Partners

Team orders and webhooks

One bundle, many recipients, one charge. The corporate gifting engine behind Printonic for Teams, for platforms and agencies that want personalized product without a factory.

Capability and billing

Every call below takes a partner key (Authorization: Bearer pk_live_...) bound to a Printonic account and holding the team_orders capability (on by default for new keys; ask Printonic to enable it on an older one). Charges follow the key's billing mode exactly like POST /api/external/orders: prepaid (wallet, then the account's default saved card) or net terms (down to the credit limit, settled through POST /api/external/billing/settle). Pricing, holds, claim links, the printed card, customs, rush and kit assembly are the same code the Printonic dashboard runs.

Rate limits are per key. idempotency_key is required on create; the same key with the same body replays the original result, the same key with a different body is a 409.

POST /api/external/team-orders

items (1 to 10): your merchant SKUs by merchant_sku_id or sku_code (what custom-listings and gift-sets return), or a merchant_product_id with fixed_options (variation id to option id) and choice_variation_ids the recipient picks on the claim page. Every recipient receives the whole bundle.

recipients (1 to 100): either every recipient carries an address (ships after payment) or none does (name + email; each gets a personal claim link and enters their own address; nothing ships until it arrives). open_seats adds shared-link seats in link mode. destination_countries lists where link recipients may be (US, CA, GB, AU, DE, FR, NL, IT, ES); addressed recipients are validated per country. card puts a printed 5x7 card in every parcel; event carries the date, rush and kit assembly; po_number and reference come back on every read and in the manifest.

POST /api/external/team-orders
Authorization: Bearer pk_live_...
Content-Type: application/json

{
  "idempotency_key": "acme-holiday-2026-batch-1",
  "name": "Acme holiday gifts",
  "reference": "ACME-2026-12",
  "po_number": "PO-4471",
  "items": [
    { "sku_code": "PRNTC-7K2M-9QX4", "quantity": 1 },
    { "merchant_product_id": 4242, "quantity": 1,
      "fixed_options": { "17": 91 },
      "choice_variation_ids": [900] }
  ],
  "recipients": [
    { "name": "Jane Doe", "email": "jane@acme.com", "company": "Acme" },
    { "name": "Sam Lee",  "email": "sam@acme.com", "gift_message": "Thanks for a great year" }
  ],
  "destination_countries": ["US", "CA", "GB"],
  "claim_expiry_days": 30,
  "gift_sender": "Acme Inc",
  "gift_message": "Happy holidays from all of us",
  "card": { "message": "Happy holidays from all of us", "sender": "Acme Inc" },
  "event": { "date": "2026-12-12", "rush": false, "kit_assembly": true },
  "return_claim_links": false
}

An addressed recipient looks like this (all recipients must then carry an address):

{ "name": "Jane Doe", "company": "Acme", "quantity": 1, "ship_on": "2026-12-01",
  "address": { "address1": "1 Main St", "city": "Austin", "state": "TX", "zip": "78701", "country": "US" } }
201 Created
{
  "team_order": {
    "id": 77, "name": "Acme holiday gifts", "reference": "ACME-2026-12",
    "po_number": "PO-4471", "quote_number": "Q-20260904-3F9A1C",
    "status": "collecting_addresses", "address_mode": "claim_links",
    "recipient_count": 2, "destination_countries": ["US", "CA", "GB"],
    "printed_card": true, "claim_expires_at": "2026-10-04T12:00:00.000Z",
    "event": { "event_date": "2026-12-12", "rush": false, "fees_cents": 300 },
    "summary": { "total": 2, "awaiting_address": 2, "claimed": 0, "shipped": 0, "delivered": 0 },
    "totals": { "subtotal_cents": 5800, "shipping_cents": 1998, "event_fees_cents": 300, "total_cents": 8098 }
  },
  "recipients": [
    { "recipient_id": 501, "name": "Jane Doe", "email": "jane@acme.com",
      "status": "awaiting", "status_label": "Waiting for address",
      "claim": { "status": "awaiting", "expires_at": "2026-10-04T12:00:00.000Z" },
      "shipment": { "order_id": 9001, "external_id": "ord_00009001", "status": "submitted",
                    "awaiting_address": true, "tracking_number": null } }
  ],
  "totals": { "subtotal_cents": 5800, "shipping_cents": 1998, "event_fees_cents": 300,
              "total_cents": 8098, "charged_cents": 8098 },
  "billing": { "billing_mode": "net_terms", "wallet_cash_cents": 8098, "card_charged_cents": 0,
               "new_cash_balance_cents": -8098, "payment_transaction_id": 31337 }
}

Claim links are sensitive. They are the recipient's only credential and are never returned unless the request carries return_claim_links: true (or the read carries ?include=claim_links). Store them like passwords; Printonic emails them to recipients itself.

Reading a team order

  • GET /api/external/team-orders?limit=&offset=&status=: newest first, with shipment and claim rollups. status is one of unpaid, collecting_addresses, in_progress, shipped, delivered, canceled.
  • GET /api/external/team-orders/{id}: the team order, its summary (waiting for an address, claimed, expired links, shipped, delivered, canceled) and every recipient with claim status, shipment stage, carrier, tracking, shipped and delivered timestamps, address, money, and event.on_time against the event date.
  • GET /api/external/team-orders/{id}/manifest.csv: the finance spreadsheet (recipient, address, claim status, order status, carrier, tracking, dates, items, money, PO and quote numbers, duties, event columns).

