Submitting orders
POST /v1/orders submits an order for one of your granted sites. This page covers the whole
lifecycle: the shape you always get back, the fast path most calls take, what a safe retry
looks like, ship-to placement, how a rejected line is reported, and the limits a request is
checked against before any of it reaches Collaterate.
One resource, three statuses
Section titled “One resource, three statuses”Every response - from the POST itself and from polling - is the same submission resource:
{ "submissionId": "…", "status": "completed", "order": { "orderNumber": …, "totalPrice": …, "lines": [ … ] } }{ "submissionId": "…", "status": "processing" }{ "submissionId": "…", "status": "failed", "errors": [ { "code": "order_rejected", "detail": "…", "partnerLineId": "…" } ] }There is deliberately no second shape for “still working” and a third for “here is your
finished order.” A 201/202 split, with a differently-shaped body on each side, is the more
obvious design - and it is the one that quietly breaks in production. The 202 branch is the
one you write once, rarely see in staging because most calls finish fast there too, and
therefore the one whose parsing bug ships unnoticed. Collapsing to one shape means the code
path that reads a processing submission is the same code path that reads a completed one
- there is no untested branch waiting for your first big order to find it.
status is one of:
status |
Meaning | Terminal? |
|---|---|---|
queued |
Accepted, not yet picked up by a worker. | No |
processing |
A worker is submitting it to Collaterate. | No |
completed |
Created. order is populated. |
Yes |
failed |
Rejected. errors is populated. |
Yes |
completed and failed never revert. Once you see either, stop polling that submission.
The fast path
Section titled “The fast path”Order submission calls into Collaterate synchronously underneath, and that call is
structurally slow for a large order: per-line tax lookups and event dispatch push wall time up
with line count, measured median 5-8 seconds and p95 10-20 seconds. POST /v1/orders waits up
to 20 seconds for a terminal state before answering, so the large majority of calls -
roughly 19 in 20 - return completed or failed in one round trip, indistinguishable from a
synchronous API:
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d @order.json \ https://api.partners.collaterate.com/v1/orders{ "submissionId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "status": "completed", "order": { "orderNumber": 1000234, "status": "NEW", "totalPrice": 245.5, "projectId": 88213, "lines": [ { "partnerLineId": "acme-line-1", "quantity": 500, "lineListTotal": 245.5, "lineTotalBeforeAdjustments": 245.5 } ] }}If the 20-second window elapses first, you get the same shape with a non-terminal status, a
Retry-After: 2 header, and a Location header naming where to poll:
HTTP/1.1 201 CreatedContent-Type: application/jsonRetry-After: 2Location: /v1/orders/submissions/3fa85f64-5717-4562-b3fc-2c963f66afa6
{ "submissionId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "status": "processing" }Poll GET /v1/orders/submissions/{submissionId} with backoff, honoring Retry-After: 2 on
every non-terminal response, until status is completed or failed:
curl -s -H "Authorization: Bearer $TOKEN" \ https://api.partners.collaterate.com/v1/orders/submissions/3fa85f64-5717-4562-b3fc-2c963f66afa6That call returns the identical Submission shape as the original POST - not a different
“status” endpoint with its own fields.
Idempotency: partnerOrderId is the key
Section titled “Idempotency: partnerOrderId is the key”partnerOrderId is your own identifier for the order and the idempotency key for the whole
call. The rule is simple and worth internalizing before your first retry, not after:
-
Resubmit the exact same body under a
partnerOrderIdyou already used, and it is always safe. You get back200, not201, with the ORIGINAL submission at whatever stage it has reached -queued,processing, or a terminal state. This is true even if your first call timed out on your side before you saw the response; the record was already durably written, so retrying is the correct thing to do, not a risk. -
Resubmit a DIFFERENT body under a
partnerOrderIdyou already used, and it is rejected. You get409 partner_order_id_reusedrather than a second order silently created, or your new request silently discarded:{"type": "about:blank","title": "Conflict","status": 409,"code": "partner_order_id_reused","detail": "This partnerOrderId was already used with a different request body. Use a new partnerOrderId, or resend the original body to poll the existing submission.","requestId": "8f3c1e2a-3d4b-4a1e-9f77-2b6c5e0a1d33"}The fix is either of the two the
detailnames: use a newpartnerOrderIdfor a genuinely different order, or resend the exact original body if you meant to poll.
201 versus 200 tells you which case you hit: 201 means this call created the record,
200 means it already existed. Neither status code, by itself, tells you whether the order is
finished - read status for that.
Each line carries its own partnerLineId, your identifier for that line. It must be unique
within the request, is echoed back on order.lines[].partnerLineId in a completed response,
and is the field a per-line rejection names in errors[] - so you can always match a result or
a failure back to the line you sent, without relying on array position.
Ship-to: order level, or every line, never both
Section titled “Ship-to: order level, or every line, never both”Every submission needs a shipTo - a full shipping address - either once at the order level or
once on every line. Mixing the two, or omitting it entirely, is rejected as
invalid_request.
{ "name": "Jane Doe", "company": "Acme Co", "address1": "123 Main St", "address2": "Suite 400", "city": "Springfield", "state": "IL", "postalCode": "62701", "country": "US", "phone": "555-0100"}email is accepted only on the order-level shipTo. It becomes the order’s
ship-notification address, and there is no per-line equivalent to deliver it to - so rather
than silently drop a line-level email, the request is rejected outright. If you send a
per-line shipTo for every line, none of them may carry email.
Validation failures are terminal - resubmit under a new id
Section titled “Validation failures are terminal - resubmit under a new id”A submission that Collaterate rejects comes back as status: failed, with one entry per
problem in errors[]:
{ "submissionId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "status": "failed", "errors": [ { "code": "order_rejected", "detail": "Insufficient stock for item 'acme-line-2': requested 2, available -48. Backordering is disabled for this site.", "field": "orderItems.quantity", "partnerLineId": "acme-line-2" } ]}code here is not a Problem.code from the error catalog - it is a
SubmissionError.code, describing why an accepted, well-formed request could not be turned
into an order. order_rejected is the value you will see in practice. field and
partnerLineId are present when the rejection names a specific field or line; detail is
prose and may be reworded without notice, same as everywhere else in this API.
failed is terminal. There is nothing to poll further and no automatic retry. Fix whatever
errors[] describes and submit again - under a new partnerOrderId. Reusing the failed
one resubmits the identical, still-invalid body and gets you back the identical 409 or the
identical failed result, not a fresh attempt.
This is a validation-time rejection unrelated to a request that never reached Collaterate at
all: a malformed body - a missing field, an unknown key, an out-of-range value - is rejected
synchronously as 400 invalid_request before a submission record is ever created. failed
means the request was accepted and something about the order was rejected downstream; 400
means the request itself never got that far. See Errors for the full
code catalog, including ordering_not_provisioned - the other 409, returned when the site
has no ordering user configured yet.
Reconciling: list your submissions
Section titled “Reconciling: list your submissions”GET /v1/orders/submissions is a keyset-paginated list of every submission you have created,
newest first. Use it to discover submissions or reconcile the full set - once you have a
specific submissionId, poll it directly rather than paging through this list to find it
again:
curl -s -H "Authorization: Bearer $TOKEN" \ "https://api.partners.collaterate.com/v1/orders/submissions?status=failed&limit=50" | jq{ "submissions": [ { "submissionId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "status": "completed", "order": { "orderNumber": 1000234, "status": "NEW", "totalPrice": 245.5, "projectId": 88213, "lines": [ { "partnerLineId": "acme-line-1", "quantity": 500, "lineListTotal": 245.5, "lineTotalBeforeAdjustments": 245.5 } ] } }, { "submissionId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "status": "queued" } ], "nextCursor": null}status filters to an exact value (queued, processing, completed, or failed); limit
defaults to 50 and rejects anything above 100 rather than silently capping it; cursor is
opaque - pass it back exactly as received and terminate your loop on nextCursor: null, same
convention as every other paginated list in this API.
Limits
Section titled “Limits”Checked before a submission record is ever created, so a request that violates one of these
never reaches Collaterate at all - it comes back as 400 invalid_request naming the exact
field:
| Limit | Value |
|---|---|
| Request body size | 256KB, measured in UTF-8 bytes |
| Lines per order | 1-100 |
partnerOrderId length |
1-100 chars, printable ASCII only |
partnerLineId length |
1-100 chars, printable ASCII only, unique within the request |
productId length |
up to 24 chars, matching `^(SLO |
sku length |
up to 100 chars |
jobName length |
up to 200 chars |
artworkUrl length |
up to 2000 chars |
templateVariables entries |
up to 50, key up to 100 chars, value up to 2000 chars |
templateVariables keys |
__proto__, constructor, prototype are rejected, reserved for the JS prototype chain |
shipTo.name / .company / .address1 / .address2 / .city |
up to 200 chars each |
shipTo.state |
up to 100 chars |
shipTo.postalCode |
up to 20 chars |
shipTo.phone |
up to 40 chars |
shipTo.email |
up to 254 chars |
shipTo.country |
exactly 2 characters - send an ISO 3166-1 alpha-2 code |
The body-size cap is measured in bytes, not characters: a body full of multi-byte UTF-8 text
can sit under a character-count limit while its real byte size is already over it, and bytes
are what the stored record and the wire actually count. siteId, quantities, and variantId
must be positive integers; there is no numeric ceiling on them beyond that.
Any top-level or line-level key not in this contract is rejected outright rather than silently
ignored - including unitPrice, on either the order or a line. See the next section for why.
No price, ever
Section titled “No price, ever”There is no price field anywhere in a submission request, at the order level or on any line.
Collaterate always prices from its own catalog and site rules; the priced result comes back on
order once a submission completes - order.totalPrice and, per line,
order.lines[].lineListTotal and .lineTotalBeforeAdjustments. Sending a price would let a
partner set their own, so the field does not exist to send: unitPrice is rejected as an unknown
key if you include it.
New optional fields - on the request or on the response - can be added inside v1 at any time without notice; see Versioning for the additive-change rule your client needs to tolerate that safely.
- Errors - the full code catalog, including the
409s above. - Tracking and line items - once
orderNumberexists, how to follow the order to shipment. - Rate limits and quotas - relevant if you poll a submission or the list endpoint on a tight schedule.