Place an order whose items ship to several addresses. Give the full item list once, then a shipment per destination naming the item indices that go there — every item in exactly one shipment. Each shipment is placed as its own order through the same path POST /orders/bulk uses (priced, charged, routed, and tracked independently, with quote honoring, spend caps, fraud gating, and webhooks identical to a batch), and the response groups them under group_id with order_ids and one row per shipment: shipment_id, index, status, order_id, portal_order_id, tracking_url, charged, error, address, items. Partial success is a 200: a shipment the platform refuses fails its own row and charges nothing while the others proceed; on a live key that includes a shipment whose address cannot be right as written (POST /addresses/validate's check), whose error starts address_invalid:. A test key places real sandbox orders that run the time-compressed lifecycle; a live key charges and routes to production — the same code path in both modes. At most 50 shipments per call. Counts as one request, against your rate limit and in your usage.
- Credential
- An API key in
x-api-key - Scopes
orders:write
Parameters
Idempotency-Keyheader · string (uuid)Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422
idempotency_key_reuserather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503idempotency_unavailablewithRetry-After, and nothing runs or is charged: retry with the same key.
Request body application/json, required
itemsobject[]required1 to 100 entries
items[].skustringrequirednon-empty
items[].product_idintegerCatalog product ID
at least 1
items[].quantityintegerrequired1 to 10000
items[].sizestringProduct size (e.g. S, M, L, XL)
items[].colorstringProduct color
items[].design_namestringDesign/artwork name
items[].artwork_urlstring (uri)URL to artwork file
items[].print_filestringURL or asset ID (ast_*)
items[].notesstringItem-level notes
items[].artworkobjectOptional: how this line's artwork was made, and who approved it and when. A declaration you state, stored on the line as sent (approved_by sanitized, approved_at in UTC), echoed on the order's reads (
line_items[].artwork) and on itsorder.createdwebhook. It never changes the price, the routing or the artwork check, and a line without it is placed exactly as before. Strict: an unknown key is a 400 validation_error.items[].artwork.provenancestringHow the artwork was made:
human(made by a person),ai_assisted(a person made it with a model's help) orai_generated(a model made it).One of
humanai_assistedai_generateditems[].artwork.approved_bystringWho approved the artwork for print: a name or an email address, at most 200 characters. Control and invisible formatting characters are removed and whitespace collapsed before it is stored. Send it with approved_at.
non-empty · at most 200 characters
items[].artwork.approved_atstring (date-time)When the artwork was approved: an ISO 8601 date-time with a time zone, not in the future. Stored and echoed in UTC. Send it with approved_by.
shipmentsobject[]requiredat least 1 entry
shipments[].addressobjectrequiredshipments[].address.namestringrequirednon-empty
shipments[].address.companystringshipments[].address.address1stringrequirednon-empty
shipments[].address.address2stringshipments[].address.citystringrequirednon-empty
shipments[].address.statestringrequirednon-empty
shipments[].address.zipstringrequirednon-empty
shipments[].address.countrystringrequiredISO 3166-1 alpha-2 country code
exactly 2 characters
shipments[].address.phonestringshipments[].address.emailstring (email)shipments[].item_indicesinteger[]requiredIndices into the items array for this shipment
at least 1 entry
Request
POST /api/v1/orders/multi-ship
curl -X POST "https://portal.printedsimply.com/api/v1/orders/multi-ship" \
-H "x-api-key: ps_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: create-multi-ship-order-$(date +%s)" \
-d '{
"items": [
{
"sku": "sticker-die-cut-3x3",
"quantity": 50,
"artwork_url": "https://example.com/logo.png"
},
{
"sku": "sticker-die-cut-3x3",
"quantity": 50,
"artwork_url": "https://example.com/logo.png"
}
],
"shipments": [
{
"address": {
"name": "Jane Doe",
"address1": "123 Main St",
"city": "Austin",
"state": "TX",
"zip": "78701",
"country": "US"
},
"item_indices": [
0
]
},
{
"address": {
"name": "Sam Lee",
"address1": "420 Pine St",
"city": "Seattle",
"state": "WA",
"zip": "98101",
"country": "US"
},
"item_indices": [
1
]
}
]
}'Response 200
Multi-ship group placed — one row per shipment, partial success inside a 200. A shipment our plant gave no answer about fails alone, uncharged, with POST /v1/orders/bulk's error starting plant_unreachable:, and the 200 carries Retry-After.
data
group_idstringrequiredThe group the per-shipment orders were placed under (msh_…)
order_idsstring[]requiredThe order ids of the shipments that succeeded
shipmentsobject[]requiredshipments[].shipment_idstringrequiredshipments[].indexintegerrequiredPosition of this shipment in the request's shipments array
shipments[].statusstringrequiredOne of
successerrorshipments[].order_idstring | nullThe order placed for this shipment, on success
shipments[].portal_order_idstring | nullshipments[].tracking_urlstring (uri) | nullshipments[].chargedstring | nullshipments[].errorstring | nullWhy this shipment was refused, on error: POST /v1/orders/bulk's row error, so
plant_unreachable:means our plant gave no answer about a SKU and nothing was charged for it.shipments[].error_codestring | nullPOST /v1/orders/bulk's row
error_code: the codeerrorstarts with, the code POST /order answers the same refusal with. Null on success, and on an error whoseerrorstarts with no code.One of
address_invalidauthentication_requireddestination_unavailableduplicate_referencefraud_reviewfulfillment_errorinvalid_quoteorder_errorpayment_failedplant_unreachablepricing_errorproduction_reconciliation_requiredprovider_cannot_produceprovider_unavailablequote_mismatchreference_check_unavailablespend_cap_exceededunknown_skushipments[].address_checkobject | anyPOST /order's
address_checkfor this shipment's address, warnings included. A live shipment with anerrorfinding is refused (error_code: address_invalid) before anything is charged. Null only where the check did not run.shipments[].address_check.validbooleanrequiredFalse when any finding is an
error.shipments[].address_check.checkedbooleanrequiredFalse for an address outside the US, which is passed through as written.
shipments[].address_check.normalizedobjectrequiredThe address in USPS style: upper case, no punctuation, ZIP+4 hyphenated, the state as its code, a unit typed into address1 moved to address2. Name, company, phone and email are trimmed and otherwise as sent.
shipments[].address_check.normalized.namestringshipments[].address_check.normalized.companystringshipments[].address_check.normalized.address1stringrequiredshipments[].address_check.normalized.address2stringshipments[].address_check.normalized.citystringrequiredshipments[].address_check.normalized.statestringrequiredshipments[].address_check.normalized.zipstringrequiredshipments[].address_check.normalized.countrystringrequiredshipments[].address_check.normalized.phonestringshipments[].address_check.normalized.emailstringshipments[].address_check.findingsobject[]requiredshipments[].address_check.findings[].codestringrequiredThe
delivery_point_*codes come from delivery-point verification, which only a live POST /order, POST /orders/bulk entry or PUT /order/{id}/address runs, on a US address the other checks pass, and only where the deployment has USPS verification configured:delivery_point_not_found(error) — USPS answered that no delivery point exists at the street address;delivery_point_incomplete(warning) — the building exists but its unit is missing or not confirmed, or more than one address matched;delivery_point_confirmed(info). When USPS cannot be reached the order proceeds without them.One of
zip_formatstate_unknownzip_state_mismatchzip_state_borderzip_unassignedpo_boxmilitary_statemilitaryaddress2_splitnot_checkeddelivery_point_not_founddelivery_point_incompletedelivery_point_confirmedshipments[].address_check.findings[].severitystringrequirederror: the address cannot be right as written, and a live order or quote is refused with 422 address_invalid.warning: worth a look, never refused.info: what normalization did, or that nothing was checked.One of
errorwarninginfoshipments[].address_check.findings[].fieldstringrequiredOne of
address1address2citystatezipcountryshipments[].address_check.findings[].messagestringrequiredshipments[].estimated_deliveryobject | nullOn success: when this shipment's order should arrive, as POST /order's
estimated_deliverydates it. Null on a refused shipment and on a lane that is not one of our own facilities.shipments[].estimated_delivery.earlieststring (date)requiredYYYY-MM-DD, UTC: the soonest the order should arrive.
shipments[].estimated_delivery.lateststring (date)requiredYYYY-MM-DD, UTC: the latest it should, before anything goes wrong.
shipments[].estimated_delivery.ships_bystring (date)requiredYYYY-MM-DD, UTC: the day it should leave the facility, when its production estimate ends, counted the same way.
shipments[].estimated_delivery.is_estimatebooleanrequiredAlways true: say it as an estimate wherever it is shown.
Always
trueshipments[].addressobjectrequiredshipments[].address.namestringrequirednon-empty
shipments[].address.companystringshipments[].address.address1stringrequirednon-empty
shipments[].address.address2stringshipments[].address.citystringrequirednon-empty
shipments[].address.statestringrequirednon-empty
shipments[].address.zipstringrequirednon-empty
shipments[].address.countrystringrequiredISO 3166-1 alpha-2 country code
exactly 2 characters
shipments[].address.phonestringshipments[].address.emailstring (email)shipments[].itemsobject[]requiredThe items that ship to this address
total_itemsintegerrequiredtotal_shipmentsintegerrequiredsucceededintegerrequiredfailedintegerrequiredtest_modebooleanrequired
meta
pageintegertotalintegerlimitintegerThe page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.
pagesintegertotaloverlimit, rounded up.next_cursorstring | nullOn a cursor-paged operation: send this back as
cursorfor the next page. Null on the last page. Absent on operations that do not page by cursor.has_morebooleanOn a list operation whose
metaisListMeta: whether another page follows this one.
{
"status": "success",
"data": {
"group_id": "msh_4uWll5So4Wdjsd0Duh7TQtBN",
"order_ids": [
"test_ord_8zJ_6hfCTgPoJN4qw3R7zXKn",
"test_ord__w1OSQwtOy5IVUF4Z7Zx0gyn"
],
"shipments": [
{
"shipment_id": "shp_IBpsk1RQj3kzZYkcBgSLl2Zo",
"index": 0,
"status": "success",
"order_id": "test_ord_8zJ_6hfCTgPoJN4qw3R7zXKn",
"portal_order_id": "ord_8zJ_6hfCTgPoJN4qw3R7zXKn",
"tracking_url": "https://portal.printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfbTVNUUN4eXlyWWVXMDdXN1Q2RWh2WXc4IiwiZXhwIjoiMjAyNy0wMS0xM1QxNTowMDoxOS4wMDBaIn0.68435d27ec0cb6ce5420fe82993e831b58dadf2b86100d464195bf6c8489e93e",
"charged": "$0.00",
"error": null,
"error_code": null,
"address_check": {
"valid": true,
"checked": true,
"normalized": {
"name": "Jane Doe",
"address1": "123 MAIN ST",
"city": "AUSTIN",
"state": "TX",
"zip": "78701",
"country": "US"
},
"findings": []
},
"estimated_delivery": {
"earliest": "2026-10-23",
"latest": "2026-10-29",
"ships_by": "2026-10-19",
"is_estimate": true
},
"address": {
"name": "Jane Doe",
"address1": "123 Main St",
"city": "Austin",
"state": "TX",
"zip": "78701",
"country": "US"
},
"items": [
{
"sku": "sticker-die-cut-3x3",
"artwork_url": "https://example.com/logo.png",
"quantity": 50
}
]
},
{
"shipment_id": "shp_K44HhNfmUG5JUKJz2R9iXbbk",
"index": 1,
"status": "success",
"order_id": "test_ord__w1OSQwtOy5IVUF4Z7Zx0gyn",
"portal_order_id": "ord__w1OSQwtOy5IVUF4Z7Zx0gyn",
"tracking_url": "https://portal.printedsimply.com/track/eyJ2IjoxLCJ0IjoidHJhY2tpbmciLCJ0b2siOiJ0cmtfVzVWdDk4NTlUc3JvZWt1QkptRm5tYnAyIiwiZXhwIjoiMjAyNy0wMS0xM1QxNTowMDoxOS4wMDBaIn0.01b69cca187803b0988f73da9cfc9548469566673537466b5a1360a35fed516c",
"charged": "$0.00",
"error": null,
"error_code": null,
"address_check": {
"valid": true,
"checked": true,
"normalized": {
"name": "Sam Lee",
"address1": "420 PINE ST",
"city": "SEATTLE",
"state": "WA",
"zip": "98101",
"country": "US"
},
"findings": []
},
"estimated_delivery": {
"earliest": "2026-10-23",
"latest": "2026-10-29",
"ships_by": "2026-10-19",
"is_estimate": true
},
"address": {
"name": "Sam Lee",
"address1": "420 Pine St",
"city": "Seattle",
"state": "WA",
"zip": "98101",
"country": "US"
},
"items": [
{
"sku": "sticker-die-cut-3x3",
"artwork_url": "https://example.com/logo.png",
"quantity": 50
}
]
}
],
"total_items": 2,
"total_shipments": 2,
"succeeded": 2,
"failed": 0,
"test_mode": true
},
"request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}Errors
Invalid request body or parameters. When the request failed the operation's schema,
detailscarriesformErrors(problems with the request as a whole),fieldErrors(messages keyed by top-level field) anderrors: one{ pointer, code, message }per refused value,pointeran RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) —/items/0/sku, with~written~0and/written~1, and""for the request as a whole. POST /v1/orders/bulk answerserrorsas one{ index, details }per refused order, eachdetailswith its own pointers from the request body (/orders/3/items/0/sku); POST /v1/orders/multi-ship keepserrorsas the{ formErrors, fieldErrors }it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top ofdetails, witherrorsbeside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it inmessageand carries noerrors.Missing, invalid or expired API key:
missing_api_key(none sent),invalid_api_key(unknown or revoked),key_expired(past the expiry set when it was created; create a new key).402Payment failed or no payment method configured
403sandbox_onlysandbox_only — the account is restricted to sandbox mode and the request used a live key. Every shipment is placed through POST /v1/orders/bulk, which refuses the whole batch before any shipment is placed, and that refusal is answered here unchanged.
payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.
variable_data_invalid or variable_data_unavailable — an item's
variable_datawas refused by POST /v1/orders/bulk, which answers before any shipment is placed or charged; the refusal is answered here unchanged, and each problem'sorder_indexis the shipment's position inshipments. sku_not_available — an item is a product our own plant does not make; refused before any shipment is placed or charged, anddetails.skusnames each one.Rate limit exceeded. Two windows can produce this status and
error.codesays which:rate_limit_exceededfor the per-minute sliding window,daily_quota_exceededfor the plan's daily quota. Both carryRetry-After; the daily one also carriesX-Daily-LimitandX-Daily-Remaining. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.internal_error — the placement answered without per-shipment results, so this response cannot say which shipments were placed. Check GET /v1/orders before retrying.
idempotency_unavailable — the request carried an
Idempotency-Keyand the ledger that makes it mean at-most-once could not be reached, so nothing ran and nothing was charged; retry the same request with the same key afterRetry-After.