Skip to content

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.

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.

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:

Terminal window
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 Created
Content-Type: application/json
Retry-After: 2
Location: /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:

Terminal window
curl -s -H "Authorization: Bearer $TOKEN" \
https://api.partners.collaterate.com/v1/orders/submissions/3fa85f64-5717-4562-b3fc-2c963f66afa6

That call returns the identical Submission shape as the original POST - not a different “status” endpoint with its own fields.

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 partnerOrderId you already used, and it is always safe. You get back 200, not 201, 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 partnerOrderId you already used, and it is rejected. You get 409 partner_order_id_reused rather 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 detail names: use a new partnerOrderId for 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.

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:

Terminal window
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.

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.

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.