API Reference
Place orders, follow them through fulfilment, and receive signed events — without polling us or scraping the dashboard. Everything below works with a single API key you create in your account.
Overview
A JSON API for placing orders, reading their progress, and hearing about changes without polling.
Everything lives under one base URL and one version. Money is always a string with two decimals, because JSON numbers are float64 and cents do not survive that. Timestamps are ISO-8601 UTC. Field names are snake_case.
This is a server-to-server API: it authenticates with a long-lived secret, so it is not designed to be called from a browser and sends no CORS headers.
The machine-readable version of this page is an OpenAPI 3.1 document, generated from the same source and public like this page. Point Postman, Insomnia or a client generator straight at it.
https://opcreative.us/api/v1curl https://opcreative.us/api/v1/me \
-H "x-api-key: $OPCREATIVE_API_KEY"https://opcreative.us/api/v1/openapi.jsonAuthentication
One header on every request.
Create and revoke keys in your dashboard under Profile → API. A key is shown once, at creation — we store only its hash, so we cannot show it to you again and neither can anyone who reaches our database.
Coming from the old system? Your existing key will not work here — generate a new one under Profile → API and swap it into your integration. The header and everything else about the request are unchanged.
Keys can be given an expiry, and rolling one is a single action: the replacement is minted before the original is revoked, so there is no window where neither works.
Missing, wrong, revoked and expired keys all return the same 401. Saying which would confirm to a guesser that a key exists.
x-api-key: opc_live_xxxxxxxxxxxxxxxxxxxxxxxx{
"error": {
"code": "unauthorized",
"message": "That API key is not valid."
}
}Errors
One envelope, everywhere, with a stable machine-readable code.
Branch on error.code, never on error.message. Codes are append-only: a new one may appear, an existing one is never reworded or reused for a different condition. Messages are written for humans and will change.
A 500 carries error.request_id, which matches the X-Request-Id response header. Quote it to support and we can find the exact request.
{
"error": {
"code": "invalid_request",
"message": "One or more fields are invalid.",
"fields": [
{
"field": "quantity",
"message": "At least one"
}
]
}
}- 401
unauthorized - No key, or a key that is unknown, revoked or expired. One message for all four — telling you which would confirm to a guesser that a key exists.
- 403
forbidden - The key is valid but its owner may not do this.
- 404
not_found - No such record — including records that exist but belong to someone else. The API never confirms another seller's ids.
- 409
conflict - The request contradicts the current state of the record.
- 413
payload_too_large - The request body is over the limit: 2 MB for a JSON body, 50 MB for a proof photo.
- 422
invalid_request - The body or query is malformed. Field-level detail arrives in
error.fields. - 429
rate_limited - Over the per-key limit.
Retry-Aftersays how many seconds to wait. - 500
internal - Our fault.
error.request_idmatches the X-Request-Id header — quote it to support.
Rate limits
Per key, with headroom you can see.
- 600 requests per minute, counted per API KEY rather than per IP — integrations run from shared cloud addresses, and an IP limit would have one customer throttle another.
- Every response carries
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(seconds until the window resets), so you can pace yourself before you are refused. - Over the limit is a
429withRetry-Afterin seconds. Wait that long — retrying sooner only spends the next window.
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 587
X-RateLimit-Reset: 41
X-Request-Id: 8f2c1e5a-6b41-4f0e-9c1d-2a7b3e5f9d04{
"error": {
"code": "rate_limited",
"message": "Too many requests. Retry shortly."
}
}Pagination
Cursors, not page numbers.
- List endpoints are cursor paginated. Pass the
next_cursoryou were given back ascursor; anullcursor means you have reached the end. - Not page numbers: an order placed while you walk the list would shift every later row down, so a numbered page 2 would re-serve a row page 1 already gave and skip another entirely.
limitcaps at 100 for orders and 200 for the catalogue.
{
"data": [
"…"
],
"next_cursor": 48100
}let cursor = null;
do {
const url = new URL("https://opcreative.us/api/v1/orders");
if (cursor) url.searchParams.set("cursor", cursor);
const page = await fetch(url, { headers }).then((r) => r.json());
handle(page.data);
cursor = page.next_cursor;
} while (cursor);Idempotency
Retry a create as often as you like without making a second order.
POST /ordersandPOST /orders/batchrequire anIdempotency-Keyheader. Any unique string will do; it is scoped to your account, so it cannot collide with another seller's.- Replaying a key returns the ORIGINAL order and a
200instead of201. Nothing is created twice, however many times a timeout makes you retry. - Reuse the same key for every retry of the same logical order. A fresh key per attempt is the same as having no idempotency at all. The reverse holds too: a key belongs to ONE order, so the same key with a different body is refused with
invalid_requestand creates nothing, not even one row of a batch. PATCH /orders/:idandPOST /orders/:id/prooftake an OPTIONALIdempotency-Key. A retry with the same key and the same request gets the first answer back, markedIdempotent-Replayed: true, and nothing runs twice. The same key on a different request is refused withinvalid_request; a retry while the first attempt is still running gets409. Kept 24 hours.
Idempotency-Key: order-1047-attempt-1Versioning and deprecation
What v1 promises, and what would count as breaking it.
- The version is in the path.
/api/v1keeps its promises for as long as it exists; a change that would break them ships as/api/v2instead. - Additive changes are NOT breaking and can arrive any day: a new endpoint, a new optional parameter, a new field in a response, a new webhook event. Parse defensively — ignore fields you do not recognise, and do not validate that a response has exactly the keys you expect.
- Breaking means: removing or renaming a response field, removing an endpoint, making an optional parameter required, narrowing an accepted value, or changing what an existing field means.
- Error codes are append-only. A new
codemay appear in the envelope; an existing one is never reworded or reused for a different condition. Branch onerror.code, never onerror.message. - Enum-like values (
status, tracking states) may gain members. Treat an unknown one as unknown rather than as an error. - If v1 is ever retired, you get at least 180 days' notice by email to the account that owns the key, and the endpoints keep working throughout that window.
Who this key belongs to
/api/v1/meReturns the account behind the key and its current balance. Small on purpose: it exists so an integration can prove its key works and show a balance without scraping the dashboard.
tier is the pricing tier the catalogue quotes against, so a seller can see why they were charged what they were charged.
curl https://opcreative.us/api/v1/me \
-H "x-api-key: $OPCREATIVE_API_KEY"{
"data": {
"id": "clx8fh2k90000abcd1234efgh",
"name": "Bright Prints",
"email": "orders@brightprints.example",
"roles": [
"SELLER"
],
"tier": 2,
"balance": "1240.55",
"debt": "0.00",
"created_at": "2026-02-03T11:20:00.000Z"
}
}The key's owner.
What you can order, and what it costs you
/api/v1/catalogReplaces two legacy endpoints at once: GET /api/customer/metadata (what can I order?) and GET /api/v3/customer/orders/pricing (what does it cost me?). They were separate, so every integration joined them client-side and each did it slightly differently.
Prices are THIS KEY'S prices — computed against the owner's tier with the same function that charges them at assignment. A price list that differs from the invoice is worse than no price list.
When OpCreative ships, shipping_first_item is added once per parcel and shipping_add_on for every extra unit in it — also your rates. null means no shipping charge.
price_customer_ship is what you pay when YOU ship it: higher, and no postage is added. price_updated_at is when the SKU's price or postage last changed, so a periodic pull can take only what moved.
description is the product information as plain text. description_html is the same text with the formatting set in the dashboard (bold, italic, underline, size, colour, alignment) as HTML, or null when it has none. It is sanitised down to p, br, strong, em, u and span, and its colours are our theme's CSS variables (var(--red-900)) — define them, or let the text inherit yours.
add_on is the extra an order can take with add_on: true — { label, price }, the price per unit, on top of every SKU below — or null when the product offers none.
thumbnail is the product's cover and images the rest of its gallery, a list of { url, label }. Both are stored on OpCreative, so the links do not go dead.
private is true on a product made only for chosen sellers, your account among them: other sellers do not see it here, and only those sellers can order it. Everything else is the public catalogue.
kind is what you are buying: CATALOGUE (our item, made and shipped by us), FULFILMENT (your own goods, stored and shipped by us) or CONTRACT_WORK (your own blank, decorated by us, where the SKU price is the labour). A FULFILMENT product bills service_fee once per order plus service_fee_add_on for every extra SKU line in it (quantity does not count), whoever ships; both are null on every other kind. An order on a SKU that has no price yet is refused with price-missing.
skus[].id is what POST /orders wants as product_variant_id.
Query parameters
cursorinteger- Continue from a previous page — pass the
next_cursoryou were given. limitinteger- Products per page, 1–200.
curl https://opcreative.us/api/v1/catalog?cursor=412&limit=50 \
-H "x-api-key: $OPCREATIVE_API_KEY"{
"data": [
{
"id": 12,
"name": "Leather Keychain",
"key": "leather-keychain",
"thumbnail": "https://files.opcreative.us/products/12-cover.png",
"images": [
{
"url": "https://files.opcreative.us/products/12-back.png",
"label": "Back"
}
],
"description": "Genuine leather, laser engraved.\nSize: 3.5 × 1.2 in.",
"description_html": "<p><strong>Genuine leather</strong>, laser engraved.</p><p>Size: 3.5 × 1.2 in.</p>",
"template_url": "https://drive.google.com/file/d/1AbC…/view",
"add_on": {
"label": "Gift box",
"price": "1.50"
},
"private": false,
"kind": "CATALOGUE",
"service_fee": null,
"service_fee_add_on": null,
"skus": [
{
"id": 881,
"code": "HW-TRK-GK-LG__P-C",
"status": "ACTIVE",
"variant": {
"id": 4,
"name": "Black",
"key": "black"
},
"price": "12.30",
"price_customer_ship": "13.30",
"price_updated_at": "2026-10-05T12:34:42.000Z",
"shipping_first_item": "4.99",
"shipping_add_on": "1.99"
}
]
}
],
"next_cursor": 412
}A page of products, each with its orderable SKUs.
Add pictures to a product
/api/v1/products/{id}/imagesAdds pictures to a product's gallery without resending the rest — what a catalogue sync, or a workshop's own tooling, calls to keep product photos current. Admin keys only: the key's owner must be allowed to manage products, and a seller key gets 403.
The body is multipart/form-data. Send each picture as a file part (PNG, JPG or WEBP, 50 MB at most) or as a url field, which we download and store. A link to someone else's server is never saved as it is, because the catalogue goes blank the day that server stops answering. Up to 10 pictures per request: repeat the part to send several, in the order they should appear.
New pictures join the end of the gallery, which holds 20. as_cover=true makes the first one the product's cover, and the old cover moves into the gallery rather than being lost; a product with no cover gets the first picture as its cover anyway.
If any picture is refused — a link that is not a reachable image, a file that is not one — the answer is 422, naming it in message with the cause in reason, and nothing is added. A gallery with no room left answers 409.
Path parameters
idintegerrequired- The product's id, as in
GET /catalog.
Headers
Idempotency-Keystring- Optional. Reuse the same value on every retry of the same upload, so a retry after a timeout does not add the pictures twice; see Idempotency.
Body
filefile (png, jpg, webp)- A picture. PNG, JPG or WEBP, 50 MB at most. Repeat for several.
urlstring (url)- A public https link to a PNG, JPG or WEBP picture, which we download and store. Repeat for several.
as_coverbooleantrueto make the first picture the product's cover.labelstring- A caption for the new gallery pictures, e.g.
Back. 120 characters at most.
curl https://opcreative.us/api/v1/products/48213/images \
-H "x-api-key: $OPCREATIVE_API_KEY" \
-H "Idempotency-Key: images-12-attempt-1" \
-F "file=@cover.jpg;type=image/jpeg" \
-F "as_cover=true"{
"data": {
"id": 12,
"thumbnail": "https://files.opcreative.us/products/3f9c2a1b-1790000000000-0.jpg",
"images": [
{
"url": "https://files.opcreative.us/products/12-back.png",
"label": "Back"
},
{
"url": "https://files.opcreative.us/products/12-cover.png"
}
]
},
"meta": {
"added": [
"https://files.opcreative.us/products/3f9c2a1b-1790000000000-0.jpg"
]
}
}The product's cover and full gallery after the upload. meta.added lists the stored URLs of the new pictures.
List your orders
/api/v1/ordersCursor paginated and filterable. The filters are the same ones the dashboard's own table uses — one shared query builder — so a filtered list here and a filtered table there can never disagree, and no filter can widen what the key is allowed to see.
The list is deliberately lean. For every parcel, the artwork and the proof photo, read one order with GET /orders/{id}.
Query parameters
cursorinteger- Continue from a previous page — pass the
next_cursoryou were given. limitinteger- Orders per page, 1–100. Defaults to 50.
statusstring- Comma-separated fulfilment statuses. Unknown values are ignored rather than erroring.
hold_reasonstring- Comma-separated hold reasons:
DESIGN_PROBLEM,WRONG_LABEL,OUT_OF_STOCK,OTHER. Only an order currently ON_HOLD carries one, so nostatusis needed beside it. Unknown values are ignored. marketplacestring- Comma-separated shop labels, matched verbatim as they were imported.
external_idstring- Your own order id, matched as a PREFIX.
1047-ETSY-9F2finds that row; it also finds every-1,-2row a batch split it into. paidbooleantrueorfalseonly. Any other value is treated as no filter at all.warehousestring- Comma-separated warehouse ids.
placed_afterstring (date-time)- ISO-8601. Orders placed at or after this moment.
placed_beforestring (date-time)- ISO-8601. Orders placed at or before this moment.
updated_afterstring (date-time)- ISO-8601. Only orders changed after this moment, on our clock: the order itself, a new or changed shipment (label, tracking number), or an address edit. Each order carries
changed_at; send the largest value you saw as the nextupdated_after, minus a few minutes of overlap, and de-duplicate onid.
curl https://opcreative.us/api/v1/orders?cursor=48100&limit=50 \
-H "x-api-key: $OPCREATIVE_API_KEY"{
"data": [
{
"id": 48213,
"external_id": "1047-ETSY-9F2",
"marketplace": "Etsy UK",
"status": "IN_PRODUCTION",
"hold_reason": null,
"quantity": 2,
"filled": 0,
"base_cost": "24.60",
"add_on": {
"label": "Gift box",
"price": "1.50"
},
"paid": true,
"placed_at": "2026-08-11T09:14:22.000Z",
"assigned_at": "2026-08-11T10:02:00.000Z",
"fulfilled_at": null,
"deadline": "2026-08-18T00:00:00.000Z",
"changed_at": "2026-08-11T10:02:00.000Z",
"product": {
"id": 12,
"name": "Leather Keychain",
"key": "leather-keychain",
"kind": "CATALOGUE"
},
"seller": {
"id": "clx9v2a7k0000w8j0f1n3q2d1",
"name": "Lane & Co"
},
"variant": {
"id": 4,
"name": "Black",
"key": "black"
},
"sku": {
"id": 881,
"code": "HW-TRK-GK-LG__P-C"
},
"warehouse": {
"id": 2,
"code": "US-E",
"name": "US East"
},
"tracking": {
"number": "9400111899561234567890",
"status": "In transit",
"carrier": "usps"
},
"shipping_type": "Ship by OP",
"note": "Gift wrap"
}
],
"next_cursor": 48100
}A page of orders. next_cursor is null on the last page.
Create one order
/api/v1/ordersName the SKU and where it goes. The SKU decides the product, the variant and the price — sending a product id alongside would let a client name its own price, so the server reads all three from the SKU and ignores anything else you send about them.
Two ways to name what you want: product_variant_id from the catalogue, or product_id plus an options map of catalog-v2 axis codes (the server resolves those to a physical SKU, creating it on first use).
An Idempotency-Key header is REQUIRED. Without one, a dropped response leaves you unable to retry safely — which is exactly how duplicate orders get made. Replaying a key returns the original order with status 200 instead of 201.
Headers
Idempotency-Keystringrequired- Any unique string of your choosing. Scoped to your account, so it cannot collide with another seller's.
Body
external_idstringrequired- Your own order id. Not unique here — one marketplace order can split into several rows — but it is how support finds anything.
product_variant_idinteger- The SKU, from
GET /catalog. Send this ORproduct_id+options. product_idinteger- Catalog v2: the product, paired with
options. optionsobject- Catalog v2: one active value code per option axis, e.g.
{ "method": "P", "color": "KL" }. Keys are axis codes and are NOT converted — send them exactly as the catalogue lists them. quantityintegerrequired- 1–10000.
shippingobjectrequiredname,company,email,phone,line1,line2,city,state,zip,country.name,line1,city,state,zipandcountryare REQUIRED and must be the customer's real details: a placeholder such as1,x,n/aor a masked73** ****is refused withinvalid_request, anderror.fieldsnames each field.stateis the two-letter code, e.g.ILorWA— a full name such asTexas, or anything longer than two letters, is refused the same way.company,email,phoneandline2are optional.shipping_typestringrequired- REQUIRED. Who ships the parcel — exactly
Ship by OPorShip by Platform.Ship by OP: we ship it, we buy the label, and postage is added to what you pay.Ship by Platform: you or the marketplace ships it and already holds the label, so no postage is charged and the customer-ship price applies. There is no default — an order with none would be priced as though you shipped it, and no label could be bought for it. A carrier service (Standard,express) is a different thing and is refused here. Letter case does not matter;ship by opis accepted and stored asShip by OP. marketplacestring- Which shop it came from, e.g.
Etsy UK. placed_atstring (date-time)- When the marketplace took the order. Defaults to now.
deadlinestring (date-time)- When it must ship by.
image_urlstring (url)required- REQUIRED. The design to print. An order cannot be produced without one, so it is refused rather than accepted and held.
positionsarray- Optional. Where each design goes when one item carries several, or a face needs two files. One entry per decorated face:
position(FRONT,BACK,BOTTOM,INSIDE,WRAP,CENTER,LEFT,RIGHT,BASE),method(ENGRAVE,PHOTO_ENGRAVE,UV_PRINT,HEAT_TRANSFER) andfiles, each{ type: PRINT | CUT | ENGRAVE, url }. Stored exactly as sent and returned onGET /orders/{id}; URLs are never changed. It does not change the SKU or the price, and the faces are not checked against the SKU's options. mockup_urlstring (url)required- REQUIRED (or
mockup_id). The mockup picture — what the listing shows. Stored once per picture per account, so re-sending the same link reuses the same mockup rather than making another. mockup_idinteger- A mockup already in your library, from a previous order. An alternative to
mockup_url; either one satisfies the mockup requirement. add_onbooleantrueadds the product's add-on — a gift box, say — to every unit of this order, at the per-unit priceGET /catalogshows asadd_on. It is billed insidebase_cost. Refused on a product whoseadd_onisnull.notestring- Anything the floor should read.
curl https://opcreative.us/api/v1/orders \
-H "x-api-key: $OPCREATIVE_API_KEY" \
-H "Idempotency-Key: order-1047-attempt-1" \
-H "Content-Type: application/json" \
-d '{
"external_id": "1047-ETSY-9F2",
"marketplace": "Etsy UK",
"product_variant_id": 881,
"quantity": 2,
"image_url": "https://cdn.example.com/designs/1047.png",
"mockup_url": "https://cdn.example.com/mockups/1047.png",
"shipping_type": "Ship by OP",
"note": "Gift wrap",
"shipping": {
"name": "A. Recipient",
"line1": "18 Example Street",
"city": "Washington",
"state": "DC",
"zip": "20002",
"country": "US"
}
}'{
"data": {
"id": 48213
}
}Created. Status 200 instead when the Idempotency-Key was a replay.
Match rows to catalog SKUs
/api/v1/orders/matchAI optionalTurn the data you already have — a listing title, a variant string, a SKU — into the product_variant_id or product_id + options that POST /orders/batch expects. Nothing is created: this returns suggestions with a confidence, and you decide what to send.
Two deterministic passes run on every request and cost nothing: an exact SKU reference, then an exact product-plus-option-label match. Well-formed rows resolve here.
The AI pass is BRING YOUR OWN KEY. Send your own provider credentials in the X-AI-* headers and the leftover rows go to your model, on your bill — we never charge our own AI to your requests, and your key is used for that one call and never stored. Send no X-AI-* headers and you get the free passes only, with the rest reported as unmatched.
claude-sonnet-5 is the recommended model and what this endpoint is tested against. Matching quality is the model's, so a cheaper one matches worse.
This endpoint carries its own rate limit of 200 requests per minute, tighter than the API's usual 600. One match call builds the whole catalog digest and runs up to 200 rows, so it costs far more than a plain read. It counts in a separate bucket, so matching never eats the budget for reading your orders.
Headers
X-AI-Providerstringanthropic(default) oropenai-compatible. Omit everyX-AI-*header to skip the AI pass.X-AI-Keystring- Your own provider key. Used for this request only — never stored, never logged, never returned.
X-AI-Modelstring- The model id, e.g.
claude-sonnet-5. Required whenever a key is sent. X-AI-Base-Urlstring- Required for
openai-compatible— the endpoint your provider serves, e.g.https://api.openai.com/v1.
Body
rowsarrayrequired- 1–200 objects. Loose key/value pairs, exactly as your export has them —
product,variant,sku,quantity, or your own column names. Unknown keys are ignored rather than rejected.
curl https://opcreative.us/api/v1/orders/match \
-H "x-api-key: $OPCREATIVE_API_KEY" \
-H "X-AI-Provider: anthropic" \
-H "X-AI-Key: sk-ant-…" \
-H "X-AI-Model: claude-sonnet-5" \
-H "X-AI-Base-Url: https://api.openai.com/v1" \
-H "Content-Type: application/json" \
-d '{
"rows": [
{
"product": "Personalized Leather Keychain",
"variant": "Tan / Center",
"quantity": 2
},
{
"sku": "HW-TRK-GK-LG__P-C",
"quantity": 1
}
]
}'{
"data": {
"matched": 1,
"suggested": 1,
"unmatched": 0,
"ai_used": true,
"results": [
{
"row": 0,
"status": "suggested",
"via": "ai",
"confidence": 0.92,
"product_variant_id": null,
"product_id": 41,
"sku": null,
"product": "Leather Keychain",
"options": {
"color": "TN",
"print_area": "C"
},
"option_labels": [
{
"axis": "Colour",
"label": "Tan"
},
{
"axis": "Print area",
"label": "Center"
}
]
},
{
"row": 1,
"status": "matched",
"via": "sku",
"confidence": 1,
"product_variant_id": 881,
"product_id": null,
"sku": "HW-TRK-GK-LG__P-C",
"product": "Trucker Hat",
"options": {},
"option_labels": []
}
]
}
}Per-row results. status is matched (deterministic, confidence 1), suggested (your AI proposed it) or unmatched. Every suggestion is re-verified against the catalog before it is returned.
Create one multi-item order
/api/v1/orders/batchOne shipping block, many items — the replacement for legacy's POST /api/v2/customer/orders/multi. Each item becomes its own order row, suffixed -1, -2, … off the shared external_id, which is the convention legacy sellers already handle. A single-item batch is NOT suffixed: the same order should not arrive under two different ids depending on how it was sent.
Omit external_id and one is generated for you (legacy did this too) — but an id you chose is the one support can search for.
It reports PER ROW. A batch whose third item names a dead SKU creates the other items and tells you about the third; it is never all-or-nothing and never silently partial. Read results[], not just the status code.
The Idempotency-Key is derived per row, so retrying a batch that half-succeeded creates exactly the missing half.
At most 500 items per request.
Headers
Idempotency-Keystringrequired- Any unique string. Each row derives its own key from it.
Body
external_idstring- Your marketplace order id. Generated if omitted.
shippingobjectrequired- Shared by every item — the same fields, and the same required real recipient, as
POST /orders. shipping_typestringrequired- REQUIRED, and shared by every item: the rows of a batch ship as ONE parcel, so they cannot be shipped by two different people. Same two values as
POST /orders—Ship by OPorShip by Platform. An item may override it, but a batch whose items disagree becomes orders that cannot be labelled together. itemsarrayrequired- 1–500 entries. Each takes the same per-item fields as
POST /orders(product_variant_idorproduct_id+options,quantity,image_url,mockup_url,note,add_on) and inherits anything set at the top level. Artwork is per item and REQUIRED on every one: one order can hold several products, each printed from its own design and mockup. marketplacestring- Applied to every row.
placed_atstring (date-time)- Applied to every row.
mockup_urlstring (url)- REQUIRED (or
mockup_id). The mockup picture — what the listing shows. Stored once per picture per account, so re-sending the same link reuses the same mockup rather than making another.
curl https://opcreative.us/api/v1/orders/batch \
-H "x-api-key: $OPCREATIVE_API_KEY" \
-H "Idempotency-Key: batch-1047-attempt-1" \
-H "Content-Type: application/json" \
-d '{
"external_id": "1047-ETSY-9F2",
"marketplace": "Etsy UK",
"shipping": {
"name": "A. Recipient",
"line1": "18 Example Street",
"city": "Washington",
"state": "DC",
"zip": "20002",
"country": "US"
},
"shipping_type": "Ship by OP",
"items": [
{
"product_variant_id": 881,
"quantity": 2,
"image_url": "https://cdn.example.com/designs/1047-a.png",
"mockup_url": "https://cdn.example.com/mockups/1047-a.png"
},
{
"product_variant_id": 903,
"quantity": 1,
"image_url": "https://cdn.example.com/designs/1047-b.png",
"note": "Second initial"
}
]
}'{
"data": {
"external_id": "1047-ETSY-9F2",
"created": 1,
"deduped": 0,
"failed": 1,
"results": [
{
"index": 0,
"external_id": "1047-ETSY-9F2-1",
"id": 48213,
"deduped": false
},
{
"index": 1,
"external_id": "1047-ETSY-9F2-2",
"error": "That SKU is not currently active and cannot be ordered."
}
]
}
}201 when at least one row was created, 200 when every row was a replay, 422 when nothing landed.
Read one order in full
/api/v1/orders/{id}Everything the list gives, plus every shipment, the design, the proof photo, the shipping address and the catalog-v2 option codes.
shipments[] is why this endpoint is worth a second call: a split or re-shipped order has several parcels and the list's tracking field shows only the newest.
An id belonging to another seller resolves to a 404, not a 403 — a 403 would confirm the id exists.
Path parameters
idintegerrequired- The order id from a list response.
curl https://opcreative.us/api/v1/orders/48213 \
-H "x-api-key: $OPCREATIVE_API_KEY"{
"data": {
"id": 48213,
"external_id": "1047-ETSY-9F2",
"marketplace": "Etsy UK",
"status": "IN_PRODUCTION",
"hold_reason": null,
"quantity": 2,
"filled": 0,
"base_cost": "24.60",
"add_on": {
"label": "Gift box",
"price": "1.50"
},
"paid": true,
"placed_at": "2026-08-11T09:14:22.000Z",
"assigned_at": "2026-08-11T10:02:00.000Z",
"fulfilled_at": null,
"deadline": "2026-08-18T00:00:00.000Z",
"changed_at": "2026-08-11T10:02:00.000Z",
"product": {
"id": 12,
"name": "Leather Keychain",
"key": "leather-keychain",
"kind": "CATALOGUE"
},
"seller": {
"id": "clx9v2a7k0000w8j0f1n3q2d1",
"name": "Lane & Co"
},
"variant": {
"id": 4,
"name": "Black",
"key": "black"
},
"sku": {
"id": 881,
"code": "HW-TRK-GK-LG__P-C"
},
"warehouse": {
"id": 2,
"code": "US-E",
"name": "US East"
},
"tracking": {
"number": "9400111899561234567890",
"status": "In transit",
"carrier": "usps"
},
"shipping_type": "Ship by OP",
"note": "Gift wrap",
"shipments": [
{
"tracking_number": "9400111899561234567890",
"tracking_status": "In transit",
"carrier": "usps",
"method": "Standard",
"cost": "4.35",
"label_url": "https://files.opcreative.us/labels/9400111899561234567890.pdf",
"created_at": "2026-08-12T16:40:11.000Z"
}
],
"image_url": "https://cdn.example.com/designs/1047.png",
"proof_image_url": "https://files.opcreative.us/proofs/48213.jpg",
"mockup": {
"id": 91022,
"name": "Keychain — initials",
"url": "https://cdn.example.com/mockups/91022.png"
},
"shipping_address": {
"name": "A. Recipient",
"company": null,
"line1": "18 Example Street",
"line2": null,
"city": "Washington",
"state": "DC",
"zip": "20002",
"country": "US"
},
"options": {
"method": "P",
"color": "KL",
"patch_color": "LG",
"print_area": "C"
},
"positions": [
{
"position": "CENTER",
"method": "UV_PRINT",
"files": [
{
"type": "PRINT",
"url": "https://cdn.example.com/designs/1047.png"
},
{
"type": "CUT",
"url": "https://cdn.example.com/designs/1047-outline.svg"
}
]
}
]
}
}One order.
Update one order
/api/v1/orders/{id}Replaces THREE legacy routes — patch, resolve-design and resolve-label — because from a seller's point of view they were one act: here is the missing piece, put my order back in the queue. What you send decides which.
Sending image_url to an order that is ON_HOLD releases it back to where it was held from. Sending label_url and tracking_number together attaches a shipment through the same code path the scan stations use, so the seller notification and the audit trail are identical.
Absent means leave it alone. Unknown field names are rejected rather than ignored — a typo you can see beats a silent no-op.
quantity is the one field locked after assignment: it changes what somebody has to make.
Send an Idempotency-Key when you may retry, for example from an offline queue: a replayed label link then returns the first answer instead of attaching again.
Path parameters
idintegerrequired- The order id.
Headers
Idempotency-Keystring- Optional. Reuse the same value on every retry of the same update; see Idempotency.
Body
notestring- Free text for the floor.
quantityinteger- Only while the order is still PENDING.
add_onbooleantrueorfalseticks or unticks the product's add-on and re-prices the order. Only while it is PENDING and not yet charged.image_urlstring (url)- The design. Releases an ON_HOLD order.
positionsarray- Replaces the whole per-face list (same shape as on create).
[]ornullremoves it. external_idstring- Your own order id.
deadlinestring (date-time)- When it must ship by.
shippingobject- Any of
name,company,email,phone,line1,line2,city,state,zip,country. When sent,name,line1,city,state,zipandcountrymust be real values — a placeholder such as1, or an empty value over a real one, is refused and named inerror.fields. Astateyou send must be its two-letter code, e.g.IL. label_urlstring- A label you bought yourself. Must be sent with
tracking_number. tracking_numberstring- Must be sent with
label_url. carrierstring- Optional carrier name for the shipment.
curl https://opcreative.us/api/v1/orders/48213 \
-H "x-api-key: $OPCREATIVE_API_KEY" \
-H "Idempotency-Key: label-48213-attempt-1" \
-X PATCH \
-H "Content-Type: application/json" \
-d '{
"image_url": "https://cdn.example.com/designs/1047.png",
"note": "Design attached"
}'{
"data": {
"id": 48213,
"external_id": "1047-ETSY-9F2",
"marketplace": "Etsy UK",
"status": "IN_PRODUCTION",
"hold_reason": null,
"quantity": 2,
"filled": 0,
"base_cost": "24.60",
"add_on": {
"label": "Gift box",
"price": "1.50"
},
"paid": true,
"placed_at": "2026-08-11T09:14:22.000Z",
"assigned_at": "2026-08-11T10:02:00.000Z",
"fulfilled_at": null,
"deadline": "2026-08-18T00:00:00.000Z",
"changed_at": "2026-08-11T10:02:00.000Z",
"product": {
"id": 12,
"name": "Leather Keychain",
"key": "leather-keychain",
"kind": "CATALOGUE"
},
"seller": {
"id": "clx9v2a7k0000w8j0f1n3q2d1",
"name": "Lane & Co"
},
"variant": {
"id": 4,
"name": "Black",
"key": "black"
},
"sku": {
"id": 881,
"code": "HW-TRK-GK-LG__P-C"
},
"warehouse": {
"id": 2,
"code": "US-E",
"name": "US East"
},
"tracking": {
"number": "9400111899561234567890",
"status": "In transit",
"carrier": "usps"
},
"shipping_type": "Ship by OP",
"note": "Gift wrap",
"shipments": [
{
"tracking_number": "9400111899561234567890",
"tracking_status": "In transit",
"carrier": "usps",
"method": "Standard",
"cost": "4.35",
"label_url": "https://files.opcreative.us/labels/9400111899561234567890.pdf",
"created_at": "2026-08-12T16:40:11.000Z"
}
],
"image_url": "https://cdn.example.com/designs/1047.png",
"proof_image_url": "https://files.opcreative.us/proofs/48213.jpg",
"mockup": {
"id": 91022,
"name": "Keychain — initials",
"url": "https://cdn.example.com/mockups/91022.png"
},
"shipping_address": {
"name": "A. Recipient",
"company": null,
"line1": "18 Example Street",
"line2": null,
"city": "Washington",
"state": "DC",
"zip": "20002",
"country": "US"
},
"options": {
"method": "P",
"color": "KL",
"patch_color": "LG",
"print_area": "C"
},
"positions": [
{
"position": "CENTER",
"method": "UV_PRINT",
"files": [
{
"type": "PRINT",
"url": "https://cdn.example.com/designs/1047.png"
},
{
"type": "CUT",
"url": "https://cdn.example.com/designs/1047-outline.svg"
}
]
}
]
},
"meta": {
"released": true,
"label_linked": false
}
}The updated order in full, so you never need a follow-up GET. meta.released says whether a hold was lifted.
Cancel one order
/api/v1/orders/{id}/cancelCancels the order and records who did it and why. It stays readable as CANCELLED; nothing is deleted.
A seller key cancels its OWN order while it is PENDING or ON_HOLD and has not reached a warehouse. After that the work has started — ask support. A warehouse or admin key cancels any order that is not finished.
No key cancels an order that has been charged: that answers 409 with reason: "paid". Request a refund instead — the refund pays the seller back and cancels the order in the same step. An order the warehouse already has answers 409 with reason: "with-warehouse"; one already finished, 409 with reason: "invalid-transition".
The body is optional. Send an Idempotency-Key when you may retry: a replay returns the first answer.
Path parameters
idintegerrequired- The order id.
Headers
Idempotency-Keystring- Optional. Reuse the same value on every retry of the same cancel; see Idempotency.
Body
reasonstring- Optional. Why — recorded on the order's history. 500 characters at most.
curl https://opcreative.us/api/v1/orders/48213/cancel \
-H "x-api-key: $OPCREATIVE_API_KEY" \
-H "Idempotency-Key: cancel-48213-1" \
-H "Content-Type: application/json" \
-d '{
"reason": "Customer ordered twice"
}'{
"data": {
"id": 48213,
"external_id": "1047-ETSY-9F2",
"marketplace": "Etsy UK",
"status": "CANCELLED",
"hold_reason": null,
"quantity": 2,
"filled": 0,
"base_cost": "24.60",
"add_on": {
"label": "Gift box",
"price": "1.50"
},
"paid": true,
"placed_at": "2026-08-11T09:14:22.000Z",
"assigned_at": "2026-08-11T10:02:00.000Z",
"fulfilled_at": null,
"deadline": "2026-08-18T00:00:00.000Z",
"changed_at": "2026-08-11T10:02:00.000Z",
"product": {
"id": 12,
"name": "Leather Keychain",
"key": "leather-keychain",
"kind": "CATALOGUE"
},
"seller": {
"id": "clx9v2a7k0000w8j0f1n3q2d1",
"name": "Lane & Co"
},
"variant": {
"id": 4,
"name": "Black",
"key": "black"
},
"sku": {
"id": 881,
"code": "HW-TRK-GK-LG__P-C"
},
"warehouse": {
"id": 2,
"code": "US-E",
"name": "US East"
},
"tracking": {
"number": "9400111899561234567890",
"status": "In transit",
"carrier": "usps"
},
"shipping_type": "Ship by OP",
"note": "Gift wrap",
"shipments": [
{
"tracking_number": "9400111899561234567890",
"tracking_status": "In transit",
"carrier": "usps",
"method": "Standard",
"cost": "4.35",
"label_url": "https://files.opcreative.us/labels/9400111899561234567890.pdf",
"created_at": "2026-08-12T16:40:11.000Z"
}
],
"image_url": "https://cdn.example.com/designs/1047.png",
"proof_image_url": "https://files.opcreative.us/proofs/48213.jpg",
"mockup": {
"id": 91022,
"name": "Keychain — initials",
"url": "https://cdn.example.com/mockups/91022.png"
},
"shipping_address": {
"name": "A. Recipient",
"company": null,
"line1": "18 Example Street",
"line2": null,
"city": "Washington",
"state": "DC",
"zip": "20002",
"country": "US"
},
"options": {
"method": "P",
"color": "KL",
"patch_color": "LG",
"print_area": "C"
},
"positions": [
{
"position": "CENTER",
"method": "UV_PRINT",
"files": [
{
"type": "PRINT",
"url": "https://cdn.example.com/designs/1047.png"
},
{
"type": "CUT",
"url": "https://cdn.example.com/designs/1047-outline.svg"
}
]
}
]
}
}The order in full, now CANCELLED.
Attach the packing photo
/api/v1/orders/{id}/proofThe photo taken at packing is what closes production. Attaching it marks every order in the parcel FULFILLED, stamps fulfilled_at, and sends each seller's order_status webhook — the same act, through the same code, as the camera button on the fulfillment station. This is the call a warehouse's packing step makes when it works OpCreative orders from its own system.
The body is multipart/form-data, not JSON — like POST /products/{id}/images, it takes a file. file must be a PNG, JPG or WEBP of 50 MB or less; the 2 MB JSON limit does not apply here.
The order id in the path identifies the whole parcel: every order sharing its tracking number receives the photo and moves together. Orders already FULFILLED or SHIPPED keep their status — the photo is replaced, the parcel does not move backwards.
A parcel that already has a photo, or is already fulfilled, answers 409 with requires_overwrite: true and changes nothing. Re-send with overwrite=true to replace it. A second photo is either a correction or a mis-scan, and the API cannot tell which, so it asks.
The key's owner needs the WAREHOUSE role; a seller key gets 403. The photo lands in proof_image_url. The design in image_url is a different field and is not touched.
Path parameters
idintegerrequired- Any order in the parcel.
Headers
Idempotency-Keystring- Optional. Reuse the same value on every retry of the same photo, so a retry after a timeout gets the first answer instead of a 409; see Idempotency.
Body
filefile (png, jpg, webp)required- The photo. PNG, JPG or WEBP, 50 MB at most.
overwritebooleantrueto replace a photo that is already there.
curl https://opcreative.us/api/v1/orders/48213/proof \
-H "x-api-key: $OPCREATIVE_API_KEY" \
-H "Idempotency-Key: proof-48213-attempt-1" \
-F "file=@proof.jpg;type=image/jpeg"{
"data": {
"id": 48213,
"external_id": "1047-ETSY-9F2",
"marketplace": "Etsy UK",
"status": "FULFILLED",
"hold_reason": null,
"quantity": 2,
"filled": 0,
"base_cost": "24.60",
"add_on": {
"label": "Gift box",
"price": "1.50"
},
"paid": true,
"placed_at": "2026-08-11T09:14:22.000Z",
"assigned_at": "2026-08-11T10:02:00.000Z",
"fulfilled_at": "2026-09-14T08:12:40.000Z",
"deadline": "2026-08-18T00:00:00.000Z",
"changed_at": "2026-08-11T10:02:00.000Z",
"product": {
"id": 12,
"name": "Leather Keychain",
"key": "leather-keychain",
"kind": "CATALOGUE"
},
"seller": {
"id": "clx9v2a7k0000w8j0f1n3q2d1",
"name": "Lane & Co"
},
"variant": {
"id": 4,
"name": "Black",
"key": "black"
},
"sku": {
"id": 881,
"code": "HW-TRK-GK-LG__P-C"
},
"warehouse": {
"id": 2,
"code": "US-E",
"name": "US East"
},
"tracking": {
"number": "9400111899561234567890",
"status": "In transit",
"carrier": "usps"
},
"shipping_type": "Ship by OP",
"note": "Gift wrap",
"shipments": [
{
"tracking_number": "9400111899561234567890",
"tracking_status": "In transit",
"carrier": "usps",
"method": "Standard",
"cost": "4.35",
"label_url": "https://files.opcreative.us/labels/9400111899561234567890.pdf",
"created_at": "2026-08-12T16:40:11.000Z"
}
],
"image_url": "https://cdn.example.com/designs/1047.png",
"proof_image_url": "https://files.opcreative.us/proofs/48213.jpg",
"mockup": {
"id": 91022,
"name": "Keychain — initials",
"url": "https://cdn.example.com/mockups/91022.png"
},
"shipping_address": {
"name": "A. Recipient",
"company": null,
"line1": "18 Example Street",
"line2": null,
"city": "Washington",
"state": "DC",
"zip": "20002",
"country": "US"
},
"options": {
"method": "P",
"color": "KL",
"patch_color": "LG",
"print_area": "C"
},
"positions": [
{
"position": "CENTER",
"method": "UV_PRINT",
"files": [
{
"type": "PRINT",
"url": "https://cdn.example.com/designs/1047.png"
},
{
"type": "CUT",
"url": "https://cdn.example.com/designs/1047-outline.svg"
}
]
}
]
},
"meta": {
"fulfilled": [
48213,
48214
]
}
}The order in full, with proof_image_url set. meta.fulfilled lists every order id in the parcel the photo now covers.
Record a scan
/api/v1/stations/scanA worker scanned a parcel. Every order under that tracking number still sitting at ASSIGNED moves to IN_PRODUCTION together, because the floor works a box, not a line.
This is the code the station screen itself runs, so the gate, the status move and the seller's webhook are the same whichever door you come through.
A parcel of more than 20 orders answers with meta.requires_confirmation and moves nothing. Send it again with confirmed: true once a human has agreed — and if you are replaying a queue, carry the answer the operator gave at the time rather than deciding it now.
Idempotency-Key is REQUIRED. This endpoint is built for an unattended replayer, and a retry must not scan twice.
Headers
Idempotency-Keystringrequired- Required. Any unique string; reuse it for every retry of the same scan.
Body
mode"tracking" | "order"requiredtrackingmatches a tracking number;ordernames one order id and widens to its parcel.valuestringrequired- The scanned code. A tracking number is at least 10 characters; an order id is digits.
confirmedbooleantrueto go past the 20-order confirmation gate.occurred_atstring (date-time)- When the worker scanned, if that is not now — an offline queue replaying. ISO-8601, no more than 5 minutes ahead of our clock and no more than 30 days old. It reaches the seller as
occurred_atand is recorded beside our own timestamp, never instead of it.
curl https://opcreative.us/api/v1/stations/scan \
-H "x-api-key: $OPCREATIVE_API_KEY" \
-H "Idempotency-Key: scan-48213-2026-09-16T08-12-40Z" \
-H "Content-Type: application/json" \
-d '{
"mode": "tracking",
"value": "9400110200881234567890",
"occurred_at": "2026-09-16T08:12:40Z"
}'{
"data": {
"tracking_number": "9400111899561234567890",
"external_id_prefix": null,
"customer": {
"id": "cku1a2b3c4d5",
"name": "Example Shop"
},
"is_multi_unit": false,
"label_url": "https://files.opcreative.us/labels/9400111899561234567890.pdf",
"basket": {
"name": "A3"
},
"orders": [
{
"id": 48213,
"order_id": "1047-ETSY-9F2",
"status": "IN_PRODUCTION",
"was_scanned": false,
"quantity": 2,
"filled": 1,
"product": "Keychain",
"variant": "Leather",
"sku": "HW-KEY-GK-LG__P-C",
"design_url": "https://cdn.example.com/designs/1047.png",
"mockup_url": "https://cdn.example.com/mockups/91022.png",
"proof_image_url": null,
"note": null,
"internal_note": null,
"basket": {
"name": "A3"
},
"label_url": "https://files.opcreative.us/labels/9400111899561234567890.pdf"
}
]
},
"meta": {
"requires_confirmation": null
}
}The parcel as it now stands, with every order in it. meta.requires_confirmation is null unless the gate stopped the scan.
Move a whole parcel to one status
/api/v1/stations/quick-updateEvery order under one tracking number moves to the status you name. A row whose current status cannot legally reach it is REPORTED in meta.skipped rather than failing the call: a mixed parcel is normal, and the worker holding the box needs the rest to move.
Three statuses notify the seller, so an accidental replay would notify twice. Idempotency-Key is REQUIRED.
Headers
Idempotency-Keystringrequired- Required. Any unique string; reuse it for every retry of the same move.
Body
tracking_numberstringrequired- The parcel's tracking number, at least 10 characters.
statusstringrequired- The status every order in the parcel should reach.
notestring- Anything the floor should record with the move.
occurred_atstring (date-time)- When it happened on the floor, if that is not now. ISO-8601; see the scan endpoint for the limits.
curl https://opcreative.us/api/v1/stations/quick-update \
-H "x-api-key: $OPCREATIVE_API_KEY" \
-H "Idempotency-Key: quick-9400110200881234567890-1" \
-H "Content-Type: application/json" \
-d '{
"tracking_number": "9400110200881234567890",
"status": "FULFILLED"
}'{
"data": {
"tracking_number": "9400111899561234567890",
"external_id_prefix": null,
"customer": {
"id": "cku1a2b3c4d5",
"name": "Example Shop"
},
"is_multi_unit": false,
"label_url": "https://files.opcreative.us/labels/9400111899561234567890.pdf",
"basket": {
"name": "A3"
},
"orders": [
{
"id": 48213,
"order_id": "1047-ETSY-9F2",
"status": "IN_PRODUCTION",
"was_scanned": false,
"quantity": 2,
"filled": 1,
"product": "Keychain",
"variant": "Leather",
"sku": "HW-KEY-GK-LG__P-C",
"design_url": "https://cdn.example.com/designs/1047.png",
"mockup_url": "https://cdn.example.com/mockups/91022.png",
"proof_image_url": null,
"note": null,
"internal_note": null,
"basket": {
"name": "A3"
},
"label_url": "https://files.opcreative.us/labels/9400111899561234567890.pdf"
}
]
},
"meta": {
"updated": 2,
"skipped": [
{
"id": 48215,
"order_id": "1047-3",
"reason": "ON_HOLD cannot become FULFILLED"
}
]
}
}The parcel after the move. meta.updated counts the rows that moved; meta.skipped names the ones that could not, each with its reason.
Fill units into a parcel
/api/v1/orders/{id}/fillCounts units as a packer fills them. On the first fill of a multi-order parcel it claims a shelf basket, and it refuses to take the count past what was ordered. The order must be IN_PRODUCTION.
A fill is a COUNTER, which makes it the one station action a replay corrupts quietly rather than refusing. Idempotency-Key is REQUIRED.
Path parameters
idintegerrequired- Any order in the parcel.
Headers
Idempotency-Keystringrequired- Required. Any unique string; reuse it for every retry of the same fill.
Body
amountintegerrequired- How many units this fill adds. At least 1, and never more than remains.
occurred_atstring (date-time)- When it happened on the floor, if that is not now. ISO-8601; see the scan endpoint for the limits.
curl https://opcreative.us/api/v1/orders/48213/fill \
-H "x-api-key: $OPCREATIVE_API_KEY" \
-H "Idempotency-Key: fill-48213-1" \
-H "Content-Type: application/json" \
-d '{
"amount": 1
}'{
"data": {
"tracking_number": "9400111899561234567890",
"external_id_prefix": null,
"customer": {
"id": "cku1a2b3c4d5",
"name": "Example Shop"
},
"is_multi_unit": false,
"label_url": "https://files.opcreative.us/labels/9400111899561234567890.pdf",
"basket": {
"name": "A3"
},
"orders": [
{
"id": 48213,
"order_id": "1047-ETSY-9F2",
"status": "IN_PRODUCTION",
"was_scanned": false,
"quantity": 2,
"filled": 1,
"product": "Keychain",
"variant": "Leather",
"sku": "HW-KEY-GK-LG__P-C",
"design_url": "https://cdn.example.com/designs/1047.png",
"mockup_url": "https://cdn.example.com/mockups/91022.png",
"proof_image_url": null,
"note": null,
"internal_note": null,
"basket": {
"name": "A3"
},
"label_url": "https://files.opcreative.us/labels/9400111899561234567890.pdf"
}
]
},
"meta": {
"basket": {
"name": "A3"
}
}
}The parcel, with filled updated on every order in it. meta.basket is the shelf slot it is staged in, when it has one.
Build it with an AI agent
Prompts written for Claude Code, Cursor or Codex — copy one, paste it into your agent, and it has the base URL, the endpoints and the traps already.
This reference explains the API to a person. An agent needs something else: a brief it can act on. Each prompt below names the live base URL, points at the OpenAPI document as the authority, and states up front the things integrations get wrong — money is a string, an idempotency key must be reused on retry rather than regenerated, and a matched row is a suggestion until something with judgement accepts it.
The prompts stay in English in every language. They are instructions a model will follow, and a translation that reworded one would change behaviour rather than wording.
Build the integration
Creating orders end to end — auth, money handling, retries, batch.
Wire up SKU matching
Turn marketplace rows into SKUs with your own AI key, then create the orders.
Review my integration
Audit an integration you already have against the rules that cost real money.
Connect an AI client (MCP)
The same account, driven from Claude or ChatGPT — a Model Context Protocol server with OAuth 2.1 sign-in, and writes that always ask first.
This reference is for code you write; the MCP server is for AI clients you talk to. Connect once and Claude or ChatGPT can search and manage orders, browse the catalog and check your account — through the same services, permissions and row scoping this page describes. Sign-in is OAuth 2.1 with PKCE and dynamic client registration, or send an x-api-key header for server-style setups. Every write tool refuses to run until the change is approved in chat, and OAuth tokens can be granted read-only. The install page has copy-paste setup for every client.
https://opcreative.us/api/mcp/mcpclaude mcp add --transport http opcreative https://opcreative.us/api/mcp/mcpWebhooks
Three events, signed, retried, and inspectable in your dashboard.
Set your endpoint and secret under Profile → Webhooks. Every delivery carries an X-Signature header: HMAC-SHA256 of the raw request body, keyed with your secret, hex encoded. Verify it against the RAW BYTES you received — re-serializing the parsed JSON produces a different string and a signature that will not match.
Respond 2xx quickly and do your work afterwards. Anything else is retried with backoff — five attempts over about eight hours — and every attempt is byte-identical to the first, with X-Delivery-Id and X-Delivery-Attempt headers so you can tell a retry from a new event.
If several events in a row exhaust their retries we stop calling your endpoint and tell you in the dashboard, where you can also see recent deliveries and replay any one of them by hand.
import { createHmac, timingSafeEqual } from "node:crypto";
// Express: give this route the RAW body, not the JSON-parsed object.
app.post("/webhooks/opcreative", express.raw({ type: "application/json" }), (req, res) => {
const expected = createHmac("sha256", process.env.OPCREATIVE_WEBHOOK_SECRET)
.update(req.body) // the exact bytes we sent — re-stringifying changes them
.digest("hex");
const given = req.get("X-Signature") ?? "";
// Constant-time: a === b leaks the signature one character at a time.
const ok =
given.length === expected.length &&
timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
// Respond 2xx quickly and do the work afterwards — we retry anything else.
res.sendStatus(200);
handle(event.type, event.data);
});order_statusAn order reaches IN_PRODUCTION, FULFILLED or ON_HOLD. The old system documented this event but never actually sent it — if your receiver only ever saw shipping_added, this is new traffic.
{
"type": "order_status",
"data": {
"id": 48213,
"order_id": "1047-ETSY-9F2",
"status": "IN_PRODUCTION",
"note": null,
"updated_at": "2026-08-12T16:40:11.000Z",
"occurred_at": "2026-08-12T16:40:11.000Z"
}
}shipping_addedA label is attached to your parcel, whether we bought it or the floor scanned one in. One event per parcel per seller — three of your items in one box is one event, not three.
{
"type": "shipping_added",
"data": {
"tracking_number": "9400111899561234567890",
"label_url": "https://files.opcreative.us/labels/9400111899561234567890.pdf",
"provider": "usps",
"orders": [
{
"id": 48213,
"order_id": "1047-ETSY-9F2"
}
],
"updated_at": "2026-08-12T16:41:02.000Z",
"occurred_at": "2026-08-12T16:41:02.000Z"
}
}tracking_statusThe carrier reports movement. Every change, not just the notable ones — a machine reading a feed wants the whole route.
{
"type": "tracking_status",
"data": {
"tracking_number": "9400111899561234567890",
"status": "Delivered",
"detail": "Left with individual",
"updated_at": "2026-08-14T18:03:44.000Z",
"occurred_at": "2026-08-14T18:03:44.000Z"
}
}Coming from the old API
Every legacy endpoint and what replaced it, including the ones that are gone.
Existing keys were migrated and keep working — the header did not change. Two things did change behaviour rather than shape, and both are marked below.
A retired row means exactly that: there is no replacement. It is listed anyway, because discovering a shutdown as a 404 at 2am is worse than reading it here.
POST /api/customer/ordersPOST /api/v1/ordersPOST /api/v2/customer/ordersPOST /api/v1/ordersPOST /api/v2/customer/orders/multiPOST /api/v1/orders/batch- Same bargain: one shipping block, many items, rows suffixed -1, -2, …. An Idempotency-Key is now required, and each row reports its own outcome.
POST /api/v3/customer/ordersRetired- V3 SKU management is gone; send
product_variant_idto POST /api/v1/orders instead. GET /api/customer/ordersGET /api/v1/orders- Cursor paged: pass
cursor, notpage. GET /api/customer/orders/:idGET /api/v1/orders/:idPATCH /api/customer/orders/:idPATCH /api/v1/orders/:idPATCH /api/customer/orders/:id/resolve-designPATCH /api/v1/orders/:id- Send
image_url. A held order returns to the queue automatically. PATCH /api/customer/orders/:id/resolve-labelPATCH /api/v1/orders/:id- Send
label_urlandtracking_numbertogether. GET /api/customer/metadataGET /api/v1/catalogGET /api/v3/customer/orders/pricingGET /api/v1/catalog- Prices are already yours — no tier lookup needed.
GET /api/customer/profileGET /api/v1/mex-api-key headerx-api-key header- Breaking:The header is unchanged, but YOUR OLD KEY IS NOT ACCEPTED. Keys issued by the old system cannot authenticate here; generate a new one under Profile → API and swap it into your integration. Nothing else about your requests has to change.
Outbound webhooks — x-api-key signatureOutbound webhooks — X-Signature- Breaking:Same events, same payload field names. The SIGNATURE HEADER CHANGED: verify X-Signature (HMAC-SHA256) instead of comparing x-api-key.
order_status webhookorder_status webhook- Breaking:It now actually fires. The old system documented this event but never wired it, so receivers that only ever saw shipping_added will start receiving order_status too.
GET /api/admin/v1/*Retired- Admin integrations are retired. Everything they did is in the dashboard.
OCR label extraction, sync-mockup, SSE order streamRetired- Retired with the Google Drive pipeline.