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 2 MB.
- 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.
- PATCH needs no key: it sets fields to the values you send, so replaying it lands on the same state.
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.
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://cdn.example.com/products/12.png",
"skus": [
{
"id": 881,
"code": "HW-TRK-GK-LG__P-C",
"status": "ACTIVE",
"variant": {
"id": 4,
"name": "Black",
"key": "black"
},
"price": "12.30"
}
]
}
],
"next_cursor": 412
}A page of products, each with its orderable SKUs.
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.
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.
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",
"quantity": 2,
"filled": 0,
"base_cost": "24.60",
"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",
"product": {
"id": 12,
"name": "Leather Keychain",
"key": "leather-keychain"
},
"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"
},
"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. Onlyzipis required — marketplaces like Amazon withhold buyer names, and rejecting those rows loses real orders.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)- The design to print.
mockup_urlstring (url)- 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.
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",
"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 — same fields as
POST /orders. 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) and inherits anything set at the top level. Artwork is per item: 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)- 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"
},
"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",
"quantity": 2,
"filled": 0,
"base_cost": "24.60",
"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",
"product": {
"id": 12,
"name": "Leather Keychain",
"key": "leather-keychain"
},
"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"
},
"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"
}
}
}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.
No Idempotency-Key is needed. A PATCH sets fields to the values you send, so replaying one lands on the same state.
Path parameters
idintegerrequired- The order id.
Body
notestring- Free text for the floor.
quantityinteger- Only while the order is still PENDING.
image_urlstring (url)- The design. Releases an ON_HOLD order.
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. 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" \
-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",
"quantity": 2,
"filled": 0,
"base_cost": "24.60",
"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",
"product": {
"id": 12,
"name": "Leather Keychain",
"key": "leather-keychain"
},
"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"
},
"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"
}
},
"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.
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"
}
}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"
}
}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"
}
}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.