Skip to content

Design to order

The whole path from a customer’s design to a product on its way: the designer commits a design with the publishable key, and the merchant’s server approves it, prints it and orders it with the secret key, or with the integration key of a connected store (Connecting a store). Each step is reported as an event to the merchant’s webhook endpoints, shown after the step that emits it.

1. The customer commits a design

The designer runs in the customer’s browser, on the merchant’s storefront, with the tenant’s publishable key. The customer fills in a published template, and the designer commits what they made with POST /v1/designs:

{
  "templateId": "tshirt-basic@2",
  "values": { "name": { "content": "Alexandra" } },
  "blankId": "blank_tshirt",
  "placementId": "front"
}

templateId is the published template version the customer filled in. values holds what they entered, keyed by the id of the template layer each value is for, with that layer’s properties inside (a text layer’s content, for instance). blankId and placementId name the product and the spot on it the design is for; send both or neither. A design is checked against its template when it is committed, and a design that breaks one of the template’s rules is refused with constraint_violation.

{
  "designId": "6f1c8a2b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "printFloorDpi": 150,
  "lowResolution": []
}

The design is now committed, and cannot change. lowResolution lists the pictures that print below printFloorDpi at the design’s size, the minimum print resolution the template sets, else your workspace’s. It is a warning, never a refusal. POST /v1/validate and POST /v1/proofs answer the same two fields, so a customer can be told before the design is committed. Hand its designId to the merchant’s own server with the customer’s cart, the way the storefront already passes an order along.

To show the committed design on its product in the cart, at checkout, on order pages and in emails, the merchant’s server fetches GET /v1/designs/{id}/mockup.png: the design drawn on the blank’s photo, as GET /t/{id}/mockup.png draws the template, in the view the customer previewed it in unless the request names another. Pass the design’s proofId as v and the image can be kept for good.

{
  "id": "1790000000000-0192f5a4-7c1e-4d3a-9b6e-2f4c8a1d5e70",
  "type": "design.committed",
  "createdAt": 1790000000000,
  "payload": {
    "designId": "6f1c8a2b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
    "templateId": "tshirt-basic@2",
    "apiKeyId": "3b2d9c1e-5a4f-4e7d-8c6b-1a0f9e8d7c6b"
  }
}

A fulfilment payload, the order data a printshop needs (a material, a shipping method), can only be attached by the secret or integration key. A server that needs one commits the design itself: the designer renders a proof with POST /v1/proofs, and the server commits it with POST /v1/designs, sending the proof’s proofToken and the fulfilmentPayload.

2. The merchant’s server approves it

Once the customer has paid, the merchant’s server approves the design with POST /v1/designs/{id}/approve and its secret or integration key. With no body it approves now:

{ "status": "approved" }
{
  "id": "1790000060000-5d7e1f2a-3b4c-4d5e-8f60-718293a4b5c6",
  "type": "design.approved",
  "createdAt": 1790000060000,
  "payload": {
    "designId": "6f1c8a2b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
    "approvedVia": "explicit"
  }
}

A server that commits the design itself, and has already taken payment, can approve it in the same call: "approve": true on POST /v1/designs commits it approved, so it never waits between two calls, and design.approved follows design.committed with approvedVia set to explicit. It needs the grant that approves (a secret or integration key holds it); a publishable key sending it is refused with forbidden.

To give the customer a window to change their mind, send afterDays:

{ "afterDays": 14 }
{ "status": "scheduled" }

The design stays committed, and design.approval_scheduled carries the time it will be approved. When that time comes, design.approved follows with approvedVia set to deadline, unless the design was rejected first with POST /v1/designs/{id}/reject, which emits design.rejected.

{
  "id": "1790000060000-9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
  "type": "design.approval_scheduled",
  "createdAt": 1790000060000,
  "payload": {
    "designId": "6f1c8a2b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
    "approveAfter": 1791209660000
  }
}

Correcting a design before it is ordered

A design that has not been ordered can be corrected with PATCH /v1/designs/{id}. values takes the commit’s shape and is applied on top of the design as it is; expectedRevision is the revision you read:

{
  "values": { "namn": { "content": "Merry Christmas" } },
  "expectedRevision": 1
}
{
  "revision": 2,
  "edited": true,
  "proofId": "prf_9c1e2d3f4a5b6c7d8e9f0a1b",
  "printFloorDpi": 150,
  "lowResolution": []
}

The design is back to committed, its approval cleared, and design.edited says so. The correction renders a proof of what the design now says, proofId, which becomes the design’s proof: show it to your customer with GET /v1/designs/{id}/proof, and a proof page shows it too. Values that leave the document as it is write nothing: the answer is edited: false with the current revision and proof, and the approval stands. Approve it again as above; send expectedRevision with the approval to be refused revision_changed if someone corrected it since you read it. A print made before the correction is refused print_superseded when ordered: print the corrected design again once it is approved.