Cancel before production

POST /api/external/team-orders/{id}/cancel cancels every recipient still before production, or only the recipient_ids given. Each canceled shipment's share of the payment is refunded to the wallet first (on net terms that reduces the outstanding balance), then to the card. Shipments already in production, with a label, or shipped come back in skipped with the reason; when nothing could be canceled the call is a 409 nothing_to_cancel.

POST /api/external/team-orders/77/cancel
{ "recipient_ids": [502], "reason": "Recipient left the company" }

200 OK
{ "ok": true, "team_order_id": 77,
  "canceled": [ { "recipient_id": 502, "order_id": 9002, "external_id": "ord_00009002", "was_paid": true,
                  "refund": { "wallet_cents": 4049, "card_cents": 0, "total_cents": 4049 } } ],
  "skipped": [], "already_canceled": [], "refunded_cents": 4049 }

Webhooks

Register an https endpoint with POST /api/external/webhooks (at most 10 per key). The signing secret is shown once. GET /api/external/webhooks lists endpoints with pending and dead counts, GET /api/external/webhooks/{id} adds the last 50 deliveries, DELETE /api/external/webhooks/{id} disables one, and POST /api/external/webhooks/{id}/test sends a signed ping right away.

Events: team_order.claimedteam_order.shippedteam_order.deliveredteam_order.needs_attentionorder.shippedorder.delivered

team_order.needs_attention carries data.reason: shipment_delayed (no production activity within the promised window; Printonic's team is already on it), shipment_rejected, or claim_link_expired (the recipient never entered an address; resend the link, enter the address, or cancel that gift).

POST /api/external/webhooks
{ "url": "https://gifts.example.com/printonic/webhooks",
  "events": ["team_order.claimed", "team_order.shipped", "team_order.delivered", "team_order.needs_attention"] }

201 Created
{ "endpoint": { "id": 12, "url": "https://gifts.example.com/printonic/webhooks", "status": "active", ... },
  "secret": "whsec_...",           // shown once
  "signature_header": "X-Printonic-Signature" }
POST https://gifts.example.com/printonic/webhooks
X-Printonic-Event: team_order.shipped
X-Printonic-Event-Id: evt_5f1c...
X-Printonic-Signature: t=1765000000,v1=8d3e...

{
  "id": "evt_5f1c...", "type": "team_order.shipped", "created_at": "2026-12-03T18:05:00.000Z",
  "data": {
    "team_order": { "id": 77, "name": "Acme holiday gifts", "reference": "ACME-2026-12", "recipient_count": 2 },
    "recipient": { "id": 501, "name": "Jane Doe", "email": "jane@acme.com", "selection": "L" },
    "shipment": { "order_id": 9001, "external_id": "ord_00009001", "status": "shipped", "country": "US",
                  "carrier": "USPS", "tracking_number": "9400...", "tracking_url": "https://www.aftership.com/track/9400...",
                  "shipped_at": "2026-12-03T17:40:12.000Z", "delivered_at": null },
    "occurred_at": "2026-12-03T17:40:12.000Z"
  }
}

Verify X-Printonic-Signature on every delivery: HMAC-SHA256 of "<t>.<raw body>" with your secret, compared in constant time, timestamps within 300 seconds.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyPrintonicSignature({ secret, header, rawBody, toleranceSeconds = 300 }) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return expected.length === parts.v1?.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

// Express: keep the raw body (express.raw({ type: "application/json" })) and
// compare before parsing JSON. Answer 2xx quickly; do the work asynchronously.

Deliveries are sourced and sent hourly. A non-2xx answer or a timeout (10 s) is retried after 5 min, 15 min, 1 h, 3 h, 6 h, 12 h, 24 h, 48 h; after the last attempt the delivery is dead and counted on the endpoint. Each event has a stable id; treat it as the deduplication key. URLs must be https on a public hostname (no IP literals, credentials or private networks); the host is resolved again before every send.

Error codes

HTTPcodeMeaning
400invalid_body, items_required, invalid_item, recipients_required, mixed_address_modes, gift_sender_required, invalid_event, invalid_url, invalid_eventsFix the request; nothing was created or charged.
401Missing or invalid key.
402insufficient_funds, insufficient_funds_no_card, credit_limit_exceeded, daily_spend_limit_exceeded, card_charge_failedThe team order exists unpaid (team_order_id in the body). Fund the account and retry with the SAME idempotency_key, or cancel it.
403The key is not bound to an account or lacks the team_orders capability.
404sku_not_found, team_order_not_found, recipient_not_found, endpoint_not_foundThe id or code is not on your account.
409idempotency_payload_mismatch, idempotency_outcome_unknown, nothing_to_cancel, too_many_endpoints, personalization_review_requiredSee the message; a different body needs a new key.
422invalid_recipientsRow-level errors in `errors` (recipient_index, errors). Nothing was created.
429Per-key rate limit; honour Retry-After.
503idempotency_unavailable, webhooks_unavailableRetry shortly with the same key; webhooks need the environment's signing key.

Back to the Partner overview |Walkthrough for platforms and agencies