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:202while it renders, its job ticket once it has, andrender_failedonce 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/designstakes it from a secret or integration key (a publishable key sending it is refusedsecret_key_required).GET /v1/designs?externalRef=finds the designs holding it, andPUT /v1/designs/{id}/external-refreplaces it, for instance once a cart becomes an order:
{ "externalRef": "WEB-10482-1" }
POST /v1/prints/{id}/ordersandPOST /v1/orderstake 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].