{
  "id": "1790000090000-3b4c5d6e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
  "type": "design.edited",
  "createdAt": 1790000090000,
  "payload": {
    "designId": "6f1c8a2b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
    "revision": 2
  }
}

Once any print of the design has an order, the correction is refused not_editable. POST /v1/designs/{id}/duplicate makes a new committed design from it instead, with duplicatedFrom naming the source, and emits design.duplicated. The duplicate waits for you to correct and approve it.

{ "designId": "7a2d9b3c-4e5f-4a6b-9c7d-8e9f0a1b2c3d" }

When the customer asks for changes

A customer on a deciding proof page can ask for changes, and your own page can relay a customer’s request with POST /v1/designs/{id}/request-changes:

{ "message": "Could the name be larger?", "expectedRevision": 1 }

The design stays committed, a scheduled approval is cancelled, and design.changes_requested carries the request’s words. expectedRevision is the revision whose proof the customer was shown; a request about a design corrected since is refused revision_changed. Correct the design, or decide it, and the request is closed; GET /v1/designs/{id}/review answers the open one. The correction’s proof is what the customer then decides.

{
  "id": "1790000095000-4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f",
  "type": "design.changes_requested",
  "createdAt": 1790000095000,
  "payload": {
    "designId": "6f1c8a2b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
    "revision": 1,
    "message": "Could the name be larger?"
  }
}

Sending the customer back to your store to edit

A proof page you hand your customer can send them back to your store once they have asked for changes, so your store opens the design for them to correct: see Sending the customer back to the store.

3. Print the approved design

POST /v1/prints makes the print files for an approved design:

{ "designId": "6f1c8a2b-3d4e-4f5a-8b6c-7d8e9f0a1b2c" }

It answers 202 as soon as the print is reserved, with its Location, and renders it after the answer. The print reads created while it renders:

{ "printId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", "status": "created" }

Read it with GET /v1/prints/{id} until it is completed, or wait for print.completed, before you order it. To be answered once it has rendered instead, send Prefer: wait: the answer is then a 201 with the print and its job ticket, which says what to make and how to cut it. The print’s ticket says nothing about any one order: how many copies, your reference and who it ships to are each order’s, added when it is placed.

{
  "printId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
  "jobTicket": {
    "product": {
      "sku": "6f1c8a2b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
      "tenantId": "acme"
    },
    "artwork": { "templateId": "tshirt-basic@2", "name": "Front" },
    "printArea": {
      "widthMm": 90,
      "heightMm": 50,
      "bleedMm": 3,
      "cutShape": { "type": "path", "d": "M 0 0 L 90 0 L 90 50 L 0 50 Z" }
    },
    "artifact": {
      "file": "PR-2026-000123_6f1c8a2b.pdf",
      "format": "pdf",
      "colorSpace": "DeviceCMYK",
      "iccProfile": "ISOcoated_v2_eci.icc",
      "effectiveDpi": 300,
      "includesBleed": true,
      "trimMarks": false,
      "spotColors": [],
      "spotOutput": "process"
    },
    "manifest": {
      "docHash": "9f2c4e1a",
      "templateVersion": "tshirt-basic@2",
      "rendererVersion": "0.1.0",
      "fontSetHash": "a1b2c3d4",
      "iccProfile": "ISOcoated_v2_eci.icc",
      "renderedAt": "2026-09-21T14:01:40.000Z"
    }
  }
}

Cut to printArea: it is the geometry the print file was rendered against. GET /v1/prints/{id} reads the print again later.

Printing emits print.created when the print is reserved and print.completed when its files are ready. A print that could not be rendered reads failed, with its failure, and emits print.failed instead; with Prefer: wait the request answers 500 with render_failed. A print still rendering ten minutes after it started is failed too.

{
  "id": "1790000100000-2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
  "type": "print.completed",
  "createdAt": 1790000100000,
  "payload": { "printId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f" }
}

4. Submit the order

POST /v1/prints/{id}/orders sends a completed print to the printshop its design was committed to, and the order records that connection as printshopConnectionId, with your name for it as printshopName. The body is optional, and every field in it is:

  • quantity — how many copies; one when left out. It belongs to the order, not the print, so one print can be ordered again in another quantity.
  • externalRef — your own reference for the order, such as your order or line id. It is not unique, and GET /v1/orders?externalRef= finds every order under it.
  • recipient and shippingAddress — who the order is for and where it ships. The country is a two-letter ISO code. They are never written to logs.
  • variant — the ref of the blank’s variant the shopper chose, such as White / M (GET /v1/blanks/{id}/variants lists them). A blank with one variant needs none; one with several refuses an order naming none (variant_required), and a variant of another blank or a retired one is refused too.

