Skip to content

Placing an order safely

A storefront sends the same request more than once. A payment hook fires twice, a job is retried after a timeout, a worker restarts halfway through. Without care, each arrival commits a second design, renders a second print or places a second physical order. This guide shows how to make every write in the path safe to send again, how to tie Pixonaut’s records to your own, and how to clean up after a cart that was never bought.

Send an Idempotency-Key with every write

POST /v1/designs, POST /v1/prints, POST /v1/prints/{id}/orders and POST /v1/orders take an optional Idempotency-Key header. Pick a key that names the thing you are making, once, and send the same key every time you send that request: your order line id and the step, for instance. A connected store sends its integration key (ik_live_…) where this shows the secret key.

POST /v1/prints/c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f/orders
Authorization: Bearer sk_live_...
Idempotency-Key: WEB-10482-1:order
Content-Type: application/json
{
  "quantity": 1,
  "externalRef": "WEB-10482-1",
  "recipient": { "name": "Alexandra Lind", "email": "[email protected]" },
  "shippingAddress": {
    "line1": "12 High Street",
    "postalCode": "SW1A 1AA",
    "city": "London",
    "country": "GB"
  }
}

Without the header, each request is a new order, held to the rule below that a design has one live order.

A key is 1 to 255 printable ASCII characters; anything else is refused 422 invalid_idempotency_key. A key is remembered for 24 hours after it is first sent. While the first request is still being answered, a repeat is refused 409 idempotency_key_in_use with a Retry-After of at most 2 seconds.

What a repeat answers

What happens to a request sent again with the same key depends on what the first one did:

The first request A repeat with the same key and body
succeeded is answered with what the first made, marked Idempotent-Replayed: true, and makes nothing
is still being answered 409 idempotency_key_in_use; wait for Retry-After and send it again
was refused (a 4xx) runs again: a refusal made nothing, so the key is free
failed for a reason of the API’s own (a 5xx) runs again, unless it had already made something, which it then answers

A key sent with a different body, or to another route, is refused 422 idempotency_key_reused, and nothing is made: a key names one request. Key order and spacing in the JSON do not count as a difference.

A repeat is answered with the thing the first request made, read as it is now, not a stored copy of the first response:

  • a commit answers { "designId": ... }, the design the first request committed, even once that design has been deleted;
  • a print answers as GET /v1/prints/{id} reads it now: 202 while it renders, its job ticket once it has, and render_failed once it failed;
  • an order answers the order as GET /v1/orders/{id} reads it, with its current status. An address erased since stays erased.

An order a partner printshop refused is still a 201, recorded as failed, so a repeat answers that failed order rather than asking the printshop again. To try a failed order again, use POST /v1/orders/{id}/retry.

Keys are shared by every secret key of your workspace, so a retry that crosses a key rotation still finds the first request. A connected store’s keys belong to its site, so a retry sent after the store reconnected, under its new integration key, still finds the first request too. A publishable key and each studio member have keys of their own.

Another caller’s order of the same design

A key belongs to the caller that sent it, so it settles your own retry, not another caller’s order. When your store orders a design at checkout and the workspace’s automation, or someone in the studio, orders the same design, the two requests carry different keys. That case is the API’s: a design has one live order, an order of it in any status but failed and cancelled. An order of prints of a design that has one, or naming such a design, is refused 409 design_already_ordered, and nothing is written:

{
  "error": "design_already_ordered",
  "designId": "6f1c8a2b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "orderId": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b"
}

orderId is the design’s newest live order: read it with GET /v1/orders/{id} rather than ordering again. Two orders of one design sent at the same moment place one, and the other is refused naming it.

A repeat of your own request with its key is checked first, so a store retrying after a timeout gets its order back with Idempotent-Replayed: true, never this refusal. To order a design again on purpose, a second shirt for the same customer, send "reorder": true in the body: the order is placed whatever orders the design has. Retrying a failed order (POST /v1/orders/{id}/retry, or replacesOrderId) needs no reorder, since a failed order is not live.

A proof token commits once

A proof token can be committed into one design only. Committing it again, with another key or none, is refused, and the answer names the design the token already became:

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

Treat this as the commit having happened: carry on with that designId. It is what a checkout that lost the first response, and retried without a key, needs to recover.

Approve and reject can be sent again

POST /v1/designs/{id}/approve and POST /v1/designs/{id}/reject take no Idempotency-Key: the design’s own status says whether the decision was made. Send the decision again, with the same expectedRevision, when you did not get its answer:

The design is Approving now again Rejecting again
approved at that revision 200 { "status": "approved" }, nothing recorded 409 not_committed
rejected at that revision 409 not_committed 200 { "ok": true }, nothing recorded
decided at another revision 409 not_committed 409 not_committed

A repeat records no second audit entry and sends no second design.approved or design.rejected. Without expectedRevision, a repeat matches the design at whatever revision it was decided, so send the revision you read when it matters which one was approved. A 409 not_committed means the design stands as some other decision left it: read it before acting.

Each decision’s event says what was decided: design.approved and design.rejected carry the revision they decided, and design.approved the consent kept with it, so a store can tell an approval of an older revision from one of the design its customer last saw without reading the design.

Tie each record to your own

Every write in the path takes your own reference, externalRef, kept exactly as you sent it, never required to be unique, and never read by Pixonaut:

  • POST /v1/designs takes it from a secret or integration key (a publishable key sending it is refused secret_key_required). GET /v1/designs?externalRef= finds the designs holding it, and PUT /v1/designs/{id}/external-ref replaces it, for instance once a cart becomes an order:
{ "externalRef": "WEB-10482-1" }
  • POST /v1/prints/{id}/orders and POST /v1/orders take it for the order, and hand it to the printshop on the job ticket. GET /v1/orders?externalRef= finds the orders holding it.

When you are unsure whether a request that timed out went through, and the key’s window has passed, look the record up by your reference before sending it again.

Delete a design that was never bought

A design committed for a cart the customer then abandoned can be deleted with DELETE /v1/designs/{id}, using the secret or integration key. It disappears from every list and read, its share links stop working, and its proof and pictures are released. It answers 204, and design.deleted is sent to your webhook endpoints:

{
  "id": "1790000000000-0192f5a4-7c1e-4d3a-9b6e-2f4c8a1d5e71",
  "type": "design.deleted",
  "createdAt": 1790000000000,
  "payload": { "designId": "6f1c8a2b-3d4e-4f5a-8b6c-7d8e9f0a1b2c" }
}

Only a design nothing was made of can go: one that was approved is refused 409 design_approved, and one that was printed 409 design_printed, whether or not it was corrected since. A design that was bought stays, with its prints and orders; erase the personal data it holds instead (Erasing personal data).

Questions? Write to [email protected].

Status · Terms of service · Privacy policy · Subprocessors