The first four reach the printshop on the job ticket, and a retry of a failed order carries the same ones and the same variant. The printshop is sent what it needs for the variant, such as Printful’s catalogue variant id, and the order keeps a copy of the variant as it was placed (variant).

{
  "quantity": 3,
  "externalRef": "ORDER-1042",
  "variant": "v_3f9a1c2b7d4e8a01",
  "recipient": { "name": "Alice Morgan", "email": "[email protected]" },
  "shippingAddress": {
    "line1": "1 High Street",
    "postalCode": "SW1A 2AA",
    "city": "London",
    "country": "GB"
  }
}
{
  "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
  "tenantId": "acme",
  "printId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
  "designId": "6f1c8a2b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "printIds": [
    "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f"
  ],
  "prints": [
    {
      "printId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
      "designId": "6f1c8a2b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
      "designRevision": 1,
      "placementId": "front",
      "printedAs": "Front",
      "spotOutput": "process",
      "status": "completed",
      "placementFields": []
    }
  ],
  "quantity": 3,
  "externalRef": "ORDER-1042",
  "recipient": {
    "name": "Alice Morgan",
    "email": "[email protected]"
  },
  "shippingAddress": {
    "line1": "1 High Street",
    "postalCode": "SW1A 2AA",
    "city": "London",
    "country": "GB"
  },
  "adapter": "printful",
  "shipTo": null,
  "printshopConnectionId": "0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
  "printshopName": "Printful",
  "spotOutput": "process",
  "externalOrderRef": "PF-889123",
  "status": "queued",
  "lastError": null,
  "failureKind": null,
  "hold": null,
  "tracking": null,
  "submittedAt": 1790000120000,
  "lastCheckedAt": null,
  "updatedAt": 1790000121000,
  "replacesOrderId": null,
  "replacedByOrderId": null,
  "personalDataErasedAt": null,
  "blankId": "blank_classic_tee",
  "variantRef": "v_3f9a1c2b7d4e8a01",
  "variant": {
    "ref": "v_3f9a1c2b7d4e8a01",
    "options": {
      "Color": "White",
      "Size": "M"
    },
    "fields": [
      {
        "name": "catalogVariantId",
        "value": "4012",
        "source": "own"
      }
    ]
  },
  "printshopAsPlaced": {
    "name": "Printful",
    "adapter": "printful"
  },
  "delivery": null,
  "blank": {
    "name": "Classic tee",
    "optionAxes": [
      "Color",
      "Size"
    ],
    "variants": 12
  }
}

A printshop that turns the order down is still a 201: the order is recorded with status: "failed" and its reason in lastError, and order.failed is emitted. A failed order that never reached the printshop can be submitted again with POST /v1/orders/{id}/retry.

{
  "id": "1790000120000-7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f",
  "type": "order.submitted",
  "createdAt": 1790000120000,
  "payload": {
    "orderId": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
    "printId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
    "adapter": "printful",
    "quantity": 3,
    "externalRef": "ORDER-1042",
    "externalOrderRef": null,
    "replacesOrderId": null
  }
}

From here the order moves on as the printshop reports it (queued, printed, shipped), and each move is an order.status_changed. Read the order and its history at any time with GET /v1/orders/{id}.

{
  "id": "1790086400000-0f1e2d3c-4b5a-4968-8776-655443322110",
  "type": "order.status_changed",
  "createdAt": 1790086400000,
  "payload": {
    "orderId": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
    "printId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
    "status": "shipped",
    "previousStatus": "printed"
  }
}

An order whose submission timed out has the status outcome_unknown: the printshop may have accepted it, and may be making it now. It is not a failure, and submitting it again could make the product twice. Ask the printshop what happened first, then settle it with what it told you. A secret key, a connected store’s integration key or a studio member holding orders:update may do this, and a reason is required:

  • The printshop does not have it: mark it failed. POST /v1/orders/{id}/retry then submits it again.
  • The printshop has it: mark it submitted, with the printshop’s own reference for it when you were given one, so its status is followed from then on.
{
  "status": "failed",
  "reason": "The printshop has no order under this number."
}

An order in outcome_unknown that the printshop has given its reference for can also be cancelled, with POST /v1/orders/{id}/cancel, as an order in submitted or queued can.

When your customer asks you to erase their personal data, see Erasing personal data.

Questions? Write to [email protected].

Status · Terms of service · Privacy policy · Subprocessors