Documentation
API
CeePrinto public API v2 for integrators
API ยท Start here
API overview
What the CeePrinto v2 API does, the base URL, who it is for and how versioning works.
The CeePrinto v2 API lets your store or app publish CeePrinto designs as products, send in orders that CeePrinto prints, ships and collects cash on delivery for, and track payouts.
What it is and who it is for
CeePrinto is a white-label print-on-demand hub. Merchants design on blank products in the Design Configurator, then sell those designs in their own stores. The API is the single protocol every store connector speaks: the Shopify app, the CeePrinto Connect plugin for WooCommerce and any custom integration you build all call the same routes with the same API key format.
Use it if you are:
- a developer connecting a store that is not Shopify or WooCommerce (a custom site, a marketplace, an ERP);
- a merchant automating publishing, order submission or payout reporting;
- building or debugging a connector and need the exact contract.
Base URLs
| Environment | Base URL |
|---|---|
| Production | https://ceeprinto.com/wp-json/ceeprinto/v2 |
Every route in these docs is relative to the base URL. Examples read it from the CP_BASE environment variable.
Authentication in one line
Send Authorization: Bearer cp_live_<key_id>.<secret> on every request, using a key from My Account โ Store Connections โ API Keys. Details in Authentication and scopes.
Three ways to integrate
| Path | Best for | What you do |
|---|---|---|
| Shopify app | Shopify stores | Install the app, paste the cp1. connection code from My Account โ Store Connections โ Get Started. No code. |
| CeePrinto Connect plugin | WooCommerce stores | Install the plugin on your WordPress site, paste the cp1. connection code. No code. |
| Raw API | Custom stores and tools | Create an API key with the scopes you need and call the routes in this reference. |
The two connectors are clients of this same API, so anything they do you can do with the raw API.
Versioning
| Item | Value |
|---|---|
| Namespace | ceeprinto/v2 |
| Current version | 2.2.0 |
| Version header | X-CP-Api-Version: 2.2.0 on every response |
| Compatibility | Additive changes (new fields, new routes) ship within v2. Branch on error.code, never on the message text. |
Legacy namespaces are deprecated. integration/v1, app/v1, internal/v1 and ceeprinto/v1 still answer, but every response carries Deprecation: true and a Link: <โฆ/openapi.yaml>; rel="deprecation" header. No sunset date is set yet. Build new work on v2 only.
Using these docs with an AI assistant
These pages are written so an AI assistant can produce correct integration code from them.
| Resource | What it gives you |
|---|---|
| Copy as prompt button | On every section and every endpoint card. Copies that part as Markdown with a short preamble (base URL, Bearer auth, "use only these facts"). Paste it into your assistant. |
| llms.txt | An index of every section with a one-line summary and a link, in the llmstxt.org format. |
| llms-full.txt | The whole documentation as one Markdown file. Best for "read this, then write my integration". |
| openapi.yaml | The OpenAPI 3.0 spec: every operation with operationId, x-scope, examples and webhook payload schemas. Feed it to code generators. |
| "For AI assistants" boxes | The invariants at the end of each section. An assistant should never break them. |
A good prompt: "Using the CeePrinto docs below, write a Node 18 script that publishes design 11 to shop 5 and polls until the publish item completes." Then paste llms-full.txt or the relevant copied sections.
API ยท Start here
Getting started
Create an API key in My Account and make your first authenticated call to /me.
In about five minutes you will have an API key, your environment set up, and a successful call to GET /me that shows which account and scopes the key has.
Prerequisites
- A CeePrinto account (log in at ceeprinto.com).
- curl, or one of PHP 7.4+ with the cURL extension, Node 18+ or Python 3.8+ with
requests.
-
Create an API key
Go to My Account โ Store Connections โ API Keys. Give the key a name, tick the scopes it needs and create it. The full key (
cp_live_<key_id>.<secret>) is shown once. Copy it now; CeePrinto stores only a hash and cannot show it again.Not sure which scopes? For a store integration tick all eight. See Authentication and scopes.
-
Set environment variables
Every example in these docs reads the base URL and key from the environment, so nothing secret ends up in your code.
export CP_BASE="https://ceeprinto.com/wp-json/ceeprinto/v2" export CP_KEY="cp_live_xxxxxxxxxxxxxxxxxxxxxxxx.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" -
Call GET /me
/meneeds no scope, so it is the right smoke test for any key.GET /mecurl -s "$CP_BASE/me" \ -H "Authorization: Bearer $CP_KEY"<?php // me.php (run: php me.php) $base = getenv('CP_BASE'); $key = getenv('CP_KEY'); $ch = curl_init($base . '/me'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $key, 'Accept: application/json', ], CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($raw === false) { throw new RuntimeException('Network error: ' . curl_error($ch)); } curl_close($ch); $json = json_decode($raw, true); if ($status >= 400) { throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']); } echo 'Account ' . $json['data']['id'] . ' (' . $json['data']['email'] . ")\n"; echo 'Scopes: ' . implode(', ', $json['data']['scopes']) . "\n";// me.mjs (run: node me.mjs) const res = await fetch(`${process.env.CP_BASE}/me`, { headers: { Authorization: `Bearer ${process.env.CP_KEY}` }, }); const json = await res.json(); if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`); console.log(`Account ${json.data.id} (${json.data.email})`); console.log(`Scopes: ${json.data.scopes.join(', ')}`);# me.py (run: python me.py) import os import requests res = requests.get( f"{os.environ['CP_BASE']}/me", headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"}, timeout=30, ) body = res.json() if not res.ok: raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}") print(f"Account {body['data']['id']} ({body['data']['email']})") print("Scopes: " + ", ".join(body["data"]["scopes"]))PHP inside WordPress? The CeePrinto Connect plugin's
includes/Client.phpis a complete WordPress-native client (wp_remote_request, Bearer header, JSON envelope) you can copy. The PHP examples in these docs use plain cURL so they run anywhere. -
Read the response
{ "data": { "id": 214, "email": "[email protected]", "name": "Ayesha Khan", "first_name": "Ayesha", "last_name": "Khan", "company": "Threadline Karachi", "api_key": { "key_id": "9f3a1c7eK2pQ8mZx4LbN6tRw", "name": "WooCommerce Connect", "is_legacy": false, "last_used_at": "2026-09-29 10:14:52" }, "scopes": [ "designs:read", "products:read", "listings:read", "listings:write", "orders:write", "shops:write", "webhooks:write" ], "fulfillment_profile": { "complete": true, "missing": [], "fields": { "billing_phone": true, "billing_address_1": true, "billing_city": true, "billing_country": true, "billing_company": true, "billing_address_2": false, "billing_state": true, "billing_postcode": true, "first_name": true, "last_name": true } } } }Field Meaning data.idThe merchant id. Store it; it never changes. data.api_key.nameThe key's name. Shopify AppandWooCommerce Connectare connection-code keys.data.api_key.is_legacytruefor an old 24-character key.data.scopesWhat this key may do. Compare with the scopes table. data.fulfillment_profile.completefalsemeans billing phone, address, city or country is missing in My Account;missinglists them. -
Check what scopes you got
If
scopeslacksorders:read(as in the example above, a connection-code key),GET /orders,GET /payoutsandPOST /shipping/quotewill answer 403. Create a key on the API Keys tab withorders:readticked if you need them.
If it fails
| Status | error.code | Fix |
|---|---|---|
| 401 | unauthorized |
Key missing, mistyped, revoked or not prefixed with Bearer . Recopy it or create a new one. |
| 404 | rest_no_route |
Wrong CP_BASE or path. The base must end in /wp-json/ceeprinto/v2. |
| 429 | rate_limited |
Wait Retry-After seconds. |
API ยท Start here
Authentication and scopes
Bearer API keys, the scopes each key carries, connection codes, rotation and revocation.
Every v2 request is authenticated with an API key sent as a Bearer token, and each key only reaches the routes its scopes allow.
Headers
| Header | Value | Use |
|---|---|---|
Authorization |
Bearer cp_live_<key_id>.<secret> |
Preferred. Checked first. |
X-CP-Key |
cp_live_<key_id>.<secret> |
For clients that cannot set Authorization. |
auth |
<24-character legacy key> |
Legacy only. What old Shopify app versions send. |
curl -s "$CP_BASE/me" -H "Authorization: Bearer $CP_KEY"
curl -s "$CP_BASE/me" -H "X-CP-Key: $CP_KEY"
Key format
| Part | Format | Notes |
|---|---|---|
| Prefix | cp_live_ |
Marks a v2 key. |
key_id |
24 letters and digits | Public identifier. Shown in My Account and in GET /me. |
| Separator | . |
|
secret |
40 letters and digits | Shown once at creation. Stored only as a hash. |
| Legacy key | 24 characters, no prefix | Still accepted. See below. |
Scopes
Each route requires one scope. A key without it gets 403 forbidden with details.required_scope and details.granted_scopes.
| Scope | Label in My Account | Routes |
|---|---|---|
| (none) | GET /me |
|
designs:read |
Read designs | GET /designs, GET /designs/{id}, GET|POST /designs/{id}/variation-mockups |
products:read |
Read the blank-product catalog | GET /catalog/products, GET /catalog/products/{id} |
listings:read |
Read store listings | GET /products, GET /products/{id}, GET /shops, GET /shops/{id}, GET /shops/{id}/listings, GET /publish-jobs/{id}, GET /shops/{id}/publish-jobs/pending |
listings:write |
Create and remove store listings | POST /products, PATCH /products/{id}, PATCH /products/{id}/variants/{variation_id}, listing POST/PATCH/DELETE, POST /publish-jobs, publish item complete/fail |
orders:read |
Read orders | GET /orders, GET /orders/{id}, GET /orders/{id}/events, GET /payouts, GET /payouts/summary, POST /shipping/quote |
orders:write |
Submit orders | POST /orders |
shops:write |
Connect and disconnect stores | POST /shops, DELETE /shops/{id} |
webhooks:write |
Manage webhook subscriptions | GET /webhooks, POST /webhooks, DELETE /webhooks/{id} |
{
"error": {
"code": "forbidden",
"message": "This API key does not have the \"orders:read\" scope.",
"details": {
"required_scope": "orders:read",
"granted_scopes": [
"designs:read",
"products:read",
"listings:read",
"listings:write",
"orders:write",
"shops:write",
"webhooks:write"
]
}
}
}
Connection codes (cp1.)
The Shopify app and the CeePrinto Connect plugin do not ask the merchant for a raw key. Instead My Account โ Store Connections โ Get Started generates a one-time connection code that bundles the hub URL and a freshly minted key.
| Property | Value |
|---|---|
| Format | cp1. + base64url (no padding) of JSON {"v":1,"base":"https://ceeprinto.com/","key":"cp_live_โฆ"} |
| Visible for | 2 minutes, once. Reloading the page after that shows nothing. |
| Key name | WooCommerce Connect or Shopify App |
| Rotation | Generating a new code revokes the previous key of the same name. |
| Scopes granted | designs:read, products:read, listings:read, listings:write, orders:write, shops:write, webhooks:write |
| Scope omitted | orders:read |
Connection-code keys cannot read orders. With a "Shopify App" or "WooCommerce Connect" key, GET /orders, GET /orders/{id}, GET /orders/{id}/events, GET /payouts, GET /payouts/summary and POST /shipping/quote return 403. They can still submit orders. For reporting, create a separate key with orders:read on the API Keys tab.
import base64, json
code = "cp1.eyJ2IjoxLCJiYXNlIjoiaHR0cHM6Ly9jZWVwcmludG8uY29tLyIsImtleSI6ImNwX2xpdmVfLi4uIn0"
raw = code[len("cp1."):]
payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))
base_url = payload["base"].rstrip("/") + "/wp-json/ceeprinto/v2"
api_key = payload["key"]
Rotation and revocation
| Action | How | Effect |
|---|---|---|
| Rotate | Create a new key on the API Keys tab, deploy it, then revoke the old one. | Zero downtime. |
| Revoke | API Keys tab โ Revoke. | Immediate: the next request with that key is 401 "This API key has been revoked." |
| Regenerate a connection code | Get Started tab. | Revokes the previous key with the same name. |
| Last used | Shown on the API Keys tab and in GET /me as api_key.last_used_at. |
Updated at most once a minute. |
Legacy keys
Accounts created before v2 have a 24-character key without the cp_live_ prefix. It still works in any of the three headers and holds every scope. GET /me reports is_legacy: true and masks key_id to its last 4 characters. Move to a scoped v2 key when you can.
Security tips
- Keep keys server-side. Never ship one in browser JavaScript or a mobile app.
- Give each integration its own key with the fewest scopes it needs, so you can revoke one without breaking the others.
- Store keys in environment variables or a secrets manager, not in source control.
- Revoke immediately if a key leaks, then check the API Keys tab for unexpected "last used" times.
- Always call HTTPS in production.
API ยท Start here
Conventions: envelope, errors, pagination, rate limits
The response envelope, error codes, pagination headers, rate limits, request ids and idempotency.
Every v2 route shares one response envelope, one error format, one pagination scheme and one rate limit, so a single client wrapper handles them all.
Response envelope
Success bodies wrap the payload in data. meta appears only when there is something to say (pagination, replay flags, cache flags). Error bodies have a single error object and no data.
{
"data": [
{
"id": 5,
"channel": "custom",
"external_shop_id": "my-store-01",
"name": "My Store",
"webhook_url": null,
"status": "active",
"connected_at": "2026-09-15T07:30:00+00:00"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 1,
"total_pages": 1
}
}
{
"error": {
"code": "unprocessable_entity",
"message": "The order could not be accepted.",
"details": {
"issues": [
"line_items[0].customer_price is required and drives COD collection."
]
}
}
}
error.details is present only when there are details; otherwise the key is absent. error.code is the contract; error.message is for humans and may change. A 204 response has no body at all.
Error codes
| HTTP | error.code | Meaning | details |
|---|---|---|---|
| 400 | invalid_request |
A required field or header is missing or malformed (e.g. no Idempotency-Key). |
Sometimes valid_topics |
| 401 | unauthorized |
No credential, malformed, unknown or revoked key. | None |
| 403 | forbidden |
The key lacks the route's scope. | required_scope, granted_scopes |
| 404 | not_found |
The resource does not exist or belongs to another account. | None |
| 409 | conflict |
The store is already connected to another account. | None |
| 422 | unprocessable_entity |
Well-formed but invalid (bad mode, missing customer_price). |
issues[] on orders |
| 429 | rate_limited |
Over 120 requests in the current minute. | retry_after |
| 500 | server_error |
Something failed on the hub. | None |
WordPress's own validation errors are passed through in the same envelope with their native codes: rest_no_route (404, wrong path or method) and rest_missing_callback_param / rest_invalid_param (400, with details.params). Treat any unknown 4xx code as a client error. Full list with fixes in Errors and troubleshooting.
Pagination
| Item | Value |
|---|---|
| Query parameters | page (1-based), per_page |
Default per_page |
20 |
Max per_page |
100 (larger values are clamped; values below 1 fall back to 20) |
| Body | meta.page, meta.per_page, meta.total, meta.total_pages |
| Headers | X-CP-Total, X-CP-Total-Pages, Link |
Link relations |
prev and first when page > 1; next and last when more pages exist; absent when there is one page |
| Not paginated | GET /shops/{id}/publish-jobs/pending, GET /webhooks, GET /orders/{id}/events: bare array in data, no meta |
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
X-CP-Request-Id: req_3f9a1c7e5b2d8a40
X-CP-Api-Version: 2.2.0
X-CP-RateLimit-Limit: 120
X-CP-RateLimit-Remaining: 117
X-CP-RateLimit-Reset: 1790676060
X-CP-Total: 230
X-CP-Total-Pages: 3
Link: <https://ceeprinto.com/wp-json/ceeprinto/v2/catalog/products?page=1&per_page=100>; rel="prev", <https://ceeprinto.com/wp-json/ceeprinto/v2/catalog/products?page=1&per_page=100>; rel="first", <https://ceeprinto.com/wp-json/ceeprinto/v2/catalog/products?page=3&per_page=100>; rel="next", <https://ceeprinto.com/wp-json/ceeprinto/v2/catalog/products?page=3&per_page=100>; rel="last"
Loop until meta.page >= meta.total_pages, or until the Link header has no rel="next". A complete example is in Recipes (catalog sync).
Rate limiting
| Item | Value |
|---|---|
| Limit | 120 requests per key per fixed 60-second window |
| Scope | Per API key; one merchant never limits another |
X-CP-RateLimit-Limit |
Requests allowed per window (120) |
X-CP-RateLimit-Remaining |
Requests left in this window |
X-CP-RateLimit-Reset |
Unix time when the window resets |
| Over the limit | 429 rate_limited, Retry-After header (seconds) and details.retry_after |
Honour Retry-After on 429, and back off exponentially on 5xx and network errors. A reusable wrapper:
#!/usr/bin/env bash
# cp_call METHOD PATH [JSON_BODY] - retries 429 (Retry-After) and 5xx (1s, 2s, 4s, 8s)
cp_call() {
local method="$1" path="$2" body="${3:-}" attempt=0 status headers
while :; do
headers=$(mktemp)
if [ -n "$body" ]; then
status=$(curl -s -o /tmp/cp_body -D "$headers" -w "%{http_code}" -X "$method" "$CP_BASE$path" \
-H "Authorization: Bearer $CP_KEY" -H "Content-Type: application/json" -d "$body")
else
status=$(curl -s -o /tmp/cp_body -D "$headers" -w "%{http_code}" -X "$method" "$CP_BASE$path" \
-H "Authorization: Bearer $CP_KEY")
fi
if [ "$status" = "429" ] || [ "$status" -ge 500 ] 2>/dev/null; then
attempt=$((attempt + 1))
[ "$attempt" -gt 4 ] && { cat /tmp/cp_body; rm -f "$headers"; return 1; }
wait=$(grep -i '^retry-after:' "$headers" | tr -dc '0-9')
sleep "${wait:-$((2 ** (attempt - 1)))}"
rm -f "$headers"
continue
fi
rm -f "$headers"
cat /tmp/cp_body
[ "$status" -lt 400 ]
return
done
}
cp_call GET /me
<?php
function cp_call(string $method, string $path, ?array $body = null, array $headers = []): array {
$delay = 1;
for ($attempt = 1; ; $attempt++) {
$respHeaders = [];
$ch = curl_init(getenv('CP_BASE') . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => array_merge([
'Authorization: Bearer ' . getenv('CP_KEY'),
'Accept: application/json',
'Content-Type: application/json',
], $headers),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$respHeaders) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$respHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$retryable = $raw === false || $status === 429 || $status >= 500;
if ($retryable && $attempt < 5) {
$wait = isset($respHeaders['retry-after']) ? (int) $respHeaders['retry-after'] : $delay;
sleep(max(1, $wait));
$delay *= 2;
continue;
}
$json = $raw === false || $raw === '' ? null : json_decode($raw, true);
if ($status >= 400 || $raw === false) {
$code = $json['error']['code'] ?? 'network_error';
throw new RuntimeException("$code (HTTP $status, request " . ($respHeaders['x-cp-request-id'] ?? '?') . ')');
}
return ['status' => $status, 'body' => $json, 'headers' => $respHeaders];
}
}
print_r(cp_call('GET', '/me')['body']['data']);
// cp.mjs
export async function cpCall(method, path, body, headers = {}) {
let delay = 1000;
for (let attempt = 1; ; attempt++) {
let res;
try {
res = await fetch(`${process.env.CP_BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30000),
});
} catch (err) {
if (attempt < 5) { await sleep(delay); delay *= 2; continue; }
throw err;
}
if ((res.status === 429 || res.status >= 500) && attempt < 5) {
const retryAfter = Number(res.headers.get('retry-after'));
await sleep(retryAfter > 0 ? retryAfter * 1000 : delay);
delay *= 2;
continue;
}
const json = res.status === 204 ? null : await res.json();
if (!res.ok) {
throw new Error(`${json.error.code} (HTTP ${res.status}, request ${res.headers.get('x-cp-request-id')})`);
}
return { status: res.status, body: json, headers: res.headers };
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
if (import.meta.url === `file://${process.argv[1]}`) {
console.log((await cpCall('GET', '/me')).body.data);
}
# cp.py
import os
import time
import requests
def cp_call(method, path, body=None, headers=None):
delay = 1
for attempt in range(1, 6):
try:
res = requests.request(
method,
f"{os.environ['CP_BASE']}{path}",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}", **(headers or {})},
json=body,
timeout=30,
)
except requests.RequestException:
if attempt == 5:
raise
time.sleep(delay)
delay *= 2
continue
if (res.status_code == 429 or res.status_code >= 500) and attempt < 5:
time.sleep(int(res.headers.get("Retry-After", delay)))
delay *= 2
continue
data = None if res.status_code == 204 else res.json()
if not res.ok:
raise RuntimeError(
f"{data['error']['code']} (HTTP {res.status_code}, request {res.headers.get('X-CP-Request-Id')})"
)
return res.status_code, data, res.headers
if __name__ == "__main__":
print(cp_call("GET", "/me")[1]["data"])
The limit is enforced only when the hub runs a persistent object cache. The headers are always sent, so write your client as if the limit is always on.
Request id and version headers
| Header | Example | Use |
|---|---|---|
X-CP-Request-Id |
req_3f9a1c7e5b2d8a40 |
On every response, success or error. Log it; quote it in support tickets. |
X-CP-Api-Version |
2.2.0 |
On every response. The hub version that served the request. |
Timestamps and money
| Item | Format |
|---|---|
| Timestamps | RFC 3339 in UTC, e.g. 2026-09-29T10:15:00+00:00. Nullable ones are null, never an empty string. |
| Exception | api_key.last_used_at on GET /me is YYYY-MM-DD HH:MM:SS UTC. |
| Dates in filters | YYYY-MM-DD (date_from, date_to on /payouts) |
| Currency | PKR unless stated. customer_price is a decimal string, e.g. "2500.00". |
Webhook sent_at |
ISO 8601 UTC, e.g. 2026-09-29T10:15:00+00:00 |
Idempotency
| Route | Behaviour on repeat |
|---|---|
POST /orders |
Requires Idempotency-Key (max 191 chars). Same key returns 200 with the original intake and meta.idempotent_replay: true. Missing key is 400. |
POST /shops |
Upserts on (channel, external_shop_id). Always 201. |
POST /shops/{id}/listings and /bulk |
Upserts on external_variant_id. Rows without one are inserted again. |
POST /publish-jobs |
Not idempotent. In create mode designs already listed on the shop are skipped; if nothing is left it is 400. |
POST /webhooks |
Not idempotent: each call creates a new subscription. |
API ยท Start here
Core concepts
Glossary of blanks, designs, hub products, variants, listings, shops, publish jobs, mockups and payouts.
This glossary defines the objects the API moves around, so the rest of the docs can use each term in exactly one sense.
Glossary
| Term | What it is | Where in the API |
|---|---|---|
| Blank | A plain WooCommerce product CeePrinto prints on (a tee, a hoodie). Has variations (Size ร Color) and print sides. | GET /catalog/products |
| Variation | One Size/Color combination of a blank. variation_id is its WooCommerce id; 0 means a simple blank with no variations. |
variations[] on a catalog product |
| Stage (print side) | A printable side of a blank: Front, Back, Sleeveโฆ stage_index 0 is always the front. |
print_config.stages[], mockup cells |
| Design | Artwork a merchant saved in the Design Configurator on a specific blank. Owned by one account. | GET /designs |
| Hub product | The merchant's sellable product on CeePrinto: one blank + a default design + optional per-variant overrides. Channel-independent; publishable to any store. | /products |
| Variant (inherit / override) | One row per blank variation inside a hub product. design_id: null inherits the default; a number overrides it. effective_design_id is the design actually printed. |
variants[], PATCH /products/{id}/variants/{variation_id} |
| Shop | A connected storefront. channel is a free-form label (shopify, woocommerce, custom); external_shop_id is your id for it. |
/shops |
| Listing | A link from a store product/variant (external_product_id, external_variant_id, external_sku) to a design and blank variation. How an incoming order line finds its design. |
/shops/{id}/listings |
| Publish job | A request to put designs (or a hub product) into a shop. Has one item per design. | POST /publish-jobs |
| Publish item | One design to publish. The connector does the store work and closes it with complete or fail. |
/publish-jobs/items/{item_id}/โฆ |
| Mode | create a new store product, link to an existing one, or update one already live. |
mode on publish jobs |
| Price mode | blank uses the blank's price; flat uses flat_price for every variant. |
price_mode, flat_price |
| Mockup matrix | Product photos of a design: one cell per variation ร stage. Each cell has a 240px thumb_url (hub UI) and a 2048px image_url (stores). |
GET /designs/{id}/variation-mockups |
| Intake | An order as received by the API, before it becomes a WooCommerce order. Statuses: received, processing, created, failed, ignored. |
POST /orders, GET /orders |
| WooCommerce order | What an intake becomes. Created as Pending payment in the merchant's My Account โ Orders. Its id is wc_order_id. |
order on GET /orders/{id} |
| customer_price | What the end buyer pays for a line, as a decimal string. Required. Drives how much is collected on delivery. | line_items[].customer_price |
| COD From Customer | The merchant's election, on the pending order, to have CeePrinto collect cash from the buyer on delivery and pay it out. additional_cod_from_customer is an extra amount (e.g. delivery fee) added to the collection. |
cod_from_customer, cod_collection_total |
| Payout | Money CeePrinto owes the merchant for a delivered COD order: sum of customer_price plus additional_cod_from_customer. Held 7 days after courier-confirmed delivery. |
GET /payouts |
| Fulfillment profile | The merchant's billing phone, address, city and country, used as the sender on every order. | fulfillment_profile on GET /me |
| Webhook subscription | A target_url that receives signed POSTs for one topic. |
/webhooks |
Payout statuses
Resolved in this order; the first match wins.
| payout_status | Meaning |
|---|---|
processed |
Paid out (label "Paid out"). processed_at is set. |
missing_bank |
The merchant has no bank code or account number in My Account โ Pay Out (label "Missing bank details"). Checked before delivery state. |
awaiting_delivery |
Delivery not confirmed yet (label "Awaiting delivery"). |
in_hold |
Delivered; inside the 7-day hold (label "7-day hold"). release_at says when it ends. |
ready |
Hold over; due in the next payout (label "Ready for payout"). |
How the pieces fit
- The merchant saves a design on a blank in the Design Configurator.
- Optionally they compose a hub product: default design plus per-variant overrides.
- A shop is connected (
POST /shops). - A publish job is created. The connector polls pending items, fetches the mockup matrix, creates the store product, registers one listing per store variant (
POST โฆ/listings/bulk) and marks the itemcomplete. - A buyer orders in the store. The connector submits an intake (
POST /orders) withcustomer_priceper line. - The hub resolves each line through the listings, creates a Pending payment WooCommerce order, and the merchant elects COD From Customer.
- CeePrinto prints and ships;
order.shippedcarries tracking. - After delivery and the 7-day hold, the amount appears as a payout.
API ยท Start here
Quickstart (curl)
A step-by-step curl walkthrough from first key to a published listing and a test order.
With nothing but curl and an API key you will connect a store, publish a design, submit a COD order and subscribe to events, the same protocol the Shopify app and CeePrinto Connect use.
A channel: "custom" integration gets exactly the same behaviour as the official connectors, including cash on delivery. Use a key with all eight scopes (created on My Account โ Store Connections โ API Keys) so every step below works.
-
Set your base URL and key
export CP_BASE="https://ceeprinto.com/wp-json/ceeprinto/v2" export CP_KEY="cp_live_xxxxxxxxxxxxxxxxxxxxxxxx.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" alias cp='curl -s -H "Authorization: Bearer $CP_KEY" -H "Content-Type: application/json"' -
Who am I (200)
cp "$CP_BASE/me"Returns your numeric account id (
data.id), grantedscopes, and whether your billing details are complete (fulfillment_profile.complete). -
Connect a store (201)
cp -X POST "$CP_BASE/shops" \ -d '{"channel":"custom","external_shop_id":"my-store-01","name":"My Store"}' # โ 201 { "data": { "id": 5, "channel": "custom", "status": "active", ... } } export CP_SHOP=5channelis a free-form label:customhere,woocommercefor a Woo plugin,shopifyfor the app. Re-posting the sameexternal_shop_idrefreshes the shop instead of duplicating it. 409 means another CeePrinto account already owns it. -
Browse designs and the catalog (200)
cp "$CP_BASE/designs?per_page=20" cp "$CP_BASE/designs/11" cp "$CP_BASE/catalog/products?per_page=100" cp "$CP_BASE/catalog/products/37"A design's
product_idis the blank it was made on. The catalog product lists that blank'svariationsand printstages. -
Get the design's product images (200)
cp "$CP_BASE/designs/11/variation-mockups" # โ { "data": { "status": "ready", "total": 24, "store_status": "ready", "store_ready": 24, "queued": 0, # "items": [ { "variation_id": 101, "stage_index": 0, "stage_name": "Front", # "store_ready": true, "thumb_url": "...", "image_url": "..." }, ... ] } }Mockups render on request at the URLs returned, so there is nothing to queue and usually nothing to wait for. Still check
store_statusis"ready"(or"partial", skipping cells withfailed: true) before uploading.POSTto the same URL is kept for older clients and answers 202 withqueued: 0.Rule Why Upload image_url(2048px), neverthumb_url(240px)Thumbs are for the hub UI and look blurry in a store. stage_index0 is the frontUse it as the main product image. Key cells by variation_id+stage_indexThere is one cell per variation per side. A plain variation_id โ urlmap lets the back overwrite the front. -
Link the design to your store product (201)
For one variant:
cp -X POST "$CP_BASE/shops/$CP_SHOP/listings" \ -d '{"design_id":11,"product_id":37,"variation_id":101,"external_product_id":"9001","external_variant_id":"9001-1","external_sku":"TEE-L"}'For a full Size ร Color matrix use bulk (200 with a per-row summary), one row per store variant, each with its own
design_id:cp -X POST "$CP_BASE/shops/$CP_SHOP/listings/bulk" \ -d '{"listings":[ {"design_id":11,"product_id":37,"variation_id":101,"external_product_id":"9001","external_variant_id":"9001-1"}, {"design_id":11,"product_id":37,"variation_id":102,"external_product_id":"9001","external_variant_id":"9001-2"} ]}'external_variant_id/external_skuare how your store identifies the item; CeePrinto resolves incoming orders back to the design through these listings. -
Or publish through a publish job (201) and poll it
# Create a brand-new product in the store: cp -X POST "$CP_BASE/publish-jobs" \ -d '{"shop_id":'"$CP_SHOP"',"design_ids":[11],"price_mode":"blank","mode":"create"}' # Or attach the design to a product you already sell: cp -X POST "$CP_BASE/publish-jobs" \ -d '{"shop_id":'"$CP_SHOP"',"design_ids":[11],"mode":"link","external_product_id":"9001"}' # Or publish a whole hub product (default design + per-variant overrides): cp -X POST "$CP_BASE/publish-jobs" \ -d '{"shop_id":'"$CP_SHOP"',"product_id":42,"price_mode":"blank","mode":"create"}' # Then poll. Do not rely on the design.publish_requested webhook alone. cp "$CP_BASE/shops/$CP_SHOP/publish-jobs/pending" # โ { "data": [ { "id": 7, "job_id": 3, "design_id": 11, "mode": "create", # "external_product_id": null, "variant_count": 12, "status": "pending", ... } ] } # Do the store work, then close the item (200). Always one or the other: cp -X POST "$CP_BASE/publish-jobs/items/7/complete" -d '{"external_product_id":"9001"}' cp -X POST "$CP_BASE/publish-jobs/items/7/fail" -d '{"error":"variant matrix rejected"}'modedefaults tocreate. An unknown mode is 422, so a typo never creates a duplicate product.linkandupdaterequireexternal_product_id(422 without). Full walkthrough: Publish a design to a store. -
Submit an order (202)
cp -X POST "$CP_BASE/orders" \ -H "Idempotency-Key: my-store-01:1001" \ -d '{ "shop_id": '"$CP_SHOP"', "external_order_id": "1001", "currency": "PKR", "shipping_address": { "first_name": "Bilal", "address_1": "Flat 4B, Block 5, Clifton", "city": "Karachi", "country": "PK", "phone": "03001234567" }, "line_items": [ { "external_variant_id": "9001-1", "quantity": 1, "customer_price": "2500.00", "customer_variant": "L / Black" } ] }' # โ 202 { "data": { "id": 1, "status": "received", "wc_order_id": null, ... } }Rule Result if broken Idempotency-Keyheader is required400 invalid_requestcustomer_priceon every line (what the buyer pays, collected on COD)422 with details.issues[]; never defaultedshop_idmust be yours400 (not 404) Same Idempotency-Keyagain200 with the original intake and meta.idempotent_replay: true; nothing new is created, so retrying a timeout is safe -
Watch it become an order (200)
cp "$CP_BASE/orders/1" cp "$CP_BASE/orders/1/events"The order is created as Pending payment in the merchant's My Account โ Orders, where they elect COD From Customer. If a line item cannot be matched the intake becomes
failedwith a specificlast_error: create the missing listing and retry from My Account โ Store Connections โ Order Activity. -
Receive events (201)
cp -X POST "$CP_BASE/webhooks" \ -d '{"topic":"order.status_changed","target_url":"https://my-store-01.pk/webhooks/ceeprinto"}' # โ 201 { "data": { "id": 9, "secret": "whsec_...", ... } }Each delivery is signed
X-CP-Signature: t=<ts>,v1=<hex>, an HMAC-SHA256 of<ts>.<raw body>with the secret. See Webhooks for verification code.
API ยท Guides
Connect a store
Register your shop with CeePrinto so designs can be published to it and orders can flow back.
Registering your storefront once gives you a shop id that every publish job, listing and order refers to.
Prerequisites
- An API key with
shops:write(to connect) andlistings:read(to list shops). - A stable id for your store that you control (
external_shop_id): a domain, a Shopify shop handle, a tenant id.
Which path?
| Your store | Do this | channel |
|---|---|---|
| Shopify | Install the CeePrinto Shopify app and paste the cp1. code from My Account โ Store Connections โ Get Started. The app calls POST /shops for you. |
shopify |
| WooCommerce | Install CeePrinto Connect and paste the cp1. code. The plugin calls POST /shops for you. |
woocommerce |
| Anything else | Call POST /shops yourself, as below. |
custom (or any label up to 32 characters) |
-
Register the shop
POST /shopsupserts on (channel,external_shop_id), so it is safe to call on every connector start-up.POST /shopscurl -s -X POST "$CP_BASE/shops" \ -H "Authorization: Bearer $CP_KEY" \ -H "Content-Type: application/json" \ -d '{"channel":"custom","external_shop_id":"my-store-01","name":"My Store"}'<?php $ch = curl_init(getenv('CP_BASE') . '/shops'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('CP_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'channel' => 'custom', 'external_shop_id' => 'my-store-01', 'name' => 'My Store', ]), CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $json = json_decode($raw, true); if ($status === 409) { exit("This store is connected to another CeePrinto account.\n"); } if ($status !== 201) { throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']); } $shopId = $json['data']['id']; echo "Connected as shop $shopId\n"; // persist thisconst res = await fetch(`${process.env.CP_BASE}/shops`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.CP_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ channel: 'custom', external_shop_id: 'my-store-01', name: 'My Store' }), }); const json = await res.json(); if (res.status === 409) { console.error('This store is connected to another CeePrinto account.'); process.exit(1); } if (res.status !== 201) throw new Error(`${json.error.code}: ${json.error.message}`); const shopId = json.data.id; // persist this console.log(`Connected as shop ${shopId}`);import os import sys import requests res = requests.post( f"{os.environ['CP_BASE']}/shops", headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"}, json={"channel": "custom", "external_shop_id": "my-store-01", "name": "My Store"}, timeout=30, ) body = res.json() if res.status_code == 409: sys.exit("This store is connected to another CeePrinto account.") if res.status_code != 201: raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}") shop_id = body["data"]["id"] # persist this print(f"Connected as shop {shop_id}"){ "data": { "id": 5, "channel": "custom", "external_shop_id": "my-store-01", "name": "My Store", "webhook_url": null, "status": "active", "connected_at": "2026-09-29T10:15:00+00:00" } } -
Store the shop id
Save
data.id(here5). You need it for/shops/{id}/listings,/shops/{id}/publish-jobs/pending,POST /publish-jobsandPOST /orders. -
Check the connection
curl -s "$CP_BASE/shops" -H "Authorization: Bearer $CP_KEY" curl -s "$CP_BASE/shops/5" -H "Authorization: Bearer $CP_KEY"The shop now appears in My Account โ Store Connections โ Stores.
-
Subscribe to events (optional)
Subscribe to
design.publish_requestedandorder.shippedso your store reacts quickly. See Webhooks. Always poll as well; webhooks are best-effort. -
Disconnect or reconnect
# Disconnect (soft): 200 with "status": "disconnected" curl -s -X DELETE "$CP_BASE/shops/5" -H "Authorization: Bearer $CP_KEY" # Reconnect: POST the same channel + external_shop_id again (201, status back to "active") curl -s -X POST "$CP_BASE/shops" -H "Authorization: Bearer $CP_KEY" -H "Content-Type: application/json" \ -d '{"channel":"custom","external_shop_id":"my-store-01","name":"My Store"}'
Outcomes
| Call | Status | Meaning |
|---|---|---|
POST /shops, new store |
201 | Created, status: "active" |
POST /shops, same store again |
201 | Refreshed (name overwritten, disconnected shop reactivated), same id. Always send name: omitting it clears it. |
POST /shops, store owned by another account |
409 | conflict. Ask the other account to disconnect it. |
POST /shops, missing or > 32-character channel |
400 | invalid_request or rest_missing_callback_param |
DELETE /shops/{id} |
200 | Body is the shop with status: "disconnected". Listings and orders are kept. |
| Any route with another account's shop id | 404 | not_found |
API ยท Guides
Publish a design to a store
Generate mockups, wait until the store is ready, create a publish job and poll it to completion.
A publish job turns a design or hub product into a real product in your store, with correct images per variant and a listing per variant so orders route back to the right design.
Prerequisites
- A connected shop id (Connect a store).
- A key with
listings:write,listings:readanddesigns:read. - A design id (
GET /designs) or a hub product id (GET /products).
Who does what
| Step | Done by |
|---|---|
| Create the publish job | The merchant (Products or Get Started tab in My Account) or you via POST /publish-jobs |
| Find pending items | Your connector, by polling GET /shops/{id}/publish-jobs/pending (and optionally the design.publish_requested webhook) |
| Create or update the store product, upload images | Your connector |
| Register listings | Your connector, POST /shops/{id}/listings/bulk |
| Close the item | Your connector, complete or fail |
Choosing mode
| mode | What the connector does | external_product_id |
Already-listed designs |
|---|---|---|---|
create (default) |
Creates a new store product. | Not needed | Skipped. If every design is already listed: 400. |
link |
Attaches the design to an existing store product (adds listings, images). | Required (422 without) | Included |
update |
Non-destructively edits a product already live in the store. | Required (422 without) | Included |
| anything else | Nothing: rejected so a typo never creates a duplicate. | 422 unprocessable_entity |
Choosing price_mode
| price_mode | Store price |
|---|---|
blank (default) |
The blank's price (from /catalog/products). For hub products, a variant's own price, when set, is passed to the connector in variants[] to apply. |
flat |
flat_price for every variant |
-
Create the publish job
Send
design_idsfor one or more designs, orproduct_idfor a hub product (designs and per-variant composition are derived from it). Answers 201 and firesdesign.publish_requested.POST /publish-jobscurl -s -X POST "$CP_BASE/publish-jobs" \ -H "Authorization: Bearer $CP_KEY" \ -H "Content-Type: application/json" \ -d '{"shop_id":5,"design_ids":[11],"price_mode":"blank","mode":"create"}'<?php $ch = curl_init(getenv('CP_BASE') . '/publish-jobs'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('CP_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'shop_id' => 5, 'design_ids' => [11], 'price_mode' => 'blank', 'mode' => 'create', ]), CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $json = json_decode($raw, true); if ($status !== 201) { throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']); } foreach ($json['data']['items'] as $item) { echo "Item {$item['id']}: design {$item['design_id']}, {$item['variant_count']} variants\n"; }const res = await fetch(`${process.env.CP_BASE}/publish-jobs`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.CP_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ shop_id: 5, design_ids: [11], price_mode: 'blank', mode: 'create' }), }); const json = await res.json(); if (res.status !== 201) throw new Error(`${json.error.code}: ${json.error.message}`); for (const item of json.data.items) { console.log(`Item ${item.id}: design ${item.design_id}, ${item.variant_count} variants`); }import os import requests res = requests.post( f"{os.environ['CP_BASE']}/publish-jobs", headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"}, json={"shop_id": 5, "design_ids": [11], "price_mode": "blank", "mode": "create"}, timeout=30, ) body = res.json() if res.status_code != 201: raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}") for item in body["data"]["items"]: print(f"Item {item['id']}: design {item['design_id']}, {item['variant_count']} variants")# Publish a hub product at a flat price curl -s -X POST "$CP_BASE/publish-jobs" -H "Authorization: Bearer $CP_KEY" -H "Content-Type: application/json" \ -d '{"shop_id":5,"product_id":42,"price_mode":"flat","flat_price":2500,"mode":"create"}' # Attach design 11 to store product 9001 curl -s -X POST "$CP_BASE/publish-jobs" -H "Authorization: Bearer $CP_KEY" -H "Content-Type: application/json" \ -d '{"shop_id":5,"design_ids":[11],"mode":"link","external_product_id":"9001"}' -
Poll for pending items
Poll every 30 to 60 seconds. The webhook is only a hint to poll sooner: a missed delivery would otherwise lose the publish silently. The list is not paginated.
curl -s "$CP_BASE/shops/5/publish-jobs/pending" -H "Authorization: Bearer $CP_KEY"{ "data": [ { "id": 7, "job_id": 3, "design_id": 11, "product_id": 37, "variant_count": 12, "status": "pending", "mode": "create", "external_product_id": null, "product_id_hub": 42, "error_text": null, "created_at": "2026-09-29T10:15:00+00:00", "updated_at": "2026-09-29T10:15:00+00:00", "completed_at": null, "shop_id": 5, "price_mode": "blank", "flat_price": null } ] }Handle webhook-delivered and polled items with the same code, and de-duplicate by item
id. -
Work out each variant's design
Item has Read variants from product_id_hubsetGET /products/{product_id_hub}: use each variant'seffective_design_id, skipenabled: false, use itsprice/skuwhen setproduct_id_hub: nullGET /catalog/products/{product_id}: every variation uses the item'sdesign_id -
Fetch the images
curl -s "$CP_BASE/designs/11/variation-mockups" -H "Authorization: Bearer $CP_KEY"For each distinct design, read the matrix. Mockups render on request at the URLs returned; nothing is queued (
queuedis0).Check Rule store_statusUpload only when "ready"."partial"means some cells failed: upload the rest, skipfailed: true."generating": wait 5 seconds and GET again."unavailable": the renderer is off; fail the item.Which URL image_url(2048px). Neverthumb_url(240px).Which side stage_index0 is the front: make it the main image. Other stages are extra gallery images.Keying By variation_id+stage_index. One cell per variation per stage.Fetching Download each image_urland upload the bytes to your store. Retry a failed download once after a few seconds. -
Create the product and register listings
Create (or link, or update) the store product with one store variant per enabled blank variation. Then register one listing per store variant in a single bulk call:
curl -s -X POST "$CP_BASE/shops/5/listings/bulk" \ -H "Authorization: Bearer $CP_KEY" -H "Content-Type: application/json" \ -d '{"listings":[ {"design_id":11,"product_id":37,"variation_id":101,"external_product_id":"9001","external_variant_id":"9001-1"}, {"design_id":12,"product_id":37,"variation_id":102,"external_product_id":"9001","external_variant_id":"9001-2"} ]}' # โ 200 { "data": [ ... ], "meta": { "created": 2, "failed": 0, "errors": [] } }Always send
external_variant_id: listings upsert on it, so a retry updates instead of duplicating. Checkmeta.failed; rows fail independently. -
Close the item
# Success: send the store product id curl -s -X POST "$CP_BASE/publish-jobs/items/7/complete" \ -H "Authorization: Bearer $CP_KEY" -H "Content-Type: application/json" \ -d '{"external_product_id":"9001"}' # Failure: the message is shown to the merchant curl -s -X POST "$CP_BASE/publish-jobs/items/7/fail" \ -H "Authorization: Bearer $CP_KEY" -H "Content-Type: application/json" \ -d '{"error":"Store rejected the variant matrix: too many options"}'Every item must end in one of the two, or it stays pending and the merchant sees "publishing" forever. For hub-product items,
completealso back-fillsproduct_id_hubon the matching listings.
After publishing
| Change | Call | What your connector gets |
|---|---|---|
| Swap a hub variant's design | PATCH /products/{id}/variants/{variation_id} {"design_id": 12} |
product.updated with the new per-variant designs and shop_ids; listings already updated on the hub |
| Change the default design | PATCH /products/{id} {"default_design_id": 12} |
product.updated |
| Override one listing only | PATCH /shops/{id}/listings/{listing_id} |
Nothing (no webhook) |
| Store product deleted | DELETE /shops/{id}/listings?external_product_id=9001 |
204 |
Errors when creating a job
| Check (in order) | Status |
|---|---|
Unknown mode |
422 unprocessable_entity |
shop_id not yours |
404 not_found |
product_id not yours |
404 not_found |
No design_ids and no product_id (or product has no designs) |
400 invalid_request |
link/update without external_product_id |
422 unprocessable_entity |
| Every design already published to this shop, or unloadable | 400 invalid_request |
API ยท Guides
Submit and track orders
Send orders with an Idempotency-Key, understand line-item resolution and follow order status.
Submitting each store order once, with an Idempotency-Key and the buyer's price per line, gets it printed, shipped and collected on delivery, and lets you follow it to tracking.
Prerequisites
- A key with
orders:write. Reading orders back needsorders:read, which connection-code keys ("Shopify App", "WooCommerce Connect") do not have. - A connected shop id, and listings for the products you sell (Publish a design), or the design id per line.
- The merchant's billing details complete (
GET /meโfulfillment_profile.complete).
How a line item finds its design
Each line must carry at least one reference. The hub tries them in this order and uses the first match:
| Order | Reference | Matched against |
|---|---|---|
| 1 | listing_id |
Your listing with that id on this shop |
| 2 | external_variant_id |
A listing on this shop with that external_variant_id |
| 3 | external_sku |
A listing on this shop with that external_sku |
| 4 | external_variant_id / external_sku |
Legacy v1 integration products (older accounts only) |
| 5 | design_id |
A design you own (prints on the blank variation it was made on) |
If nothing matches, no WooCommerce order is created for any line and the intake is marked failed.
Required fields
| Field | Rule | If missing |
|---|---|---|
Idempotency-Key header |
Unique per order, max 191 characters. Scoped to the shop. Use <external_shop_id>:<external_order_id>. |
400 |
shop_id |
A shop you own | 400 (not 404) |
shipping_address.first_name, address_1, city |
Non-empty. country is ISO 3166-1 alpha-2 (PK). |
422 |
line_items |
At least one | 422 |
line_items[].quantity |
At least 1 | 422 |
line_items[].customer_price |
Numeric string, what the buyer pays. Never defaulted: 0 would collect nothing. | 422 |
| A reference per line | One of listing_id, external_variant_id, external_sku, design_id |
422 |
currency |
Optional, default PKR |
customer_price and quantity. The hub adds up customer_price across line items to get customer_price_total and cod_collection_total; it does not multiply by quantity. For lines with quantity above 1, confirm the collection amount on GET /orders/{id} (order.cod_collection_total) or in My Account โ Orders before the merchant elects COD.
-
Submit the order
A new key answers 202 with
status: "received". A background worker then builds the WooCommerce order, usually within seconds.POST /orderscurl -s -X POST "$CP_BASE/orders" \ -H "Authorization: Bearer $CP_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: my-store-01:1001" \ -d '{ "shop_id": 5, "external_order_id": "1001", "currency": "PKR", "shipping_address": { "first_name": "Bilal", "last_name": "Ahmed", "phone": "03001234567", "address_1": "Flat 4B, Block 5, Clifton", "city": "Karachi", "country": "PK" }, "line_items": [ { "external_variant_id": "9001-1", "quantity": 1, "customer_price": "2500.00", "customer_variant": "L / Black" } ] }'<?php $order = [ 'shop_id' => 5, 'external_order_id' => '1001', 'currency' => 'PKR', 'shipping_address' => [ 'first_name' => 'Bilal', 'last_name' => 'Ahmed', 'phone' => '03001234567', 'address_1' => 'Flat 4B, Block 5, Clifton', 'city' => 'Karachi', 'country' => 'PK', ], 'line_items' => [ ['external_variant_id' => '9001-1', 'quantity' => 1, 'customer_price' => '2500.00', 'customer_variant' => 'L / Black'], ], ]; $ch = curl_init(getenv('CP_BASE') . '/orders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('CP_KEY'), 'Content-Type: application/json', 'Idempotency-Key: my-store-01:1001', ], CURLOPT_POSTFIELDS => json_encode($order), CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $json = json_decode($raw, true); if ($status === 422) { throw new RuntimeException('Rejected: ' . implode('; ', $json['error']['details']['issues'])); } if ($status !== 202 && $status !== 200) { throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']); } $replay = !empty($json['meta']['idempotent_replay']); echo "Intake {$json['data']['id']} is {$json['data']['status']}" . ($replay ? " (replay)\n" : "\n");const order = { shop_id: 5, external_order_id: '1001', currency: 'PKR', shipping_address: { first_name: 'Bilal', last_name: 'Ahmed', phone: '03001234567', address_1: 'Flat 4B, Block 5, Clifton', city: 'Karachi', country: 'PK', }, line_items: [ { external_variant_id: '9001-1', quantity: 1, customer_price: '2500.00', customer_variant: 'L / Black' }, ], }; const res = await fetch(`${process.env.CP_BASE}/orders`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.CP_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'my-store-01:1001', }, body: JSON.stringify(order), }); const json = await res.json(); if (res.status === 422) throw new Error(`Rejected: ${json.error.details.issues.join('; ')}`); if (res.status !== 202 && res.status !== 200) throw new Error(`${json.error.code}: ${json.error.message}`); const replay = json.meta?.idempotent_replay === true; console.log(`Intake ${json.data.id} is ${json.data.status}${replay ? ' (replay)' : ''}`);import os import requests order = { "shop_id": 5, "external_order_id": "1001", "currency": "PKR", "shipping_address": { "first_name": "Bilal", "last_name": "Ahmed", "phone": "03001234567", "address_1": "Flat 4B, Block 5, Clifton", "city": "Karachi", "country": "PK", }, "line_items": [ {"external_variant_id": "9001-1", "quantity": 1, "customer_price": "2500.00", "customer_variant": "L / Black"}, ], } res = requests.post( f"{os.environ['CP_BASE']}/orders", headers={ "Authorization": f"Bearer {os.environ['CP_KEY']}", "Idempotency-Key": "my-store-01:1001", }, json=order, timeout=30, ) body = res.json() if res.status_code == 422: raise RuntimeError("Rejected: " + "; ".join(body["error"]["details"]["issues"])) if res.status_code not in (200, 202): raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}") replay = body.get("meta", {}).get("idempotent_replay", False) print(f"Intake {body['data']['id']} is {body['data']['status']}" + (" (replay)" if replay else "")){ "data": { "id": 1, "shop_id": 5, "external_order_id": "1001", "status": "received", "wc_order_id": null, "attempts": 0, "last_error": null, "received_at": "2026-09-29T10:15:00+00:00", "processed_at": null } } -
Retry safely
You got Do 202 Store data.id. Done.200 with meta.idempotent_replay: trueThis key was already accepted. datais the original intake (it may already becreated). Nothing new was made.Timeout / network error / 5xx / 429 Resend the same body with the same Idempotency-Key. Never mint a new key for a retry.400 / 403 / 422 Fix the request. Do not retry unchanged. A replay returns the first intake even if the body changed; use a new key only for a genuinely new order.
-
Follow the intake
curl -s "$CP_BASE/orders/1" -H "Authorization: Bearer $CP_KEY" curl -s "$CP_BASE/orders/1/events" -H "Authorization: Bearer $CP_KEY"Intake status Meaning receivedAccepted and queued. processingThe worker is building the order. attemptscounts tries.createdWooCommerce order exists: wc_order_idset,orderblock present onGET /orders/{id}.failedNo order was created. last_errorsays why.ignoredReserved; not set by the current API. -
Handle a failed intake
last_error starts with Cause Fix No listing, design or legacy product matched this line item (โฆ)No reference on a line resolved. The parentheses list what you sent. Create the listing ( POST /shops/{id}/listings) or senddesign_id, then the merchant clicks Retry in My Account โ Store Connections โ Order Activity.WooCommerce product N no longer exists.The blank or variation was removed from the catalog. Re-publish on a current blank and update the listing. Order has no line items.Stored payload had no lines. Submit a new order with a new key. A line item could not be added to the order.WooCommerce refused the product (e.g. not purchasable). Contact support with the X-CP-Request-Id.Retrying the same
Idempotency-Keythrough the API does not reprocess a failed intake; it returns it. Reprocessing is the Retry button in Order Activity. -
Merchant elects COD, CeePrinto ships
The order lands as Pending payment in the merchant's My Account โ Orders, where they elect COD From Customer (and any additional amount). Then subscribe to
order.status_changedandorder.shipped(Webhooks).order.shippedcarriestracking_number,tracking_companyandtracking_url; if you miss it,GET /orders/{id}โorder.trackinghas the same data.
Totals on GET /orders/{id}
| Field | Is |
|---|---|
order.total |
What the merchant owes CeePrinto: blank price per line plus shipping (default 200 PKR Karachi, 250 PKR elsewhere). |
order.customer_price_total |
Sum of line customer_price values. |
order.additional_cod_from_customer |
Extra amount the merchant added to the collection. |
order.cod_collection_total |
customer_price_total + additional_cod_from_customer: what the courier collects. |
API ยท Guides
Webhooks
Subscribe to event topics, verify the X-CP-Signature header and handle retries.
Webhooks push signed events to your server within seconds of an order, design, stock or publish change, so your store reacts without constant polling.
Prerequisites
- A key with
webhooks:write. - A public HTTPS endpoint that answers 2xx within 10 seconds.
Topics
| Topic | Fires when | Typical reaction |
|---|---|---|
order.status_changed |
A WooCommerce order placed through the API changes status | Update the order status in your store |
order.shipped |
An order moves to completed with a live courier booking |
Mark fulfilled, send tracking to the buyer |
design.updated |
A saved design changes | Refresh images if you show that design |
design.publish_requested |
A publish job is created | Poll pending items now |
product.stock_changed |
A blank or variation you have listed changes stock | Update availability in your store |
product.updated |
A published hub product's default or variant design changes | Swap images per variant |
Payload schemas and full examples: Webhook events reference.
Delivery
| Property | Value |
|---|---|
| Method | POST, Content-Type: application/json |
| Body | {"topic": "โฆ", "data": {โฆ}, "sent_at": "2026-09-29T10:15:00+00:00"} |
| Headers | X-CP-Topic: <topic>, X-CP-Signature: t=<unix ts>,v1=<hex> |
| Signature | Hex HMAC-SHA256 of <t>.<raw body> with the subscription's whsec_โฆ secret |
| Timeout | 10 seconds |
| Success | Any 2xx status |
| Attempts | 5 in total |
| Back-off | 1, 2, 4 and 8 minutes after attempts 1 to 4 |
| Order | Not guaranteed. Use sent_at and re-read state with GET when order matters. |
| Guarantee | Best-effort. Also poll GET /shops/{id}/publish-jobs/pending and GET /orders/{id}. |
-
Subscribe
One subscription per topic. The response contains the signing
secret; store it with the subscription id.for topic in order.status_changed order.shipped design.publish_requested product.stock_changed product.updated; do curl -s -X POST "$CP_BASE/webhooks" \ -H "Authorization: Bearer $CP_KEY" -H "Content-Type: application/json" \ -d '{"topic":"'"$topic"'","target_url":"https://my-store-01.pk/webhooks/ceeprinto"}' done # โ 201 { "data": { "id": 9, "topic": "order.status_changed", "secret": "whsec_Xk2p9QvT4mLr7sWn1bYc8dHf3gJa6eZu", "status": "active", ... } }GET /webhookslists your subscriptions, each with itssecret(treat the response as sensitive).DELETE /webhooks/{id}removes one (204). -
Verify the signature
Compute the HMAC over the raw request bytes, before any JSON parsing. The hub's JSON escapes slashes (
https:\/\/), so re-serialising the parsed body gives a different string and a false mismatch. Compare in constant time and reject timestamps more than 5 minutes from now (replay protection).Verify X-CP-Signature#!/usr/bin/env bash # verify.sh SIGNATURE_HEADER BODY_FILE (secret in CP_WEBHOOK_SECRET) sig_header="$1"; body_file="$2" t=$(printf '%s' "$sig_header" | sed -n 's/.*t=\([0-9]*\).*/\1/p') v1=$(printf '%s' "$sig_header" | sed -n 's/.*v1=\([0-9a-f]*\).*/\1/p') expected=$( { printf '%s.' "$t"; cat "$body_file"; } | openssl dgst -sha256 -hmac "$CP_WEBHOOK_SECRET" | awk '{print $NF}') now=$(date +%s) if [ "$expected" != "$v1" ]; then echo "bad signature"; exit 1; fi if [ $(( now > t ? now - t : t - now )) -gt 300 ]; then echo "stale timestamp"; exit 1; fi echo "ok"<?php function cp_verify_webhook(string $rawBody, string $sigHeader, string $secret, int $tolerance = 300): bool { $parts = []; foreach (explode(',', $sigHeader) as $pair) { [$k, $v] = array_pad(explode('=', trim($pair), 2), 2, ''); $parts[$k] = $v; } if (empty($parts['t']) || empty($parts['v1']) || !ctype_digit($parts['t'])) { return false; } if (abs(time() - (int) $parts['t']) > $tolerance) { return false; } $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']); } $raw = file_get_contents('php://input'); if (!cp_verify_webhook($raw, $_SERVER['HTTP_X_CP_SIGNATURE'] ?? '', getenv('CP_WEBHOOK_SECRET'))) { http_response_code(400); exit('bad signature'); } $event = json_decode($raw, true); http_response_code(200);import crypto from 'node:crypto'; export function verifyWebhook(rawBody, sigHeader, secret, toleranceSec = 300) { const parts = Object.fromEntries( String(sigHeader || '').split(',').map((p) => p.trim().split('=', 2)), ); if (!/^\d+$/.test(parts.t || '') || !/^[0-9a-f]{64}$/.test(parts.v1 || '')) return false; if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false; const expected = crypto .createHmac('sha256', secret) .update(`${parts.t}.`) .update(rawBody) // Buffer of the exact request bytes .digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parts.v1, 'hex')); }import hashlib import hmac import time def verify_webhook(raw_body: bytes, sig_header: str, secret: str, tolerance: int = 300) -> bool: parts = dict(p.strip().split("=", 1) for p in (sig_header or "").split(",") if "=" in p) t, v1 = parts.get("t", ""), parts.get("v1", "") if not t.isdigit() or not v1: return False if abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1)Complete runnable servers are in Recipes.
-
Answer fast, work later
Return 2xx as soon as the signature checks out, then process asynchronously (a queue, a background job). Slow handlers hit the 10-second timeout and are retried, which causes duplicates.
-
Be idempotent
Retries and the reduced duplicate below mean the same event can arrive more than once. De-duplicate on the natural key:
wc_order_id+statusfor orders,job_id/item_idfor publishes,product_id+variation_id+stock_statusfor stock. -
Keep polling
After 5 failed attempts an event is dropped. Poll
GET /shops/{id}/publish-jobs/pendingevery 30 to 60 seconds, and re-readGET /orders/{id}for open orders, so nothing is lost.
Quirks to handle
| Situation | What you receive |
|---|---|
| Order completes with a courier booking | order.shipped (full payload with tracking) and a reduced order.status_changed with only wc_order_id and status |
| Publish job created from the Get Started tab | design.publish_requested without mode, product_id_hub, variants or per-item mode; treat as create |
| Stock change on a blank you have not listed | Nothing: product.stock_changed goes only to merchants with a listing for that product or variation |
| Subscription deleted or inactive | Queued retries stop |
API ยท Guides
Payouts and shipping quotes
Read your payout history and request shipping quotes before placing an order.
Read the same COD payout ledger the merchant sees in My Account โ Pay Out, and quote shipping for a city before you take an order.
Prerequisites
All three routes need orders:read. Connection-code keys ("Shopify App", "WooCommerce Connect") do not have it and get 403. Create a separate key with orders:read on the API Keys tab for reporting.
Routes
| Route | Returns | Paginated |
|---|---|---|
GET /payouts |
One row per COD order: amount, bucket, hold end, tracking | Yes (page, per_page max 100) |
GET /payouts/summary |
Totals and counts per bucket (the five My Account cards) | No; cached 15 minutes (meta.cached) |
POST /shipping/quote |
Flat rate for a city in PKR | No |
-
List payouts still owed
statustakesall(default),pending(everything not yet paid out) or one bucket.date_from/date_to(YYYY-MM-DD, inclusive) filter on order creation date.GET /payoutscurl -s "$CP_BASE/payouts?status=pending&date_from=2026-09-01&per_page=100" \ -H "Authorization: Bearer $CP_KEY"<?php $query = http_build_query(['status' => 'pending', 'date_from' => '2026-09-01', 'per_page' => 100]); $ch = curl_init(getenv('CP_BASE') . '/payouts?' . $query); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('CP_KEY')], CURLOPT_TIMEOUT => 30, ]); $raw = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $json = json_decode($raw, true); if ($status === 403) { exit("This key lacks orders:read. Use a key from the API Keys tab.\n"); } if ($status !== 200) { throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']); } foreach ($json['data'] as $row) { printf("#%s %s %.2f %s\n", $row['number'], $row['currency'], $row['amount'], $row['payout_status_label']); }const params = new URLSearchParams({ status: 'pending', date_from: '2026-09-01', per_page: '100' }); const res = await fetch(`${process.env.CP_BASE}/payouts?${params}`, { headers: { Authorization: `Bearer ${process.env.CP_KEY}` }, }); const json = await res.json(); if (res.status === 403) throw new Error('This key lacks orders:read. Use a key from the API Keys tab.'); if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`); for (const row of json.data) { console.log(`#${row.number} ${row.currency} ${row.amount.toFixed(2)} ${row.payout_status_label}`); }import os import requests res = requests.get( f"{os.environ['CP_BASE']}/payouts", headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"}, params={"status": "pending", "date_from": "2026-09-01", "per_page": 100}, timeout=30, ) body = res.json() if res.status_code == 403: raise SystemExit("This key lacks orders:read. Use a key from the API Keys tab.") if not res.ok: raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}") for row in body["data"]: print(f"#{row['number']} {row['currency']} {row['amount']:.2f} {row['payout_status_label']}") -
Show the totals
curl -s "$CP_BASE/payouts/summary" -H "Authorization: Bearer $CP_KEY"{ "data": { "total": 184500, "processed": 120000, "pending": 64500, "ready": 22000, "in_hold": 30500, "awaiting_delivery": 12000, "missing_bank": 0, "count_total": 41, "count_processed": 27, "count_pending": 14, "count_ready": 5, "count_in_hold": 6, "count_awaiting_delivery": 3, "count_missing_bank": 0, "currency": "PKR" }, "meta": { "cached": false } }The summary is always the whole ledger (filters are ignored) and is cached per merchant for 15 minutes; the cache is cleared when an order changes status or a payout is processed.
-
Quote shipping
curl -s -X POST "$CP_BASE/shipping/quote" \ -H "Authorization: Bearer $CP_KEY" -H "Content-Type: application/json" \ -d '{"city":"Karachi"}' # โ 200 { "data": { "city": "Karachi", "total": 200, "currency": "PKR", "method_id": "flat_rate", "method_title": "Flat Rate" } }Send
cityor ashipping_addressobject (only itscityis read). Missing city is 400. The same rate is added as the shipping line whenPOST /orderscreates the WooCommerce order.Defaults; CeePrinto can change the table City (case-insensitive) Default rate Karachi,KHI200 PKR Anywhere else 250 PKR
Payout buckets
| payout_status | Label | Meaning |
|---|---|---|
processed |
Paid out | Paid; processed_at set |
missing_bank |
Missing bank details | No bank code or account number on file (checked before delivery state) |
awaiting_delivery |
Awaiting delivery | Not yet delivered |
in_hold |
7-day hold | Delivered; hold ends at release_at |
ready |
Ready for payout | Due in the next payout |
| Row field | Rule |
|---|---|
amount |
Owed to the merchant: sum of line customer_price plus additional_cod_from_customer |
payout_status_label |
Display as given; do not derive your own |
release_at, days_until_ready |
null until delivery is confirmed |
payment_url |
Non-null only when needs_payment is true. Show a "Pay now" action only then. |
API ยท Guides
Recipes: full scripts
Complete scripts in curl, PHP, Node and Python for publishing, webhooks, orders and catalog sync.
Four complete, runnable scripts in bash, PHP, Node and Python that you can copy, set environment variables for, and run as the starting point of a real integration.
| Recipe | Env variables | Scopes |
|---|---|---|
| Publish and poll | CP_BASE, CP_KEY, CP_SHOP_ID, CP_DESIGN_ID |
listings:write, listings:read, designs:read, products:read |
| Verify a webhook | CP_WEBHOOK_SECRET (whsec_โฆ) |
None (receiver) |
| Submit an order with idempotent retry | CP_BASE, CP_KEY, CP_SHOP_ID, CP_EXTERNAL_SHOP_ID |
orders:write (+ orders:read for the status check) |
| Sync catalog and stock | CP_BASE, CP_KEY |
products:read |
Runtimes: bash scripts need curl, jq and openssl; PHP 7.4+ with ext-curl (CLI); Node 18+ (save as .mjs); Python 3.8+ with pip install requests.
Publish and poll
Creates a publish job for one design, then works the pending queue for that job: waits for store-ready mockups, creates the store product (a stub you replace), registers one listing per variation, and closes each item with complete, or fail on any error.
#!/usr/bin/env bash
# publish-and-poll.sh Requires curl, jq.
# Env: CP_BASE, CP_KEY, CP_SHOP_ID, CP_DESIGN_ID
set -euo pipefail
: "${CP_BASE:?}" "${CP_KEY:?}" "${CP_SHOP_ID:?}" "${CP_DESIGN_ID:?}"
# api METHOD PATH [JSON]: prints the body; returns 1 on HTTP >= 400
api() {
local method="$1" path="$2" body="${3:-}" out status
out=$(mktemp)
local args=(-s -o "$out" -w "%{http_code}" -X "$method" "$CP_BASE$path" -H "Authorization: Bearer $CP_KEY")
[ -n "$body" ] && args+=(-H "Content-Type: application/json" -d "$body")
status=$(curl "${args[@]}")
if [ "$status" -ge 400 ]; then
echo "HTTP $status $method $path: $(cat "$out")" >&2
rm -f "$out"
return 1
fi
cat "$out"
rm -f "$out"
}
# Replace with your store's API. Must print the store product id.
create_store_product() {
local design_id="$1" images_tsv="$2"
echo "Would create a product for design $design_id with $(wc -l < "$images_tsv") images" >&2
echo "demo-$design_id-$(date +%s)"
}
process_item() {
local item="$1" item_id design_id product_id mode ext m status i
item_id=$(jq -r '.id' <<<"$item")
design_id=$(jq -r '.design_id' <<<"$item")
product_id=$(jq -r '.product_id' <<<"$item")
mode=$(jq -r '.mode' <<<"$item")
# Mockups render on request; wait for store_status ready or partial.
for i in $(seq 1 24); do
m=$(api GET "/designs/$design_id/variation-mockups") || return 1
status=$(jq -r '.data.store_status' <<<"$m")
case "$status" in
ready|partial) break ;;
unavailable) echo "Mockup renderer unavailable" >&2; return 1 ;;
esac
[ "$i" -eq 24 ] && { echo "Mockups not ready after 2 minutes" >&2; return 1; }
sleep 5
done
# One row per variation x stage. Key by variation_id + stage_index; 0 is the front.
local images
images=$(mktemp)
jq -r '.data.items[] | select(.failed | not) | [.variation_id, .stage_index, .image_url] | @tsv' <<<"$m" > "$images"
if [ "$mode" = "create" ]; then
ext=$(create_store_product "$design_id" "$images") || return 1
else
ext=$(jq -r '.external_product_id' <<<"$item")
fi
rm -f "$images"
local catalog listings
catalog=$(api GET "/catalog/products/$product_id") || return 1
listings=$(jq -c --arg ext "$ext" --argjson d "$design_id" --argjson p "$product_id" '
{listings: (if (.data.variations | length) == 0
then [{design_id: $d, product_id: $p, variation_id: 0, external_product_id: $ext, external_variant_id: $ext}]
else [.data.variations[] | {design_id: $d, product_id: $p, variation_id: .id,
external_product_id: $ext, external_variant_id: ($ext + "-" + (.id | tostring))}]
end)}' <<<"$catalog")
api POST "/shops/$CP_SHOP_ID/listings/bulk" "$listings" | jq -e '.meta.failed == 0' >/dev/null || return 1
api POST "/publish-jobs/items/$item_id/complete" "$(jq -nc --arg e "$ext" '{external_product_id: $e}')" >/dev/null
echo "Item $item_id complete -> store product $ext"
}
job=$(api POST /publish-jobs "$(jq -nc --argjson s "$CP_SHOP_ID" --argjson d "$CP_DESIGN_ID" \
'{shop_id: $s, design_ids: [$d], mode: "create", price_mode: "blank"}')")
job_id=$(jq -r '.data.id' <<<"$job")
echo "Created publish job $job_id"
for round in $(seq 1 60); do
pending=$(api GET "/shops/$CP_SHOP_ID/publish-jobs/pending" | jq -c --argjson j "$job_id" '[.data[] | select(.job_id == $j)]')
if [ "$(jq length <<<"$pending")" -eq 0 ]; then
echo "Job $job_id has no pending items. Done."
exit 0
fi
while read -r item; do
item_id=$(jq -r '.id' <<<"$item")
if ! process_item "$item"; then
api POST "/publish-jobs/items/$item_id/fail" '{"error":"Connector could not publish this design. See connector logs."}' >/dev/null || true
echo "Item $item_id failed" >&2
fi
done < <(jq -c '.[]' <<<"$pending")
sleep 5
done
echo "Gave up waiting on job $job_id" >&2
exit 1
<?php
// publish-and-poll.php php publish-and-poll.php
// Env: CP_BASE, CP_KEY, CP_SHOP_ID, CP_DESIGN_ID
function env(string $name): string {
$v = getenv($name);
if ($v === false || $v === '') {
fwrite(STDERR, "Missing env $name\n");
exit(1);
}
return $v;
}
function api(string $method, string $path, ?array $body = null): array {
$ch = curl_init(env('CP_BASE') . $path);
$headers = ['Authorization: Bearer ' . env('CP_KEY'), 'Accept: application/json'];
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($raw === false) {
throw new RuntimeException("Network error on $method $path: $err");
}
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException("HTTP $status $method $path: " . ($json['error']['code'] ?? '') . ' ' . ($json['error']['message'] ?? $raw));
}
return $json;
}
// Replace with your store's API. Returns the store product id.
function create_store_product(int $designId, array $images): string {
fwrite(STDERR, 'Would create a product for design ' . $designId . ' with ' . count($images) . " images\n");
return 'demo-' . $designId . '-' . time();
}
function wait_for_mockups(int $designId): array {
for ($i = 1; $i <= 24; $i++) {
$m = api('GET', "/designs/$designId/variation-mockups")['data'];
if (in_array($m['store_status'], ['ready', 'partial'], true)) {
return $m;
}
if ($m['store_status'] === 'unavailable') {
throw new RuntimeException('Mockup renderer unavailable');
}
sleep(5);
}
throw new RuntimeException('Mockups not ready after 2 minutes');
}
function process_item(array $item, int $shopId): void {
$mockups = wait_for_mockups((int) $item['design_id']);
// Key by variation_id + stage_index; stage 0 is the front. Upload image_url, never thumb_url.
$images = [];
foreach ($mockups['items'] as $cell) {
if (!$cell['failed']) {
$images[$cell['variation_id'] . ':' . $cell['stage_index']] = $cell['image_url'];
}
}
$ext = $item['mode'] === 'create'
? create_store_product((int) $item['design_id'], $images)
: (string) $item['external_product_id'];
$catalog = api('GET', '/catalog/products/' . $item['product_id'])['data'];
$rows = [];
$variations = $catalog['variations'] ?: [['id' => 0]];
foreach ($variations as $v) {
$rows[] = [
'design_id' => (int) $item['design_id'],
'product_id' => (int) $item['product_id'],
'variation_id' => (int) $v['id'],
'external_product_id' => $ext,
'external_variant_id' => $v['id'] ? $ext . '-' . $v['id'] : $ext,
];
}
$bulk = api('POST', "/shops/$shopId/listings/bulk", ['listings' => $rows]);
if ($bulk['meta']['failed'] > 0) {
throw new RuntimeException('Listing rows failed: ' . json_encode($bulk['meta']['errors']));
}
api('POST', "/publish-jobs/items/{$item['id']}/complete", ['external_product_id' => $ext]);
echo "Item {$item['id']} complete -> store product $ext\n";
}
$shopId = (int) env('CP_SHOP_ID');
$designId = (int) env('CP_DESIGN_ID');
$job = api('POST', '/publish-jobs', ['shop_id' => $shopId, 'design_ids' => [$designId], 'mode' => 'create', 'price_mode' => 'blank']);
$jobId = $job['data']['id'];
echo "Created publish job $jobId\n";
for ($round = 1; $round <= 60; $round++) {
$pending = array_filter(
api('GET', "/shops/$shopId/publish-jobs/pending")['data'],
fn ($i) => (int) $i['job_id'] === (int) $jobId
);
if (!$pending) {
echo "Job $jobId has no pending items. Done.\n";
exit(0);
}
foreach ($pending as $item) {
try {
process_item($item, $shopId);
} catch (Throwable $e) {
fwrite(STDERR, "Item {$item['id']} failed: {$e->getMessage()}\n");
try {
api('POST', "/publish-jobs/items/{$item['id']}/fail", ['error' => substr($e->getMessage(), 0, 500)]);
} catch (Throwable $ignored) {
}
}
}
sleep(5);
}
fwrite(STDERR, "Gave up waiting on job $jobId\n");
exit(1);
// publish-and-poll.mjs node publish-and-poll.mjs
// Env: CP_BASE, CP_KEY, CP_SHOP_ID, CP_DESIGN_ID
for (const name of ['CP_BASE', 'CP_KEY', 'CP_SHOP_ID', 'CP_DESIGN_ID']) {
if (!process.env[name]) { console.error(`Missing env ${name}`); process.exit(1); }
}
const shopId = Number(process.env.CP_SHOP_ID);
const designId = Number(process.env.CP_DESIGN_ID);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function api(method, path, body) {
const res = await fetch(`${process.env.CP_BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30000),
});
const json = res.status === 204 ? null : await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status} ${method} ${path}: ${json?.error?.code} ${json?.error?.message}`);
return json;
}
// Replace with your store's API. Returns the store product id.
async function createStoreProduct(designId, images) {
console.error(`Would create a product for design ${designId} with ${images.size} images`);
return `demo-${designId}-${Date.now()}`;
}
async function waitForMockups(designId) {
for (let i = 1; i <= 24; i++) {
const { data } = await api('GET', `/designs/${designId}/variation-mockups`);
if (data.store_status === 'ready' || data.store_status === 'partial') return data;
if (data.store_status === 'unavailable') throw new Error('Mockup renderer unavailable');
await sleep(5000);
}
throw new Error('Mockups not ready after 2 minutes');
}
async function processItem(item) {
const mockups = await waitForMockups(item.design_id);
// Key by variation_id + stage_index; stage 0 is the front. Upload image_url, never thumb_url.
const images = new Map();
for (const cell of mockups.items) {
if (!cell.failed) images.set(`${cell.variation_id}:${cell.stage_index}`, cell.image_url);
}
const ext = item.mode === 'create'
? await createStoreProduct(item.design_id, images)
: item.external_product_id;
const { data: blank } = await api('GET', `/catalog/products/${item.product_id}`);
const variations = blank.variations.length ? blank.variations : [{ id: 0 }];
const listings = variations.map((v) => ({
design_id: item.design_id,
product_id: item.product_id,
variation_id: v.id,
external_product_id: ext,
external_variant_id: v.id ? `${ext}-${v.id}` : ext,
}));
const bulk = await api('POST', `/shops/${shopId}/listings/bulk`, { listings });
if (bulk.meta.failed > 0) throw new Error(`Listing rows failed: ${JSON.stringify(bulk.meta.errors)}`);
await api('POST', `/publish-jobs/items/${item.id}/complete`, { external_product_id: ext });
console.log(`Item ${item.id} complete -> store product ${ext}`);
}
const job = await api('POST', '/publish-jobs', {
shop_id: shopId, design_ids: [designId], mode: 'create', price_mode: 'blank',
});
const jobId = job.data.id;
console.log(`Created publish job ${jobId}`);
for (let round = 1; round <= 60; round++) {
const { data } = await api('GET', `/shops/${shopId}/publish-jobs/pending`);
const pending = data.filter((i) => i.job_id === jobId);
if (pending.length === 0) {
console.log(`Job ${jobId} has no pending items. Done.`);
process.exit(0);
}
for (const item of pending) {
try {
await processItem(item);
} catch (err) {
console.error(`Item ${item.id} failed: ${err.message}`);
await api('POST', `/publish-jobs/items/${item.id}/fail`, { error: err.message.slice(0, 500) }).catch(() => {});
}
}
await sleep(5000);
}
console.error(`Gave up waiting on job ${jobId}`);
process.exit(1);
# publish_and_poll.py python publish_and_poll.py
# Env: CP_BASE, CP_KEY, CP_SHOP_ID, CP_DESIGN_ID
import os
import sys
import time
import requests
for name in ("CP_BASE", "CP_KEY", "CP_SHOP_ID", "CP_DESIGN_ID"):
if not os.environ.get(name):
sys.exit(f"Missing env {name}")
BASE = os.environ["CP_BASE"]
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['CP_KEY']}"
SHOP_ID = int(os.environ["CP_SHOP_ID"])
DESIGN_ID = int(os.environ["CP_DESIGN_ID"])
def api(method, path, body=None):
res = SESSION.request(method, BASE + path, json=body, timeout=30)
data = None if res.status_code == 204 else res.json()
if not res.ok:
err = (data or {}).get("error", {})
raise RuntimeError(f"HTTP {res.status_code} {method} {path}: {err.get('code')} {err.get('message')}")
return data
def create_store_product(design_id, images):
"""Replace with your store's API. Returns the store product id."""
print(f"Would create a product for design {design_id} with {len(images)} images", file=sys.stderr)
return f"demo-{design_id}-{int(time.time())}"
def wait_for_mockups(design_id):
for _ in range(24):
data = api("GET", f"/designs/{design_id}/variation-mockups")["data"]
if data["store_status"] in ("ready", "partial"):
return data
if data["store_status"] == "unavailable":
raise RuntimeError("Mockup renderer unavailable")
time.sleep(5)
raise RuntimeError("Mockups not ready after 2 minutes")
def process_item(item):
mockups = wait_for_mockups(item["design_id"])
# Key by variation_id + stage_index; stage 0 is the front. Upload image_url, never thumb_url.
images = {
(cell["variation_id"], cell["stage_index"]): cell["image_url"]
for cell in mockups["items"]
if not cell["failed"]
}
if item["mode"] == "create":
ext = create_store_product(item["design_id"], images)
else:
ext = item["external_product_id"]
blank = api("GET", f"/catalog/products/{item['product_id']}")["data"]
variations = blank["variations"] or [{"id": 0}]
listings = [
{
"design_id": item["design_id"],
"product_id": item["product_id"],
"variation_id": v["id"],
"external_product_id": ext,
"external_variant_id": f"{ext}-{v['id']}" if v["id"] else ext,
}
for v in variations
]
bulk = api("POST", f"/shops/{SHOP_ID}/listings/bulk", {"listings": listings})
if bulk["meta"]["failed"] > 0:
raise RuntimeError(f"Listing rows failed: {bulk['meta']['errors']}")
api("POST", f"/publish-jobs/items/{item['id']}/complete", {"external_product_id": ext})
print(f"Item {item['id']} complete -> store product {ext}")
job = api("POST", "/publish-jobs", {"shop_id": SHOP_ID, "design_ids": [DESIGN_ID], "mode": "create", "price_mode": "blank"})
job_id = job["data"]["id"]
print(f"Created publish job {job_id}")
for _ in range(60):
pending = [i for i in api("GET", f"/shops/{SHOP_ID}/publish-jobs/pending")["data"] if i["job_id"] == job_id]
if not pending:
print(f"Job {job_id} has no pending items. Done.")
sys.exit(0)
for item in pending:
try:
process_item(item)
except Exception as exc: # noqa: BLE001
print(f"Item {item['id']} failed: {exc}", file=sys.stderr)
try:
api("POST", f"/publish-jobs/items/{item['id']}/fail", {"error": str(exc)[:500]})
except Exception: # noqa: BLE001
pass
time.sleep(5)
sys.exit(f"Gave up waiting on job {job_id}")
Verify a webhook
A minimal HTTP receiver: reads the raw body, checks X-CP-Signature with a constant-time compare and a 5-minute tolerance, answers 200 immediately and then handles the event. The bash tab is a test sender that signs a sample event and posts it to your receiver.
#!/usr/bin/env bash
# send-test-webhook.sh Signs a sample event and POSTs it to your receiver.
# Env: CP_WEBHOOK_SECRET. Optional: TARGET (default https://your-store.example/webhooks/ceeprinto)
set -euo pipefail
: "${CP_WEBHOOK_SECRET:?}"
TARGET="${TARGET:-https://your-store.example/webhooks/ceeprinto}"
now=$(date +%s)
sent_at=$(date -u +%Y-%m-%dT%H:%M:%S+00:00)
body='{"topic":"order.status_changed","data":{"wc_order_id":50231,"status":"processing","previous_status":"pending","external_order_id":"1001"},"sent_at":"'"$sent_at"'"}'
sig=$(printf '%s.%s' "$now" "$body" | openssl dgst -sha256 -hmac "$CP_WEBHOOK_SECRET" | awk '{print $NF}')
echo "Valid signature:"
curl -s -w " (HTTP %{http_code})\n" -X POST "$TARGET" \
-H "Content-Type: application/json" \
-H "X-CP-Topic: order.status_changed" \
-H "X-CP-Signature: t=$now,v1=$sig" \
--data-binary "$body"
echo "Tampered body (must be rejected):"
curl -s -w " (HTTP %{http_code})\n" -X POST "$TARGET" \
-H "Content-Type: application/json" \
-H "X-CP-Topic: order.status_changed" \
-H "X-CP-Signature: t=$now,v1=$sig" \
--data-binary "${body/processing/cancelled}"
<?php
// webhook.php Run: CP_WEBHOOK_SECRET=whsec_... php -S 0.0.0.0:8080 webhook.php
const TOLERANCE_SECONDS = 300;
function cp_verify(string $raw, string $header, string $secret): bool {
$parts = [];
foreach (explode(',', $header) as $pair) {
$kv = explode('=', trim($pair), 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $raw, $secret);
return hash_equals($expected, $parts['v1']);
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
$secret = getenv('CP_WEBHOOK_SECRET');
$raw = file_get_contents('php://input'); // raw bytes: never re-encode before verifying
$header = $_SERVER['HTTP_X_CP_SIGNATURE'] ?? '';
if (!$secret || !cp_verify($raw, $header, $secret)) {
http_response_code(400);
echo 'invalid signature';
exit;
}
$event = json_decode($raw, true);
http_response_code(200);
echo 'ok';
// Answer first, then work (php-fpm: fastcgi_finish_request()).
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
switch ($event['topic']) {
case 'order.shipped':
error_log("Order {$event['data']['wc_order_id']} shipped: {$event['data']['tracking_url']}");
break;
case 'order.status_changed':
error_log("Order {$event['data']['wc_order_id']} is now {$event['data']['status']}");
break;
case 'design.publish_requested':
error_log("Publish job {$event['data']['job_id']}: poll /shops/{$event['data']['shop_id']}/publish-jobs/pending now");
break;
default:
error_log('Received ' . $event['topic']);
}
// webhook.mjs Run: CP_WEBHOOK_SECRET=whsec_... node webhook.mjs
import http from 'node:http';
import crypto from 'node:crypto';
const SECRET = process.env.CP_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
if (!SECRET) { console.error('Missing env CP_WEBHOOK_SECRET'); process.exit(1); }
function verify(raw, header) {
const parts = {};
for (const pair of String(header || '').split(',')) {
const i = pair.indexOf('=');
if (i > 0) parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
}
if (!/^\d+$/.test(parts.t || '') || !/^[0-9a-f]{64}$/.test(parts.v1 || '')) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.`).update(raw).digest();
return crypto.timingSafeEqual(expected, Buffer.from(parts.v1, 'hex'));
}
function handle(event) {
switch (event.topic) {
case 'order.shipped':
console.log(`Order ${event.data.wc_order_id} shipped: ${event.data.tracking_url}`);
break;
case 'order.status_changed':
console.log(`Order ${event.data.wc_order_id} is now ${event.data.status}`);
break;
case 'design.publish_requested':
console.log(`Publish job ${event.data.job_id}: poll /shops/${event.data.shop_id}/publish-jobs/pending now`);
break;
default:
console.log(`Received ${event.topic}`);
}
}
http.createServer((req, res) => {
if (req.method !== 'POST') { res.writeHead(405).end(); return; }
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks); // raw bytes: never re-encode before verifying
if (!verify(raw, req.headers['x-cp-signature'])) {
res.writeHead(400).end('invalid signature');
return;
}
res.writeHead(200).end('ok'); // answer within 10 s, then work
setImmediate(() => {
try { handle(JSON.parse(raw.toString('utf8'))); } catch (err) { console.error(err); }
});
});
}).listen(8080, () => console.log('Listening on :8080'));
# webhook.py Run: CP_WEBHOOK_SECRET=whsec_... python webhook.py
import hashlib
import hmac
import json
import os
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SECRET = os.environ.get("CP_WEBHOOK_SECRET") or sys.exit("Missing env CP_WEBHOOK_SECRET")
TOLERANCE_SECONDS = 300
def verify(raw: bytes, header: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or not v1:
return False
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET.encode(), t.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
def handle(event: dict) -> None:
topic, data = event["topic"], event["data"]
if topic == "order.shipped":
print(f"Order {data['wc_order_id']} shipped: {data['tracking_url']}")
elif topic == "order.status_changed":
print(f"Order {data['wc_order_id']} is now {data['status']}")
elif topic == "design.publish_requested":
print(f"Publish job {data['job_id']}: poll /shops/{data['shop_id']}/publish-jobs/pending now")
else:
print(f"Received {topic}")
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) # raw bytes
if not verify(raw, self.headers.get("X-CP-Signature", "")):
self.send_response(400)
self.end_headers()
self.wfile.write(b"invalid signature")
return
self.send_response(200) # answer within 10 s, then work
self.end_headers()
self.wfile.write(b"ok")
threading.Thread(target=handle, args=(json.loads(raw),), daemon=True).start()
if __name__ == "__main__":
print("Listening on :8080")
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
Submit an order with idempotent retry
Sends one order with an Idempotency-Key derived from your shop and order number, retries timeouts, network errors, 429 and 5xx with the same key, stops on 4xx, recognises a replay, then follows the intake until it is created or failed. Pass your order number as the first argument.
#!/usr/bin/env bash
# submit-order.sh ORDER_NUMBER Requires curl, jq.
# Env: CP_BASE, CP_KEY, CP_SHOP_ID, CP_EXTERNAL_SHOP_ID
set -euo pipefail
: "${CP_BASE:?}" "${CP_KEY:?}" "${CP_SHOP_ID:?}" "${CP_EXTERNAL_SHOP_ID:?}"
order_no="${1:?usage: $0 ORDER_NUMBER}"
idem="$CP_EXTERNAL_SHOP_ID:$order_no" # same key on every retry
body=$(jq -nc --argjson shop "$CP_SHOP_ID" --arg no "$order_no" '{
shop_id: $shop, external_order_id: $no, currency: "PKR",
shipping_address: {first_name: "Bilal", last_name: "Ahmed", phone: "03001234567",
address_1: "Flat 4B, Block 5, Clifton", city: "Karachi", country: "PK"},
line_items: [{external_variant_id: "9001-1", quantity: 1, customer_price: "2500.00", customer_variant: "L / Black"}]
}')
out=$(mktemp); hdr=$(mktemp); trap 'rm -f "$out" "$hdr"' EXIT
delay=1
for attempt in 1 2 3 4 5; do
status=$(curl -s --max-time 20 -o "$out" -D "$hdr" -w "%{http_code}" -X POST "$CP_BASE/orders" \
-H "Authorization: Bearer $CP_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $idem" -d "$body") || status=000
case "$status" in
202) echo "Accepted: $(jq -c '.data' "$out")"; break ;;
200) echo "Already accepted earlier (replay): $(jq -c '.data' "$out")"; break ;;
000|429|5??)
wait=$(grep -i '^retry-after:' "$hdr" 2>/dev/null | tr -dc '0-9' || true)
echo "Attempt $attempt got $status; retrying with the same key" >&2
sleep "${wait:-$delay}"; delay=$((delay * 2)) ;;
*) echo "Rejected ($status): $(cat "$out")" >&2; exit 1 ;;
esac
[ "$attempt" -eq 5 ] && { echo "Gave up; safe to rerun later with the same order number" >&2; exit 1; }
done
intake_id=$(jq -r '.data.id' "$out")
for i in $(seq 1 30); do
status=$(curl -s -o "$out" -w "%{http_code}" "$CP_BASE/orders/$intake_id" -H "Authorization: Bearer $CP_KEY")
[ "$status" = "403" ] && { echo "Key lacks orders:read; check My Account -> Store Connections -> Order Activity"; exit 0; }
s=$(jq -r '.data.status' "$out")
case "$s" in
created) echo "WooCommerce order $(jq -r '.data.wc_order_id' "$out") created (Pending payment)"; exit 0 ;;
failed) echo "Intake failed: $(jq -r '.data.last_error' "$out")" >&2; exit 1 ;;
esac
sleep 2
done
echo "Still $s after 60 s; check again later" >&2
<?php
// submit-order.php ORDER_NUMBER
// Env: CP_BASE, CP_KEY, CP_SHOP_ID, CP_EXTERNAL_SHOP_ID
foreach (['CP_BASE', 'CP_KEY', 'CP_SHOP_ID', 'CP_EXTERNAL_SHOP_ID'] as $n) {
if (!getenv($n)) { fwrite(STDERR, "Missing env $n\n"); exit(1); }
}
$orderNo = $argv[1] ?? null;
if (!$orderNo) { fwrite(STDERR, "usage: php submit-order.php ORDER_NUMBER\n"); exit(1); }
$idempotencyKey = getenv('CP_EXTERNAL_SHOP_ID') . ':' . $orderNo; // same key on every retry
$order = [
'shop_id' => (int) getenv('CP_SHOP_ID'),
'external_order_id' => $orderNo,
'currency' => 'PKR',
'shipping_address' => [
'first_name' => 'Bilal', 'last_name' => 'Ahmed', 'phone' => '03001234567',
'address_1' => 'Flat 4B, Block 5, Clifton', 'city' => 'Karachi', 'country' => 'PK',
],
'line_items' => [
['external_variant_id' => '9001-1', 'quantity' => 1, 'customer_price' => '2500.00', 'customer_variant' => 'L / Black'],
],
];
function request(string $method, string $path, ?array $body = null, array $extra = []): array {
$headers = [];
$ch = curl_init(getenv('CP_BASE') . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => array_merge(['Authorization: Bearer ' . getenv('CP_KEY'), 'Content-Type: application/json'], $extra),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
$kv = explode(':', $line, 2);
if (count($kv) === 2) { $headers[strtolower(trim($kv[0]))] = trim($kv[1]); }
return strlen($line);
},
]);
if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); }
$raw = curl_exec($ch);
$status = $raw === false ? 0 : curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$status, $raw ? json_decode($raw, true) : null, $headers];
}
$delay = 1;
for ($attempt = 1; ; $attempt++) {
[$status, $json, $headers] = request('POST', '/orders', $order, ['Idempotency-Key: ' . $idempotencyKey]);
if ($status === 202 || $status === 200) {
$replay = !empty($json['meta']['idempotent_replay']);
echo ($replay ? 'Already accepted earlier (replay): ' : 'Accepted: ') . json_encode($json['data']) . "\n";
break;
}
$retryable = $status === 0 || $status === 429 || $status >= 500;
if (!$retryable) {
fwrite(STDERR, "Rejected ($status): " . json_encode($json['error'] ?? null) . "\n");
exit(1);
}
if ($attempt === 5) {
fwrite(STDERR, "Gave up; safe to rerun later with the same order number\n");
exit(1);
}
fwrite(STDERR, "Attempt $attempt got $status; retrying with the same key\n");
sleep(isset($headers['retry-after']) ? (int) $headers['retry-after'] : $delay);
$delay *= 2;
}
$intakeId = $json['data']['id'];
for ($i = 0; $i < 30; $i++) {
[$status, $json] = request('GET', "/orders/$intakeId");
if ($status === 403) { echo "Key lacks orders:read; check My Account -> Store Connections -> Order Activity\n"; exit(0); }
$s = $json['data']['status'] ?? 'unknown';
if ($s === 'created') { echo "WooCommerce order {$json['data']['wc_order_id']} created (Pending payment)\n"; exit(0); }
if ($s === 'failed') { fwrite(STDERR, "Intake failed: {$json['data']['last_error']}\n"); exit(1); }
sleep(2);
}
fwrite(STDERR, "Still $s after 60 s; check again later\n");
// submit-order.mjs ORDER_NUMBER
// Env: CP_BASE, CP_KEY, CP_SHOP_ID, CP_EXTERNAL_SHOP_ID
for (const n of ['CP_BASE', 'CP_KEY', 'CP_SHOP_ID', 'CP_EXTERNAL_SHOP_ID']) {
if (!process.env[n]) { console.error(`Missing env ${n}`); process.exit(1); }
}
const orderNo = process.argv[2];
if (!orderNo) { console.error('usage: node submit-order.mjs ORDER_NUMBER'); process.exit(1); }
const idempotencyKey = `${process.env.CP_EXTERNAL_SHOP_ID}:${orderNo}`; // same key on every retry
const order = {
shop_id: Number(process.env.CP_SHOP_ID),
external_order_id: orderNo,
currency: 'PKR',
shipping_address: {
first_name: 'Bilal', last_name: 'Ahmed', phone: '03001234567',
address_1: 'Flat 4B, Block 5, Clifton', city: 'Karachi', country: 'PK',
},
line_items: [
{ external_variant_id: '9001-1', quantity: 1, customer_price: '2500.00', customer_variant: 'L / Black' },
],
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function request(method, path, body, headers = {}) {
try {
const res = await fetch(`${process.env.CP_BASE}${path}`, {
method,
headers: { Authorization: `Bearer ${process.env.CP_KEY}`, 'Content-Type': 'application/json', ...headers },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(20000),
});
return { status: res.status, json: await res.json().catch(() => null), headers: res.headers };
} catch (err) {
return { status: 0, json: null, headers: new Headers(), err }; // timeout or network error
}
}
let result;
for (let attempt = 1, delay = 1000; ; attempt++, delay *= 2) {
result = await request('POST', '/orders', order, { 'Idempotency-Key': idempotencyKey });
const { status, json } = result;
if (status === 202 || status === 200) {
const replay = json.meta?.idempotent_replay === true;
console.log(`${replay ? 'Already accepted earlier (replay)' : 'Accepted'}: ${JSON.stringify(json.data)}`);
break;
}
if (!(status === 0 || status === 429 || status >= 500)) {
console.error(`Rejected (${status}): ${JSON.stringify(json?.error)}`);
process.exit(1);
}
if (attempt === 5) { console.error('Gave up; safe to rerun later with the same order number'); process.exit(1); }
console.error(`Attempt ${attempt} got ${status}; retrying with the same key`);
const retryAfter = Number(result.headers.get('retry-after'));
await sleep(retryAfter > 0 ? retryAfter * 1000 : delay);
}
const intakeId = result.json.data.id;
let state = 'unknown';
for (let i = 0; i < 30; i++) {
const { status, json } = await request('GET', `/orders/${intakeId}`);
if (status === 403) { console.log('Key lacks orders:read; check My Account -> Store Connections -> Order Activity'); process.exit(0); }
state = json?.data?.status;
if (state === 'created') { console.log(`WooCommerce order ${json.data.wc_order_id} created (Pending payment)`); process.exit(0); }
if (state === 'failed') { console.error(`Intake failed: ${json.data.last_error}`); process.exit(1); }
await sleep(2000);
}
console.error(`Still ${state} after 60 s; check again later`);
# submit_order.py ORDER_NUMBER
# Env: CP_BASE, CP_KEY, CP_SHOP_ID, CP_EXTERNAL_SHOP_ID
import os
import sys
import time
import requests
for n in ("CP_BASE", "CP_KEY", "CP_SHOP_ID", "CP_EXTERNAL_SHOP_ID"):
if not os.environ.get(n):
sys.exit(f"Missing env {n}")
if len(sys.argv) < 2:
sys.exit("usage: python submit_order.py ORDER_NUMBER")
order_no = sys.argv[1]
idempotency_key = f"{os.environ['CP_EXTERNAL_SHOP_ID']}:{order_no}" # same key on every retry
BASE = os.environ["CP_BASE"]
HEADERS = {"Authorization": f"Bearer {os.environ['CP_KEY']}"}
order = {
"shop_id": int(os.environ["CP_SHOP_ID"]),
"external_order_id": order_no,
"currency": "PKR",
"shipping_address": {
"first_name": "Bilal", "last_name": "Ahmed", "phone": "03001234567",
"address_1": "Flat 4B, Block 5, Clifton", "city": "Karachi", "country": "PK",
},
"line_items": [
{"external_variant_id": "9001-1", "quantity": 1, "customer_price": "2500.00", "customer_variant": "L / Black"},
],
}
delay = 1
for attempt in range(1, 6):
try:
res = requests.post(
f"{BASE}/orders",
headers={**HEADERS, "Idempotency-Key": idempotency_key},
json=order,
timeout=20,
)
status = res.status_code
except requests.RequestException:
res, status = None, 0 # timeout or network error
if status in (200, 202):
body = res.json()
replay = body.get("meta", {}).get("idempotent_replay", False)
print(("Already accepted earlier (replay): " if replay else "Accepted: ") + str(body["data"]))
break
if not (status == 0 or status == 429 or status >= 500):
sys.exit(f"Rejected ({status}): {res.json().get('error')}")
if attempt == 5:
sys.exit("Gave up; safe to rerun later with the same order number")
print(f"Attempt {attempt} got {status}; retrying with the same key", file=sys.stderr)
wait = int(res.headers.get("Retry-After", delay)) if res is not None else delay
time.sleep(wait)
delay *= 2
intake_id = body["data"]["id"]
state = "unknown"
for _ in range(30):
r = requests.get(f"{BASE}/orders/{intake_id}", headers=HEADERS, timeout=20)
if r.status_code == 403:
print("Key lacks orders:read; check My Account -> Store Connections -> Order Activity")
sys.exit(0)
data = r.json()["data"]
state = data["status"]
if state == "created":
print(f"WooCommerce order {data['wc_order_id']} created (Pending payment)")
sys.exit(0)
if state == "failed":
sys.exit(f"Intake failed: {data['last_error']}")
time.sleep(2)
print(f"Still {state} after 60 s; check again later", file=sys.stderr)
Sync catalog and stock
Walks GET /catalog/products 100 at a time by following the Link header's rel="next", fetches each variable blank for per-variation stock, honours 429 Retry-After, and writes catalog.json with one row per sellable variant. Subscribe to product.stock_changed afterwards to stay in sync without re-running it.
#!/usr/bin/env bash
# sync-catalog.sh Requires curl, jq. Env: CP_BASE, CP_KEY. Writes catalog.json
set -euo pipefail
: "${CP_BASE:?}" "${CP_KEY:?}"
hdr=$(mktemp); out=$(mktemp); rows=$(mktemp); trap 'rm -f "$hdr" "$out" "$rows"' EXIT
# get URL: fetch into $out/$hdr, retrying 429 after Retry-After
get() {
local url="$1" status wait
for attempt in 1 2 3 4 5; do
status=$(curl -s -o "$out" -D "$hdr" -w "%{http_code}" "$url" -H "Authorization: Bearer $CP_KEY")
[ "$status" = "200" ] && return 0
if [ "$status" = "429" ] || [ "$status" -ge 500 ]; then
wait=$(grep -i '^retry-after:' "$hdr" | tr -dc '0-9' || true)
sleep "${wait:-$attempt}"; continue
fi
echo "HTTP $status for $url: $(cat "$out")" >&2; return 1
done
return 1
}
url="$CP_BASE/catalog/products?per_page=100"
while [ -n "$url" ]; do
get "$url"
# Save the next-page URL now; the detail requests below overwrite $hdr.
next=$(grep -i '^link:' "$hdr" | tr ',' '\n' | grep 'rel="next"' | sed -E 's/.*<([^>]+)>.*/\1/' || true)
jq -c '.data[]' "$out" > "$rows.page"
while read -r p; do
id=$(jq -r '.id' <<<"$p")
if [ "$(jq -r '.has_variations' <<<"$p")" = "true" ]; then
get "$CP_BASE/catalog/products/$id"
jq -c '.data as $p | $p.variations[] | {product_id: $p.id, variation_id: .id, title: .title, sku, stock_status, stock_quantity, in_stock}' "$out" >> "$rows"
else
jq -c '{product_id: .id, variation_id: 0, title, sku, stock_status, stock_quantity, in_stock}' <<<"$p" >> "$rows"
fi
done < "$rows.page"
rm -f "$rows.page"
url="$next"
done
jq -s '.' "$rows" > catalog.json
echo "Wrote $(jq length catalog.json) variants to catalog.json"
<?php
// sync-catalog.php Env: CP_BASE, CP_KEY. Writes catalog.json
foreach (['CP_BASE', 'CP_KEY'] as $n) {
if (!getenv($n)) { fwrite(STDERR, "Missing env $n\n"); exit(1); }
}
function get_json(string $url): array {
for ($attempt = 1; $attempt <= 5; $attempt++) {
$headers = [];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('CP_KEY')],
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
$kv = explode(':', $line, 2);
if (count($kv) === 2) { $headers[strtolower(trim($kv[0]))] = trim($kv[1]); }
return strlen($line);
},
]);
$raw = curl_exec($ch);
$status = $raw === false ? 0 : curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 200) {
return [json_decode($raw, true), $headers];
}
if ($status === 0 || $status === 429 || $status >= 500) {
sleep(isset($headers['retry-after']) ? (int) $headers['retry-after'] : $attempt);
continue;
}
throw new RuntimeException("HTTP $status for $url: $raw");
}
throw new RuntimeException("Gave up on $url");
}
function next_link(?string $link): ?string {
foreach (explode(',', (string) $link) as $part) {
if (preg_match('/<([^>]+)>\s*;\s*rel="next"/', $part, $m)) {
return $m[1];
}
}
return null;
}
$rows = [];
$url = getenv('CP_BASE') . '/catalog/products?per_page=100';
while ($url) {
[$page, $headers] = get_json($url);
foreach ($page['data'] as $p) {
if ($p['has_variations']) {
[$detail] = get_json(getenv('CP_BASE') . '/catalog/products/' . $p['id']);
foreach ($detail['data']['variations'] as $v) {
$rows[] = ['product_id' => $p['id'], 'variation_id' => $v['id'], 'title' => $v['title'], 'sku' => $v['sku'],
'stock_status' => $v['stock_status'], 'stock_quantity' => $v['stock_quantity'], 'in_stock' => $v['in_stock']];
}
} else {
$rows[] = ['product_id' => $p['id'], 'variation_id' => 0, 'title' => $p['title'], 'sku' => $p['sku'],
'stock_status' => $p['stock_status'], 'stock_quantity' => $p['stock_quantity'], 'in_stock' => $p['in_stock']];
}
}
$url = next_link($headers['link'] ?? null);
}
file_put_contents('catalog.json', json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
echo 'Wrote ' . count($rows) . " variants to catalog.json\n";
// sync-catalog.mjs Env: CP_BASE, CP_KEY. Writes catalog.json
import { writeFile } from 'node:fs/promises';
for (const n of ['CP_BASE', 'CP_KEY']) {
if (!process.env[n]) { console.error(`Missing env ${n}`); process.exit(1); }
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getJson(url) {
for (let attempt = 1; attempt <= 5; attempt++) {
let res;
try {
res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.CP_KEY}` },
signal: AbortSignal.timeout(30000),
});
} catch {
await sleep(attempt * 1000);
continue;
}
if (res.ok) return { json: await res.json(), headers: res.headers };
if (res.status === 429 || res.status >= 500) {
await sleep((Number(res.headers.get('retry-after')) || attempt) * 1000);
continue;
}
throw new Error(`HTTP ${res.status} for ${url}: ${await res.text()}`);
}
throw new Error(`Gave up on ${url}`);
}
function nextLink(link) {
const m = /<([^>]+)>\s*;\s*rel="next"/.exec(link || '');
return m ? m[1] : null;
}
const rows = [];
let url = `${process.env.CP_BASE}/catalog/products?per_page=100`;
while (url) {
const { json: page, headers } = await getJson(url);
for (const p of page.data) {
if (p.has_variations) {
const { json: detail } = await getJson(`${process.env.CP_BASE}/catalog/products/${p.id}`);
for (const v of detail.data.variations) {
rows.push({ product_id: p.id, variation_id: v.id, title: v.title, sku: v.sku,
stock_status: v.stock_status, stock_quantity: v.stock_quantity, in_stock: v.in_stock });
}
} else {
rows.push({ product_id: p.id, variation_id: 0, title: p.title, sku: p.sku,
stock_status: p.stock_status, stock_quantity: p.stock_quantity, in_stock: p.in_stock });
}
}
url = nextLink(headers.get('link'));
}
await writeFile('catalog.json', JSON.stringify(rows, null, 2));
console.log(`Wrote ${rows.length} variants to catalog.json`);
# sync_catalog.py Env: CP_BASE, CP_KEY. Writes catalog.json
import json
import os
import sys
import time
import requests
for n in ("CP_BASE", "CP_KEY"):
if not os.environ.get(n):
sys.exit(f"Missing env {n}")
BASE = os.environ["CP_BASE"]
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['CP_KEY']}"
def get(url):
for attempt in range(1, 6):
try:
res = SESSION.get(url, timeout=30)
except requests.RequestException:
time.sleep(attempt)
continue
if res.ok:
return res
if res.status_code == 429 or res.status_code >= 500:
time.sleep(int(res.headers.get("Retry-After", attempt)))
continue
raise RuntimeError(f"HTTP {res.status_code} for {url}: {res.text}")
raise RuntimeError(f"Gave up on {url}")
rows = []
url = f"{BASE}/catalog/products?per_page=100"
while url:
res = get(url)
for p in res.json()["data"]:
if p["has_variations"]:
detail = get(f"{BASE}/catalog/products/{p['id']}").json()["data"]
for v in detail["variations"]:
rows.append({"product_id": p["id"], "variation_id": v["id"], "title": v["title"], "sku": v["sku"],
"stock_status": v["stock_status"], "stock_quantity": v["stock_quantity"],
"in_stock": v["in_stock"]})
else:
rows.append({"product_id": p["id"], "variation_id": 0, "title": p["title"], "sku": p["sku"],
"stock_status": p["stock_status"], "stock_quantity": p["stock_quantity"],
"in_stock": p["in_stock"]})
url = res.links.get("next", {}).get("url") # requests parses the Link header
with open("catalog.json", "w") as fh:
json.dump(rows, fh, indent=2)
print(f"Wrote {len(rows)} variants to catalog.json")
API ยท Reference
Endpoint reference
Every ceeprinto/v2 route with its scope, parameters, request, response and notes.
Every ceeprinto/v2 route with its scope, parameters, request body, full response and runnable examples.
Base URL https://ceeprinto.com/wp-json/ceeprinto/v2. Every request sends Authorization: Bearer $CP_KEY. Examples read CP_BASE and CP_KEY from the environment; Node examples use top-level await, so save them as .mjs (Node 18+). The machine-readable spec is openapi.yaml.
| Group | Routes | Scopes |
|---|---|---|
| Me | 1 | none |
| Catalog | 2 | products:read |
| Products | 5 | listings:read, listings:write |
| Designs | 4 | designs:read |
| Shops | 4 | listings:read, shops:write |
| Listings | 6 | listings:read, listings:write |
| Publish jobs | 5 | listings:write, listings:read |
| Orders | 4 | orders:read, orders:write |
| Payouts | 2 | orders:read |
| Shipping | 1 | orders:read |
| Webhooks | 3 | webhooks:write |
Me
The account and API key behind the credential.
GET
/me
The account behind the key.
{
"data": {
"id": 214,
"email": "[email protected]",
"name": "Ayesha Khan",
"first_name": "Ayesha",
"last_name": "Khan",
"company": "Threadline Karachi",
"api_key": {
"key_id": "9f3a1c7eK2pQ8mZx4LbN6tRw",
"name": "WooCommerce Connect",
"is_legacy": false,
"last_used_at": "2026-09-29 10:14:52"
},
"scopes": [
"designs:read",
"products:read",
"listings:read",
"listings:write",
"orders:write",
"shops:write",
"webhooks:write"
],
"fulfillment_profile": {
"complete": true,
"missing": [],
"fields": {
"billing_phone": true,
"billing_address_1": true,
"billing_city": true,
"billing_country": true,
"billing_company": true,
"billing_address_2": false,
"billing_state": true,
"billing_postcode": true,
"first_name": true,
"last_name": true
}
}
}
}
curl -s "$CP_BASE/me" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/me');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/me`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/me",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Any valid key can call it; no scope is required.
- Store
data.idas the merchant id. It is the numeric WordPress user id and never changes. api_key.key_idis the public 24-character id (the part betweencp_live_and the dot). For a legacy key it is masked except for the last 4 characters.fulfillment_profile.completeisfalseuntil billing phone, address line 1, city and country are set in My Account. Orders still intake, but fulfilment needs them.- Error statuses: 401, 404, 429. See Errors.
Catalog
Blank WooCommerce products that designs are printed on, with stock.
GET
/catalog/products
Blank products designs are built on.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
search |
query | string | No | Free-text search on product title. |
page |
query | integer | No | 1-based page number. |
per_page |
query | integer | No | Items per page. Values below 1 fall back to 20; values above 100 are clamped to 100. |
{
"data": [
{
"id": 37,
"title": "Classic Crew Tee",
"type": "variable",
"has_variations": true,
"sku": "CP-TEE",
"price": "1200",
"image_url": "https://ceeprinto.com/wp-content/uploads/2026/05/classic-tee.jpg",
"stock_status": "instock",
"stock_quantity": null,
"in_stock": true
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 1,
"total_pages": 1
}
}
curl -s "$CP_BASE/catalog/products?search=tee&per_page=50" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/catalog/products?search=tee&per_page=50');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/catalog/products?search=tee&per_page=50`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/catalog/products",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
params={
"search": "tee",
"per_page": 50,
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Published WooCommerce products ordered by title. These are blanks, not the merchant's own products.
- Paginated: read
meta.total_pagesor follow theLinkheader. stock_quantityisnullwhen WooCommerce is not managing stock; usein_stock.- Error statuses: 401, 403, 429. See Errors.
GET
/catalog/products/{id}
One blank with attributes, variations, stock and print config.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Catalog (blank) product id. |
{
"data": {
"id": 37,
"title": "Classic Crew Tee",
"type": "variable",
"has_variations": true,
"sku": "CP-TEE",
"price": "1200",
"image_url": "https://ceeprinto.com/wp-content/uploads/2026/05/classic-tee.jpg",
"stock_status": "instock",
"stock_quantity": null,
"in_stock": true,
"description": "180 GSM combed cotton crew neck.",
"attributes": [
{
"name": "Size",
"slug": "pa_size",
"options": [
"S",
"M",
"L"
]
},
{
"name": "Color",
"slug": "pa_color",
"options": [
"Black",
"White",
"Navy",
"Maroon"
]
}
],
"variations": [
{
"id": 101,
"title": "Classic Crew Tee - L, Black",
"attributes": {
"attribute_pa_size": "l",
"attribute_pa_color": "black"
},
"sku": "CP-TEE-L-BLK",
"price": "1200",
"stock_status": "instock",
"stock_quantity": 40,
"in_stock": true
}
],
"print_config": {
"stages": [
{
"id": 1,
"index": 0,
"name": "Front"
},
{
"id": 2,
"index": 1,
"name": "Back"
}
]
}
}
}
curl -s "$CP_BASE/catalog/products/37" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/catalog/products/37');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/catalog/products/37`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/catalog/products/37",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
print_config.stages[].indexmatchesstage_indexin the mockup matrix. Index0is the front.- Subscribe to
product.stock_changedto keep stock in sync instead of polling this. - Error statuses: 401, 403, 404, 429. See Errors.
Products
Hub products: a blank plus a default design and per-variant overrides, publishable to any connected store.
GET
/products
The merchant's hub products.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
page |
query | integer | No | 1-based page number. |
per_page |
query | integer | No | Items per page. Values below 1 fall back to 20; values above 100 are clamped to 100. |
{
"data": [
{
"id": 42,
"user_id": 214,
"blank_product_id": 37,
"title": "Karachi Skyline Tee",
"description": "",
"price_mode": "blank",
"flat_price": null,
"default_design_id": 11,
"status": "active",
"created_at": "2026-09-20T09:12:00+00:00",
"updated_at": "2026-09-28T16:40:05+00:00",
"variants": [
{
"id": 501,
"product_id": 42,
"variation_id": 101,
"design_id": null,
"price": null,
"sku": null,
"enabled": true,
"effective_design_id": 11,
"inherits_default": true
},
{
"id": 502,
"product_id": 42,
"variation_id": 102,
"design_id": 12,
"price": 2800,
"sku": "TEE-BLK-L",
"enabled": true,
"effective_design_id": 12,
"inherits_default": false
}
]
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 1,
"total_pages": 1
}
}
curl -s "$CP_BASE/products" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/products');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/products`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/products",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Hub products (a blank plus a default design and per-variant overrides), not the blank catalog. Newest first.
per_pageis honoured since 2.2.0.- Error statuses: 401, 403, 429. See Errors.
POST
/products
Create a hub product from a blank.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
blank_product_id |
body | integer | Yes | |
title |
body | string | No | Defaults to the blank's name. |
description |
body | string | No | Product description. |
price_mode |
body | string | No | One of blank, flat. Default blank. |
flat_price |
body | number, nullable | No | |
default_design_id |
body | integer | No | |
status |
body | string | No | Status label. One of draft, active, archived. Default draft. |
{
"blank_product_id": 37,
"title": "Karachi Skyline Tee",
"default_design_id": 11,
"price_mode": "flat",
"flat_price": 2500
}
{
"data": {
"id": 42,
"user_id": 214,
"blank_product_id": 37,
"title": "Karachi Skyline Tee",
"description": "",
"price_mode": "flat",
"flat_price": 2500,
"default_design_id": 11,
"status": "draft",
"created_at": "2026-09-29T10:15:00+00:00",
"updated_at": "2026-09-29T10:15:00+00:00",
"variants": [
{
"id": 501,
"product_id": 42,
"variation_id": 101,
"design_id": null,
"price": null,
"sku": null,
"enabled": true,
"effective_design_id": 11,
"inherits_default": true
},
{
"id": 502,
"product_id": 42,
"variation_id": 102,
"design_id": null,
"price": null,
"sku": null,
"enabled": true,
"effective_design_id": 11,
"inherits_default": true
}
]
}
}
curl -s -X POST "$CP_BASE/products" \
-H "Authorization: Bearer $CP_KEY" \
-H "Content-Type: application/json" \
-d '{"blank_product_id":37,"title":"Karachi Skyline Tee","default_design_id":11,"price_mode":"flat","flat_price":2500}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/products');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'blank_product_id' => 37,
'title' => 'Karachi Skyline Tee',
'default_design_id' => 11,
'price_mode' => 'flat',
'flat_price' => 2500,
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/products`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
blank_product_id: 37,
title: 'Karachi Skyline Tee',
default_design_id: 11,
price_mode: 'flat',
flat_price: 2500,
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.post(
f"{os.environ['CP_BASE']}/products",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
json={
"blank_product_id": 37,
"title": "Karachi Skyline Tee",
"default_design_id": 11,
"price_mode": "flat",
"flat_price": 2500,
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Seeds one variant per blank variation with
design_id: null(inherit the default). A simple blank gets one variant withvariation_id: 0. - An unknown or missing
blank_product_idis 400invalid_request, not 422. - Error statuses: 400, 401, 403, 429, 500. See Errors.
GET
/products/{id}
One hub product's full composition.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Hub product id. |
{
"data": {
"id": 42,
"user_id": 214,
"blank_product_id": 37,
"title": "Karachi Skyline Tee",
"description": "",
"price_mode": "blank",
"flat_price": null,
"default_design_id": 11,
"status": "active",
"created_at": "2026-09-20T09:12:00+00:00",
"updated_at": "2026-09-28T16:40:05+00:00",
"variants": [
{
"id": 501,
"product_id": 42,
"variation_id": 101,
"design_id": null,
"price": null,
"sku": null,
"enabled": true,
"effective_design_id": 11,
"inherits_default": true
},
{
"id": 502,
"product_id": 42,
"variation_id": 102,
"design_id": 12,
"price": 2800,
"sku": "TEE-BLK-L",
"enabled": true,
"effective_design_id": 12,
"inherits_default": false
}
]
}
}
curl -s "$CP_BASE/products/42" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/products/42');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/products/42`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/products/42",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Pending publish items carry only
product_id_hub; fetch the per-variant designs here. - Use
effective_design_id, notdesign_id:design_id: nullmeans the variant inheritsdefault_design_id. - Error statuses: 401, 403, 404, 429. See Errors.
PATCH
/products/{id}
Update product-level fields.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Hub product id. |
title |
body | string | No | Product title. |
description |
body | string | No | Product description. |
price_mode |
body | string | No | One of blank, flat. |
flat_price |
body | number, nullable | No | |
default_design_id |
body | integer | No | |
status |
body | string | No | Status label. One of draft, active, archived. |
{
"default_design_id": 12,
"status": "active"
}
{
"data": {
"id": 42,
"user_id": 214,
"blank_product_id": 37,
"title": "Karachi Skyline Tee",
"description": "",
"price_mode": "blank",
"flat_price": null,
"default_design_id": 12,
"status": "active",
"created_at": "2026-09-20T09:12:00+00:00",
"updated_at": "2026-09-29T10:15:00+00:00",
"variants": [
{
"id": 501,
"product_id": 42,
"variation_id": 101,
"design_id": null,
"price": null,
"sku": null,
"enabled": true,
"effective_design_id": 12,
"inherits_default": true
}
]
}
}
curl -s -X PATCH "$CP_BASE/products/42" \
-H "Authorization: Bearer $CP_KEY" \
-H "Content-Type: application/json" \
-d '{"default_design_id":12,"status":"active"}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/products/42');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'default_design_id' => 12,
'status' => 'active',
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/products/42`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
default_design_id: 12,
status: 'active',
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.patch(
f"{os.environ['CP_BASE']}/products/42",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
json={
"default_design_id": 12,
"status": "active",
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Changing
default_design_idre-flows to every variant with no override of its own. - If the product is already published, the change is projected onto its listings in every store and
product.updatedfires. - Error statuses: 401, 403, 404, 429. See Errors.
PATCH
/products/{id}/variants/{variation_id}
Override (or restore) one variant's design, price, sku or enabled flag.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Hub product id. |
variation_id |
path | integer | Yes | Blank variation id (0 for a simple blank). |
design_id |
body | integer | No | 0 = inherit the product default. |
price |
body | number, nullable | No | |
sku |
body | string | No | |
enabled |
body | boolean | No |
{
"design_id": 12,
"price": 2800,
"sku": "TEE-BLK-L"
}
{
"data": {
"id": 502,
"product_id": 42,
"variation_id": 102,
"design_id": 12,
"price": 2800,
"sku": "TEE-BLK-L",
"enabled": true,
"effective_design_id": 12,
"inherits_default": false
}
}
curl -s -X PATCH "$CP_BASE/products/42/variants/102" \
-H "Authorization: Bearer $CP_KEY" \
-H "Content-Type: application/json" \
-d '{"design_id":12,"price":2800,"sku":"TEE-BLK-L"}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/products/42/variants/102');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'design_id' => 12,
'price' => 2800,
'sku' => 'TEE-BLK-L',
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/products/42/variants/102`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
design_id: 12,
price: 2800,
sku: 'TEE-BLK-L',
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.patch(
f"{os.environ['CP_BASE']}/products/42/variants/102",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
json={
"design_id": 12,
"price": 2800,
"sku": "TEE-BLK-L",
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Send
design_id: 0to clear an override and inherit the product default again. - Projects onto published stores and fires
product.updated, likePATCH /products/{id}. - Error statuses: 401, 403, 404, 429. See Errors.
Designs
The merchant's saved designs and their per-variation, per-side mockups.
GET
/designs
The merchant's designs.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
status |
query | string | No | Filter by design status, e.g. saved. |
product_id |
query | integer | No | Filter by blank product id. |
page |
query | integer | No | 1-based page number. |
per_page |
query | integer | No | Items per page. Values below 1 fall back to 20; values above 100 are clamped to 100. |
{
"data": [
{
"id": 11,
"name": "Karachi Skyline",
"status": "saved",
"product_id": 37,
"variation_id": 101,
"preview_url": "https://ceeprinto.com/wp-content/uploads/ceeprinto-designs/previews/design-11.png",
"total_price": 1200,
"order_id": null,
"created_at": "2026-09-18T11:02:44+00:00",
"updated_at": "2026-09-18T11:05:10+00:00"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 1,
"total_pages": 1
}
}
curl -s "$CP_BASE/designs?product_id=37&per_page=20" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/designs?product_id=37&per_page=20');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/designs?product_id=37&per_page=20`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/designs",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
params={
"product_id": 37,
"per_page": 20,
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
product_idis the blank the design was made on.- Error statuses: 401, 403, 429. See Errors.
GET
/designs/{id}
One design owned by the key's account.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Design id. |
{
"data": {
"id": 11,
"name": "Karachi Skyline",
"status": "saved",
"product_id": 37,
"variation_id": 101,
"preview_url": "https://ceeprinto.com/wp-content/uploads/ceeprinto-designs/previews/design-11.png",
"total_price": 1200,
"order_id": null,
"created_at": "2026-09-18T11:02:44+00:00",
"updated_at": "2026-09-18T11:05:10+00:00",
"hires_png_url": "https://ceeprinto.com/wp-content/uploads/ceeprinto-designs/hires/design-11.png",
"pdf_url": null,
"config_id": 6,
"base_product": {
"id": 37,
"title": "Classic Crew Tee",
"sku": "CP-TEE",
"price": "1200"
},
"design_data": {
"stages": [
{
"index": 0,
"name": "Front",
"objects": []
}
]
}
}
}
curl -s "$CP_BASE/designs/11" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/designs/11');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/designs/11`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/designs/11",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Another account's design is 404, never 403.
hires_png_urlis the print file;preview_urlis a small preview.- Error statuses: 401, 403, 404, 429. See Errors.
GET
/designs/{id}/variation-mockups
Per-variation, per-side mockup matrix and its render status.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Design id. |
ensure_store |
query | integer | No | Accepted for compatibility. Mockups now render on request, so this changes nothing. |
batch |
query | integer | No | Echoed back as batch (clamped 1 to 25). Nothing is queued. |
{
"data": {
"status": "ready",
"total": 24,
"ready": 24,
"pending": 0,
"failed": 0,
"store_status": "ready",
"store_ready": 24,
"store_failed": 0,
"store_pending": 0,
"batch": 5,
"queued": 0,
"items": [
{
"variation_id": 101,
"stage_index": 0,
"stage_id": 1,
"stage_name": "Front",
"ready": true,
"store_ready": true,
"failed": false,
"error": null,
"thumb_url": "https://ceeprinto.com/wp-json/ceeprinto/v1/mockup/11/3fa9c2e1b7d4.jpg",
"image_url": "https://ceeprinto.com/wp-json/ceeprinto/v1/mockup/11/8be0d41c92aa.jpg"
},
{
"variation_id": 101,
"stage_index": 1,
"stage_id": 2,
"stage_name": "Back",
"ready": true,
"store_ready": true,
"failed": false,
"error": null,
"thumb_url": "https://ceeprinto.com/wp-json/ceeprinto/v1/mockup/11/c71f03d5e2b9.jpg",
"image_url": "https://ceeprinto.com/wp-json/ceeprinto/v1/mockup/11/0d4e6a91f3c7.jpg"
}
]
}
}
curl -s "$CP_BASE/designs/11/variation-mockups" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/designs/11/variation-mockups');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/designs/11/variation-mockups`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/designs/11/variation-mockups",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- One cell per blank variation per stage: key cells by
variation_id+stage_index.stage_index0 is the front. - Cells render on request at the returned URLs; nothing is queued, so
queuedis always0. - Wait for
store_status: "ready"(or"partial", skipping cells withfailed: true) before uploading. - Upload
image_url(2048px) to stores. Never uploadthumb_url(240px). - If the design configurator is inactive this answers 200 with
status: "unavailable"and emptyitems. - Error statuses: 401, 403, 404, 429. See Errors.
POST
/designs/{id}/variation-mockups
Compatibility route: returns the mockup matrix with 202. Nothing is queued; cells render on request.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Design id. |
ensure_store |
query | integer | No | Accepted for compatibility. Mockups now render on request, so this changes nothing. |
batch |
query | integer | No | Echoed back as batch (clamped 1 to 25). Nothing is queued. |
{
"data": {
"status": "ready",
"total": 24,
"ready": 24,
"pending": 0,
"failed": 0,
"store_status": "ready",
"store_ready": 24,
"store_failed": 0,
"store_pending": 0,
"batch": 5,
"queued": 0,
"items": [
{
"variation_id": 101,
"stage_index": 0,
"stage_id": 1,
"stage_name": "Front",
"ready": true,
"store_ready": true,
"failed": false,
"error": null,
"thumb_url": "https://ceeprinto.com/wp-json/ceeprinto/v1/mockup/11/3fa9c2e1b7d4.jpg",
"image_url": "https://ceeprinto.com/wp-json/ceeprinto/v1/mockup/11/8be0d41c92aa.jpg"
},
{
"variation_id": 101,
"stage_index": 1,
"stage_id": 2,
"stage_name": "Back",
"ready": true,
"store_ready": true,
"failed": false,
"error": null,
"thumb_url": "https://ceeprinto.com/wp-json/ceeprinto/v1/mockup/11/c71f03d5e2b9.jpg",
"image_url": "https://ceeprinto.com/wp-json/ceeprinto/v1/mockup/11/0d4e6a91f3c7.jpg"
}
]
}
}
curl -s -X POST "$CP_BASE/designs/11/variation-mockups" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/designs/11/variation-mockups');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/designs/11/variation-mockups`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.post(
f"{os.environ['CP_BASE']}/designs/11/variation-mockups",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Kept for compatibility. Returns 202 with the same envelope as the GET and
queued: 0, because cells render on request. - Returns 500
server_errorwhen the mockup renderer (design configurator) is not active. - You never need to call this before a GET.
- Error statuses: 401, 403, 404, 429, 500. See Errors.
Shops
Connected storefronts (Shopify, WooCommerce, custom).
GET
/shops
Connected storefronts.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
page |
query | integer | No | 1-based page number. |
per_page |
query | integer | No | Items per page. Values below 1 fall back to 20; values above 100 are clamped to 100. |
{
"data": [
{
"id": 5,
"channel": "custom",
"external_shop_id": "my-store-01",
"name": "My Store",
"webhook_url": null,
"status": "active",
"connected_at": "2026-09-15T07:30:00+00:00"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 1,
"total_pages": 1
}
}
curl -s "$CP_BASE/shops" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/shops');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/shops`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/shops",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Includes disconnected shops (
status: "disconnected"). - Error statuses: 401, 403, 429. See Errors.
POST
/shops
Register or refresh a storefront (idempotent).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
channel |
body | string | Yes | Free-form label, 1 to 32 characters: custom, woocommerce, shopify. Max length 32. |
external_shop_id |
body | string | Yes | Your own stable id for the store. Upsert key together with channel. |
name |
body | string | No | Display name shown in My Account. |
{
"channel": "custom",
"external_shop_id": "my-store-01",
"name": "My Store"
}
{
"data": {
"id": 5,
"channel": "custom",
"external_shop_id": "my-store-01",
"name": "My Store",
"webhook_url": null,
"status": "active",
"connected_at": "2026-09-29T10:15:00+00:00"
}
}
curl -s -X POST "$CP_BASE/shops" \
-H "Authorization: Bearer $CP_KEY" \
-H "Content-Type: application/json" \
-d '{"channel":"custom","external_shop_id":"my-store-01","name":"My Store"}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/shops');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'channel' => 'custom',
'external_shop_id' => 'my-store-01',
'name' => 'My Store',
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/shops`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
channel: 'custom',
external_shop_id: 'my-store-01',
name: 'My Store',
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.post(
f"{os.environ['CP_BASE']}/shops",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
json={
"channel": "custom",
"external_shop_id": "my-store-01",
"name": "My Store",
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Upserts on (
channel,external_shop_id): re-posting refreshes the row and reactivates a disconnected shop. Always 201. - 409
conflictwhen another CeePrinto account already owns that store. - Store the returned
data.id; every other shop route uses it. - Error statuses: 400, 401, 403, 409, 429. See Errors.
GET
/shops/{id}
One connected storefront.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Shop id (from POST /shops). |
{
"data": {
"id": 5,
"channel": "custom",
"external_shop_id": "my-store-01",
"name": "My Store",
"webhook_url": null,
"status": "active",
"connected_at": "2026-09-15T07:30:00+00:00"
}
}
curl -s "$CP_BASE/shops/5" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/shops/5');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/shops/5`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/shops/5",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Another account's shop is 404.
- Error statuses: 401, 403, 404, 429. See Errors.
DELETE
/shops/{id}
Disconnect a storefront (soft).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Shop id (from POST /shops). |
{
"data": {
"id": 5,
"channel": "custom",
"external_shop_id": "my-store-01",
"name": "My Store",
"webhook_url": null,
"status": "disconnected",
"connected_at": "2026-09-15T07:30:00+00:00"
}
}
curl -s -X DELETE "$CP_BASE/shops/5" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/shops/5');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/shops/5`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.delete(
f"{os.environ['CP_BASE']}/shops/5",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Soft delete: answers 200 with the shop and
status: "disconnected"(not 204). - Listings and order history are kept.
POST /shopswith the sameexternal_shop_idreconnects. - Error statuses: 401, 403, 404, 429. See Errors.
Listings
Links between a design and a store product/variant, used to resolve incoming orders.
GET
/shops/{id}/listings
Listings on one shop.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Shop id. |
page |
query | integer | No | 1-based page number. |
per_page |
query | integer | No | Items per page. Values below 1 fall back to 20; values above 100 are clamped to 100. |
{
"data": [
{
"id": 88,
"shop_id": 5,
"design_id": 11,
"product_id": 37,
"variation_id": 101,
"external_product_id": "9001",
"external_variant_id": "9001-1",
"external_sku": "TEE-L",
"product_id_hub": 42,
"status": "active",
"created_at": "2026-09-20T09:30:00+00:00",
"updated_at": "2026-09-20T09:30:00+00:00"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 1,
"total_pages": 1
}
}
curl -s "$CP_BASE/shops/5/listings" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/shops/5/listings');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/shops/5/listings`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/shops/5/listings",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Listings are how an incoming order line is resolved back to a design.
- Error statuses: 401, 403, 404, 429. See Errors.
POST
/shops/{id}/listings
Link a design to an external product/variant.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Shop id (from POST /shops). |
design_id |
body | integer | No | Design id. |
product_id |
body | integer | No | Catalog (blank) product id. |
variation_id |
body | integer | No | Blank variation id. |
external_product_id |
body | string | No | Your store's product id. |
external_variant_id |
body | string | No | Your store's variant id. Listings upsert on this value. |
external_sku |
body | string | No | Your store's SKU. |
status |
body | string | No | Status label. |
{
"design_id": 11,
"product_id": 37,
"variation_id": 101,
"external_product_id": "9001",
"external_variant_id": "9001-1",
"external_sku": "TEE-L"
}
{
"data": {
"id": 88,
"shop_id": 5,
"design_id": 11,
"product_id": 37,
"variation_id": 101,
"external_product_id": "9001",
"external_variant_id": "9001-1",
"external_sku": "TEE-L",
"product_id_hub": null,
"status": "active",
"created_at": "2026-09-29T10:15:00+00:00",
"updated_at": "2026-09-29T10:15:00+00:00"
}
}
curl -s -X POST "$CP_BASE/shops/5/listings" \
-H "Authorization: Bearer $CP_KEY" \
-H "Content-Type: application/json" \
-d '{"design_id":11,"product_id":37,"variation_id":101,"external_product_id":"9001","external_variant_id":"9001-1","external_sku":"TEE-L"}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/shops/5/listings');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'design_id' => 11,
'product_id' => 37,
'variation_id' => 101,
'external_product_id' => '9001',
'external_variant_id' => '9001-1',
'external_sku' => 'TEE-L',
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/shops/5/listings`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
design_id: 11,
product_id: 37,
variation_id: 101,
external_product_id: '9001',
external_variant_id: '9001-1',
external_sku: 'TEE-L',
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.post(
f"{os.environ['CP_BASE']}/shops/5/listings",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
json={
"design_id": 11,
"product_id": 37,
"variation_id": 101,
"external_product_id": "9001",
"external_variant_id": "9001-1",
"external_sku": "TEE-L",
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Needs at least one of
external_variant_id,external_product_id,external_sku, and one ofdesign_id,product_id(else 400). - Upserts on
external_variant_idonly. Re-posting a row without one creates a new listing every time. - Error statuses: 400, 401, 403, 404, 429, 500. See Errors.
DELETE
/shops/{id}/listings
Delete every listing for one external (store) product.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Shop id (from POST /shops). |
external_product_id |
query | string | Yes | The store product whose listings should all be removed. |
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "$CP_BASE/shops/5/listings?external_product_id=9001" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/shops/5/listings?external_product_id=9001');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 204) {
$json = json_decode($raw, true);
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
echo "Deleted\n";
const res = await fetch(`${process.env.CP_BASE}/shops/5/listings?external_product_id=9001`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
if (res.status !== 204) {
const json = await res.json();
throw new Error(`${json.error.code}: ${json.error.message}`);
}
console.log('Deleted');
import os
import requests
res = requests.delete(
f"{os.environ['CP_BASE']}/shops/5/listings",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
params={
"external_product_id": "9001",
},
timeout=30,
)
if res.status_code != 204:
err = res.json()["error"]
raise RuntimeError(f"{err['code']}: {err['message']}")
print("Deleted")
Notes
- Call this when a store product is deleted or unlinked, so orders cannot resolve to it.
- Missing
external_product_idis 400. - Error statuses: 400, 401, 403, 404, 429. See Errors.
POST
/shops/{id}/listings/bulk
Create or update many listings (one per variant).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Shop id (from POST /shops). |
listings |
body | array of object | Yes | |
listings[].design_id |
body | integer | No | Design id. |
listings[].product_id |
body | integer | No | Catalog (blank) product id. |
listings[].variation_id |
body | integer | No | Blank variation id. |
listings[].external_product_id |
body | string | No | Your store's product id. |
listings[].external_variant_id |
body | string | No | Your store's variant id. Listings upsert on this value. |
listings[].external_sku |
body | string | No | Your store's SKU. |
listings[].status |
body | string | No | Status label. |
{
"listings": [
{
"design_id": 11,
"product_id": 37,
"variation_id": 101,
"external_product_id": "9001",
"external_variant_id": "9001-1"
},
{
"design_id": 12,
"product_id": 37,
"variation_id": 102,
"external_product_id": "9001",
"external_variant_id": "9001-2"
}
]
}
{
"data": [
{
"id": 88,
"shop_id": 5,
"design_id": 11,
"product_id": 37,
"variation_id": 101,
"external_product_id": "9001",
"external_variant_id": "9001-1",
"external_sku": null,
"product_id_hub": null,
"status": "active",
"created_at": "2026-09-29T10:15:00+00:00",
"updated_at": "2026-09-29T10:15:00+00:00"
},
{
"id": 89,
"shop_id": 5,
"design_id": 12,
"product_id": 37,
"variation_id": 102,
"external_product_id": "9001",
"external_variant_id": "9001-2",
"external_sku": null,
"product_id_hub": null,
"status": "active",
"created_at": "2026-09-29T10:15:00+00:00",
"updated_at": "2026-09-29T10:15:00+00:00"
}
],
"meta": {
"created": 2,
"failed": 0,
"errors": []
}
}
curl -s -X POST "$CP_BASE/shops/5/listings/bulk" \
-H "Authorization: Bearer $CP_KEY" \
-H "Content-Type: application/json" \
-d '{"listings":[{"design_id":11,"product_id":37,"variation_id":101,"external_product_id":"9001","external_variant_id":"9001-1"},{"design_id":12,"product_id":37,"variation_id":102,"external_product_id":"9001","external_variant_id":"9001-2"}]}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/shops/5/listings/bulk');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'listings' => [
[
'design_id' => 11,
'product_id' => 37,
'variation_id' => 101,
'external_product_id' => '9001',
'external_variant_id' => '9001-1',
],
[
'design_id' => 12,
'product_id' => 37,
'variation_id' => 102,
'external_product_id' => '9001',
'external_variant_id' => '9001-2',
],
],
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/shops/5/listings/bulk`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
listings: [
{
design_id: 11,
product_id: 37,
variation_id: 101,
external_product_id: '9001',
external_variant_id: '9001-1',
},
{
design_id: 12,
product_id: 37,
variation_id: 102,
external_product_id: '9001',
external_variant_id: '9001-2',
},
],
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.post(
f"{os.environ['CP_BASE']}/shops/5/listings/bulk",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
json={
"listings": [
{
"design_id": 11,
"product_id": 37,
"variation_id": 101,
"external_product_id": "9001",
"external_variant_id": "9001-1",
},
{
"design_id": 12,
"product_id": 37,
"variation_id": 102,
"external_product_id": "9001",
"external_variant_id": "9001-2",
},
],
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Rows are independent: answers 200 even if some fail. Check
meta.failedandmeta.errors[].index. - One row per store variant, each with its own
design_id. - Error statuses: 400, 401, 403, 404, 429. See Errors.
PATCH
/shops/{id}/listings/{listing_id}
Override the design on one listing cell.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Shop id (from POST /shops). |
listing_id |
path | integer | Yes | Listing id. |
design_id |
body | integer | Yes | The design to print for this listing cell. |
{
"design_id": 12
}
{
"data": {
"id": 89,
"shop_id": 5,
"design_id": 12,
"product_id": 37,
"variation_id": 102,
"external_product_id": "9001",
"external_variant_id": "9001-2",
"external_sku": null,
"product_id_hub": 42,
"status": "active",
"created_at": "2026-09-20T09:30:00+00:00",
"updated_at": "2026-09-29T10:15:00+00:00"
}
}
curl -s -X PATCH "$CP_BASE/shops/5/listings/89" \
-H "Authorization: Bearer $CP_KEY" \
-H "Content-Type: application/json" \
-d '{"design_id":12}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/shops/5/listings/89');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'design_id' => 12,
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/shops/5/listings/89`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
design_id: 12,
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.patch(
f"{os.environ['CP_BASE']}/shops/5/listings/89",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
json={
"design_id": 12,
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Changes one cell only and notifies nothing. If you own a hub product, use
PATCH /products/{id}/variants/{variation_id}instead so every store is updated andproduct.updatedfires. - A
design_idyou do not own is 404. - Error statuses: 400, 401, 403, 404, 429. See Errors.
DELETE
/shops/{id}/listings/{listing_id}
Delete one listing.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Shop id (from POST /shops). |
listing_id |
path | integer | Yes | Listing id. |
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "$CP_BASE/shops/5/listings/89" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/shops/5/listings/89');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 204) {
$json = json_decode($raw, true);
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
echo "Deleted\n";
const res = await fetch(`${process.env.CP_BASE}/shops/5/listings/89`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
if (res.status !== 204) {
const json = await res.json();
throw new Error(`${json.error.code}: ${json.error.message}`);
}
console.log('Deleted');
import os
import requests
res = requests.delete(
f"{os.environ['CP_BASE']}/shops/5/listings/89",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
if res.status_code != 204:
err = res.json()["error"]
raise RuntimeError(f"{err['code']}: {err['message']}")
print("Deleted")
Notes
- Answers 204 with an empty body.
- Error statuses: 401, 403, 404, 429. See Errors.
Publish jobs
Hub-driven publishing. Connectors poll pending items and close them out.
POST
/publish-jobs
Create a hub publish job (one item per design).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
shop_id |
body | integer | Yes | Shop id from POST /shops. |
design_ids |
body | array of integer | No | Designs to publish, one item each. Required unless product_id is sent. |
product_id |
body | integer | No | A hub product id, instead of design_ids. Designs and variants[] come from its composition. |
mode |
body | string | No | What the connector should do in the store. One of create, link, update. Default create. |
external_product_id |
body | string | No | Required when mode is "link" or "update". |
price_mode |
body | string | No | blank uses the blank's price; flat uses flat_price. One of blank, flat. Default blank. |
flat_price |
body | number, nullable | No | Used only when price_mode is flat. |
{
"shop_id": 5,
"design_ids": [
11
],
"price_mode": "blank",
"mode": "create"
}
{
"data": {
"id": 3,
"user_id": 214,
"shop_id": 5,
"status": "pending",
"mode": "create",
"price_mode": "blank",
"flat_price": null,
"created_at": "2026-09-29T10:15:00+00:00",
"updated_at": "2026-09-29T10:15:00+00:00",
"items": [
{
"id": 7,
"job_id": 3,
"design_id": 11,
"product_id": 37,
"variant_count": 12,
"status": "pending",
"mode": "create",
"external_product_id": null,
"product_id_hub": null,
"error_text": null,
"created_at": "2026-09-29T10:15:00+00:00",
"updated_at": "2026-09-29T10:15:00+00:00",
"completed_at": null
}
]
}
}
curl -s -X POST "$CP_BASE/publish-jobs" \
-H "Authorization: Bearer $CP_KEY" \
-H "Content-Type: application/json" \
-d '{"shop_id":5,"design_ids":[11],"price_mode":"blank","mode":"create"}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/publish-jobs');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'shop_id' => 5,
'design_ids' => [11],
'price_mode' => 'blank',
'mode' => 'create',
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/publish-jobs`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
shop_id: 5,
design_ids: [11],
price_mode: 'blank',
mode: 'create',
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.post(
f"{os.environ['CP_BASE']}/publish-jobs",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
json={
"shop_id": 5,
"design_ids": [11],
"price_mode": "blank",
"mode": "create",
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Send
design_idsorproduct_id(a hub product). Firesdesign.publish_requested. - Status order: unknown
mode422; shop not yours 404; product not yours 404; nothing to publish 400;link/updatewithoutexternal_product_id422; every design already published 400. - In
createmode, designs already listed on the shop are skipped. - The webhook is best-effort. Connectors must poll
GET /shops/{id}/publish-jobs/pending. - Error statuses: 400, 401, 403, 404, 422, 429, 500. See Errors.
GET
/publish-jobs/{id}
One publish job with its items.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Publish job id. |
{
"data": {
"id": 3,
"user_id": 214,
"shop_id": 5,
"status": "completed",
"mode": "create",
"price_mode": "blank",
"flat_price": null,
"created_at": "2026-09-29T10:15:00+00:00",
"updated_at": "2026-09-29T10:18:40+00:00",
"items": [
{
"id": 7,
"job_id": 3,
"design_id": 11,
"product_id": 37,
"variant_count": 12,
"status": "completed",
"mode": "create",
"external_product_id": "9001",
"product_id_hub": null,
"error_text": null,
"created_at": "2026-09-29T10:15:00+00:00",
"updated_at": "2026-09-29T10:18:40+00:00",
"completed_at": "2026-09-29T10:18:40+00:00"
}
]
}
}
curl -s "$CP_BASE/publish-jobs/3" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/publish-jobs/3');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/publish-jobs/3`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/publish-jobs/3",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Job
statusfollows its items:pending,processing,completed,failed. - Error statuses: 401, 403, 404, 429. See Errors.
GET
/shops/{id}/publish-jobs/pending
Publish items the hub is still waiting on for this store.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Shop id (from POST /shops). |
{
"data": [
{
"id": 7,
"job_id": 3,
"design_id": 11,
"product_id": 37,
"variant_count": 12,
"status": "pending",
"mode": "create",
"external_product_id": null,
"product_id_hub": 42,
"error_text": null,
"created_at": "2026-09-29T10:15:00+00:00",
"updated_at": "2026-09-29T10:15:00+00:00",
"completed_at": null,
"shop_id": 5,
"price_mode": "blank",
"flat_price": null
}
]
}
curl -s "$CP_BASE/shops/5/publish-jobs/pending" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/shops/5/publish-jobs/pending');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/shops/5/publish-jobs/pending`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/shops/5/publish-jobs/pending",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Not paginated:
datais a bare array and there is nometa. - Connectors must poll this. Items carry
mode,external_product_id,shop_id,price_modeandflat_price, so a polled item is handled exactly like a webhook one. - For hub-product items (
product_id_hubset), read the per-variant designs fromGET /products/{id}. - Close every item with
completeorfail, or it stays pending forever. - Error statuses: 401, 403, 404, 429. See Errors.
POST
/publish-jobs/items/{item_id}/complete
Mark a publish item done.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
item_id |
path | integer | Yes | Publish job item id. |
external_product_id |
body | string | No | The store product the item landed on. |
{
"external_product_id": "9001"
}
{
"data": {
"id": 7,
"job_id": 3,
"design_id": 11,
"product_id": 37,
"variant_count": 12,
"status": "completed",
"mode": "create",
"external_product_id": "9001",
"product_id_hub": 42,
"error_text": null,
"created_at": "2026-09-29T10:15:00+00:00",
"updated_at": "2026-09-29T10:18:40+00:00",
"completed_at": "2026-09-29T10:18:40+00:00"
}
}
curl -s -X POST "$CP_BASE/publish-jobs/items/7/complete" \
-H "Authorization: Bearer $CP_KEY" \
-H "Content-Type: application/json" \
-d '{"external_product_id":"9001"}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/publish-jobs/items/7/complete');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'external_product_id' => '9001',
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/publish-jobs/items/7/complete`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
external_product_id: '9001',
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.post(
f"{os.environ['CP_BASE']}/publish-jobs/items/7/complete",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
json={
"external_product_id": "9001",
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Send the store product id the item landed on.
- For hub-product items the matching listings get
product_id_hubback-filled. - Error statuses: 401, 403, 404, 429. See Errors.
POST
/publish-jobs/items/{item_id}/fail
Mark a publish item failed.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
item_id |
path | integer | Yes | Publish job item id. |
error |
body | string | No | Shown to the merchant. Defaults to "Publish failed." |
{
"error": "variant matrix rejected"
}
{
"data": {
"id": 7,
"job_id": 3,
"design_id": 11,
"product_id": 37,
"variant_count": 12,
"status": "failed",
"mode": "create",
"external_product_id": null,
"product_id_hub": null,
"error_text": "variant matrix rejected",
"created_at": "2026-09-29T10:15:00+00:00",
"updated_at": "2026-09-29T10:17:02+00:00",
"completed_at": null
}
}
curl -s -X POST "$CP_BASE/publish-jobs/items/7/fail" \
-H "Authorization: Bearer $CP_KEY" \
-H "Content-Type: application/json" \
-d '{"error":"variant matrix rejected"}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/publish-jobs/items/7/fail');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'error' => 'variant matrix rejected',
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/publish-jobs/items/7/fail`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
error: 'variant matrix rejected',
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.post(
f"{os.environ['CP_BASE']}/publish-jobs/items/7/fail",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
json={
"error": "variant matrix rejected",
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
erroris shown to the merchant in My Account. Defaults toPublish failed.- Error statuses: 401, 403, 404, 429. See Errors.
Orders
Order intake (idempotent, queued) and the resulting WooCommerce order with COD fields.
GET
/orders
The merchant's order intakes.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
page |
query | integer | No | 1-based page number. |
per_page |
query | integer | No | Items per page. Values below 1 fall back to 20; values above 100 are clamped to 100. |
{
"data": [
{
"id": 1,
"shop_id": 5,
"external_order_id": "1001",
"status": "created",
"wc_order_id": 50231,
"attempts": 1,
"last_error": null,
"received_at": "2026-09-29T10:15:00+00:00",
"processed_at": "2026-09-29T10:15:04+00:00"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 1,
"total_pages": 1
}
}
curl -s "$CP_BASE/orders?per_page=20" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/orders?per_page=20');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/orders?per_page=20`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/orders",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
params={
"per_page": 20,
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Requires
orders:read. Connection-code keys ("Shopify App", "WooCommerce Connect") get 403. - Rows are intakes;
wc_order_idis set once the WooCommerce order exists. - Error statuses: 401, 403, 429. See Errors.
POST
/orders
Submit an order (intake).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Idempotency-Key |
header | string | Yes | Required on POST /orders. Any unique string per order (e.g. <external_shop_id>:<external_order_id>). Replaying a key returns the original intake with 200 and meta.idempotent_replay: true. |
shop_id |
body | integer | Yes | A shop you own. An unknown shop is 400, not 404. |
external_order_id |
body | string | No | Your store's order number. Echoed in webhooks. |
currency |
body | string | No | Default PKR. |
shipping_address |
body | object | Yes | Buyer address. first_name, address_1 and city are required; country is ISO 3166-1 alpha-2. |
line_items |
body | array of object | Yes | At least one line item. |
line_items[].listing_id |
body | integer | No | Preferred reference: resolves directly. |
line_items[].design_id |
body | integer | No | Fallback reference: a design you own. |
line_items[].external_variant_id |
body | string | No | Resolved through your listings. |
line_items[].external_sku |
body | string | No | Resolved through your listings. |
line_items[].quantity |
body | integer | Yes | At least 1. |
line_items[].customer_price |
body | string | Yes | Required. What the buyer pays; drives COD collection. Never defaulted. See the note on quantity. |
line_items[].customer_price_currency |
body | string | No | Defaults to the order currency. |
line_items[].customer_name |
body | string | No | Line name shown on the order. Defaults to the product name. |
line_items[].customer_variant |
body | string | No | Variant label shown to the merchant, e.g. L / Black. |
{
"shop_id": 5,
"external_order_id": "1001",
"currency": "PKR",
"shipping_address": {
"first_name": "Bilal",
"last_name": "Ahmed",
"phone": "03001234567",
"email": "[email protected]",
"address_1": "Flat 4B, Block 5, Clifton",
"city": "Karachi",
"state": "Sindh",
"postcode": "75600",
"country": "PK"
},
"line_items": [
{
"external_variant_id": "9001-1",
"quantity": 1,
"customer_price": "2500.00",
"customer_price_currency": "PKR",
"customer_variant": "L / Black"
}
]
}
{
"data": {
"id": 1,
"shop_id": 5,
"external_order_id": "1001",
"status": "received",
"wc_order_id": null,
"attempts": 0,
"last_error": null,
"received_at": "2026-09-29T10:15:00+00:00",
"processed_at": null
}
}
curl -s -X POST "$CP_BASE/orders" \
-H "Authorization: Bearer $CP_KEY" \
-H "Idempotency-Key: my-store-01:1001" \
-H "Content-Type: application/json" \
-d '{"shop_id":5,"external_order_id":"1001","currency":"PKR","shipping_address":{"first_name":"Bilal","last_name":"Ahmed","phone":"03001234567","email":"[email protected]","address_1":"Flat 4B, Block 5, Clifton","city":"Karachi","state":"Sindh","postcode":"75600","country":"PK"},"line_items":[{"external_variant_id":"9001-1","quantity":1,"customer_price":"2500.00","customer_price_currency":"PKR","customer_variant":"L / Black"}]}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/orders');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Idempotency-Key: my-store-01:1001',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'shop_id' => 5,
'external_order_id' => '1001',
'currency' => 'PKR',
'shipping_address' => [
'first_name' => 'Bilal',
'last_name' => 'Ahmed',
'phone' => '03001234567',
'email' => '[email protected]',
'address_1' => 'Flat 4B, Block 5, Clifton',
'city' => 'Karachi',
'state' => 'Sindh',
'postcode' => '75600',
'country' => 'PK',
],
'line_items' => [
[
'external_variant_id' => '9001-1',
'quantity' => 1,
'customer_price' => '2500.00',
'customer_price_currency' => 'PKR',
'customer_variant' => 'L / Black',
],
],
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/orders`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Idempotency-Key': 'my-store-01:1001',
'Content-Type': 'application/json',
},
body: JSON.stringify({
shop_id: 5,
external_order_id: '1001',
currency: 'PKR',
shipping_address: {
first_name: 'Bilal',
last_name: 'Ahmed',
phone: '03001234567',
email: '[email protected]',
address_1: 'Flat 4B, Block 5, Clifton',
city: 'Karachi',
state: 'Sindh',
postcode: '75600',
country: 'PK',
},
line_items: [
{
external_variant_id: '9001-1',
quantity: 1,
customer_price: '2500.00',
customer_price_currency: 'PKR',
customer_variant: 'L / Black',
},
],
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.post(
f"{os.environ['CP_BASE']}/orders",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}", "Idempotency-Key": "my-store-01:1001"},
json={
"shop_id": 5,
"external_order_id": "1001",
"currency": "PKR",
"shipping_address": {
"first_name": "Bilal",
"last_name": "Ahmed",
"phone": "03001234567",
"email": "[email protected]",
"address_1": "Flat 4B, Block 5, Clifton",
"city": "Karachi",
"state": "Sindh",
"postcode": "75600",
"country": "PK",
},
"line_items": [
{
"external_variant_id": "9001-1",
"quantity": 1,
"customer_price": "2500.00",
"customer_price_currency": "PKR",
"customer_variant": "L / Black",
},
],
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
Idempotency-Keyheader is required (400 without). Use<external_shop_id>:<external_order_id>.- New key: 202 with
status: "received". Same key again: 200 with the original intake andmeta.idempotent_replay: true; nothing new is created. - Line items resolve in this order:
listing_id, thenexternal_variant_id, thenexternal_sku(through your listings), thendesign_id. customer_priceis required on every line (422 withdetails.issues[]if missing). The hub sumscustomer_priceacross lines and does not multiply it byquantity; checkcod_collection_totalonGET /orders/{id}.- The order is created as Pending payment in the merchant's My Account โ Orders, where they elect COD From Customer.
- Error statuses: 400, 401, 403, 422, 429. See Errors.
GET
/orders/{id}
One intake plus its WooCommerce order, COD fields and tracking.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Intake id returned by POST /orders. |
{
"data": {
"id": 1,
"shop_id": 5,
"external_order_id": "1001",
"status": "created",
"wc_order_id": 50231,
"attempts": 1,
"last_error": null,
"received_at": "2026-09-29T10:15:00+00:00",
"processed_at": "2026-09-29T10:15:04+00:00",
"order": {
"wc_order_id": 50231,
"status": "completed",
"total": "1400.00",
"currency": "PKR",
"cod_from_customer": true,
"additional_cod_from_customer": 200,
"customer_price_total": 2500,
"cod_collection_total": 2700,
"tracking": {
"number": "KHI1234567",
"company": "M&P Courier",
"url": "https://mulphilog.com/tracking/KHI1234567"
},
"items": [
{
"name": "Karachi Skyline Tee - L",
"quantity": 1,
"line_total": 1200,
"customer_price": "2500.00",
"customer_variant": "L / Black",
"design_id": 11
}
]
}
}
}
curl -s "$CP_BASE/orders/1" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/orders/1');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/orders/1`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/orders/1",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
orderis present only once the WooCommerce order exists (status: "created").trackingisnulluntil a courier booking exists. Use it to reconcile a missedorder.shipped.cod_collection_total=customer_price_total+additional_cod_from_customer.- Error statuses: 401, 403, 404, 429. See Errors.
GET
/orders/{id}/events
Intake event history.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Intake id returned by POST /orders. |
{
"data": [
{
"event": "received",
"at": "2026-09-29T10:15:00+00:00"
},
{
"event": "processing",
"attempts": 1
},
{
"event": "created",
"wc_order_id": 50231,
"at": "2026-09-29T10:15:04+00:00"
}
]
}
curl -s "$CP_BASE/orders/1/events" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/orders/1/events');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/orders/1/events`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/orders/1/events",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Oldest first, not paginated. A
failedevent carriesreason. - The
processingevent hasattemptsand noat. - Error statuses: 401, 403, 404, 429. See Errors.
Payouts
Read-only COD payout ledger (money CeePrinto owes the brand).
GET
/payouts
The merchant's COD payout ledger.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
status |
query | string | No | all (default), pending (everything not yet paid out), or one exact bucket. Unrecognised values fall back to all. |
date_from |
query | string (date) | No | YYYY-MM-DD, on order creation date. |
date_to |
query | string (date) | No | YYYY-MM-DD, inclusive. |
page |
query | integer | No | 1-based page number. |
per_page |
query | integer | No | Items per page. Values below 1 fall back to 20; values above 100 are clamped to 100. |
{
"data": [
{
"wc_order_id": 50231,
"number": "50231",
"date_created": "2026-09-29T10:15:04+00:00",
"order_status": "completed",
"currency": "PKR",
"amount": 2700,
"payout_status": "in_hold",
"payout_status_label": "7-day hold",
"release_at": "2026-10-08T08:02:11+00:00",
"days_until_ready": 7,
"processed_at": null,
"tracking": {
"number": "KHI1234567",
"company": "M&P Courier",
"url": "https://mulphilog.com/tracking/KHI1234567"
},
"needs_payment": false,
"payment_url": null,
"items": [
{
"name": "Karachi Skyline Tee - L",
"quantity": 1,
"customer_price": "2500.00"
}
]
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 1,
"total_pages": 1
}
}
curl -s "$CP_BASE/payouts?status=pending&per_page=50" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/payouts?status=pending&per_page=50');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/payouts?status=pending&per_page=50`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/payouts",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
params={
"status": "pending",
"per_page": 50,
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Requires
orders:read: connection-code keys get 403. - Display
payout_status_label; do not derive it yourself. The hold is 7 days after courier-confirmed delivery (release_at). payment_urlis non-null only whenneeds_paymentis true. Show a "Pay now" action only then.- Returns 500 when the CeePrinto theme is not active on the hub.
- Error statuses: 401, 403, 429, 500. See Errors.
GET
/payouts/summary
Payout totals for the merchant.
{
"data": {
"total": 184500,
"processed": 120000,
"pending": 64500,
"ready": 22000,
"in_hold": 30500,
"awaiting_delivery": 12000,
"missing_bank": 0,
"count_total": 41,
"count_processed": 27,
"count_pending": 14,
"count_ready": 5,
"count_in_hold": 6,
"count_awaiting_delivery": 3,
"count_missing_bank": 0,
"currency": "PKR"
},
"meta": {
"cached": false
}
}
curl -s "$CP_BASE/payouts/summary" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/payouts/summary');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/payouts/summary`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/payouts/summary",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Cached for 15 minutes per user;
meta.cachedsays which you got. Always the whole ledger (no filters). - Requires
orders:read. - Error statuses: 401, 403, 429. See Errors.
Shipping
Shipping rate quotes (PKR).
POST
/shipping/quote
Shipping rate for a destination city (PKR).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
city |
body | string | No | Destination city. Required unless shipping_address.city is sent. |
shipping_address |
body | object | No | Alternative to city; only city is read. |
{
"city": "Karachi"
}
{
"data": {
"city": "Karachi",
"total": 200,
"currency": "PKR",
"method_id": "flat_rate",
"method_title": "Flat Rate"
}
}
curl -s -X POST "$CP_BASE/shipping/quote" \
-H "Authorization: Bearer $CP_KEY" \
-H "Content-Type: application/json" \
-d '{"city":"Karachi"}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/shipping/quote');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'city' => 'Karachi',
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/shipping/quote`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
city: 'Karachi',
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.post(
f"{os.environ['CP_BASE']}/shipping/quote",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
json={
"city": "Karachi",
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Requires
orders:read: connection-code keys get 403. - Missing city is 400. Amounts are PKR.
- Error statuses: 400, 401, 403, 429. See Errors.
Webhooks
Signed outbound event subscriptions.
GET
/webhooks
The account's webhook subscriptions.
{
"data": [
{
"id": 9,
"topic": "order.status_changed",
"target_url": "https://my-store-01.pk/webhooks/ceeprinto",
"secret": "whsec_Xk2p9QvT4mLr7sWn1bYc8dHf3gJa6eZu",
"status": "active",
"created_at": "2026-09-15T07:31:00+00:00"
}
]
}
curl -s "$CP_BASE/webhooks" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/webhooks');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/webhooks`, {
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.get(
f"{os.environ['CP_BASE']}/webhooks",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- Not paginated: bare array, no
meta. - Each item includes its signing
secret. Treat the response as sensitive. - Error statuses: 401, 403, 429. See Errors.
POST
/webhooks
Subscribe a URL to one topic.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
topic |
body | string | Yes | The event to subscribe to. One of order.status_changed, order.shipped, design.updated, design.publish_requested, product.stock_changed, product.updated. |
target_url |
body | string (URL) | Yes | HTTPS endpoint that receives signed POSTs. |
{
"topic": "order.status_changed",
"target_url": "https://my-store-01.pk/webhooks/ceeprinto"
}
{
"data": {
"id": 9,
"topic": "order.status_changed",
"target_url": "https://my-store-01.pk/webhooks/ceeprinto",
"secret": "whsec_Xk2p9QvT4mLr7sWn1bYc8dHf3gJa6eZu",
"status": "active",
"created_at": "2026-09-29T10:15:00+00:00"
}
}
curl -s -X POST "$CP_BASE/webhooks" \
-H "Authorization: Bearer $CP_KEY" \
-H "Content-Type: application/json" \
-d '{"topic":"order.status_changed","target_url":"https://my-store-01.pk/webhooks/ceeprinto"}'
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/webhooks');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'topic' => 'order.status_changed',
'target_url' => 'https://my-store-01.pk/webhooks/ceeprinto',
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$json = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
print_r($json['data']);
const res = await fetch(`${process.env.CP_BASE}/webhooks`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
topic: 'order.status_changed',
target_url: 'https://my-store-01.pk/webhooks/ceeprinto',
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data);
import os
import requests
res = requests.post(
f"{os.environ['CP_BASE']}/webhooks",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
json={
"topic": "order.status_changed",
"target_url": "https://my-store-01.pk/webhooks/ceeprinto",
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"])
Notes
- One topic per subscription. Unknown topic is 400 with
details.valid_topics; bad URL is 400. - Save
secret(whsec_โฆ) to verifyX-CP-Signature. - Error statuses: 400, 401, 403, 429. See Errors.
DELETE
/webhooks/{id}
Remove a subscription.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | integer | Yes | Webhook subscription id. |
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "$CP_BASE/webhooks/9" \
-H "Authorization: Bearer $CP_KEY"
<?php
$base = getenv('CP_BASE');
$key = getenv('CP_KEY');
$ch = curl_init($base . '/webhooks/9');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 204) {
$json = json_decode($raw, true);
throw new RuntimeException($json['error']['code'] . ': ' . $json['error']['message']);
}
echo "Deleted\n";
const res = await fetch(`${process.env.CP_BASE}/webhooks/9`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.CP_KEY}`,
},
});
if (res.status !== 204) {
const json = await res.json();
throw new Error(`${json.error.code}: ${json.error.message}`);
}
console.log('Deleted');
import os
import requests
res = requests.delete(
f"{os.environ['CP_BASE']}/webhooks/9",
headers={"Authorization": f"Bearer {os.environ['CP_KEY']}"},
timeout=30,
)
if res.status_code != 204:
err = res.json()["error"]
raise RuntimeError(f"{err['code']}: {err['message']}")
print("Deleted")
Notes
- Answers 204. Pending retries for this subscription stop.
- Error statuses: 401, 403, 404, 429. See Errors.
API ยท Reference
Webhook events reference
Every webhook topic with when it fires and a full example payload.
Each webhook topic below lists when it fires, every payload field, a full example body and a real signed delivery you can test your verifier against.
Envelope and headers (all topics)
| Part | Value |
|---|---|
| Method | POST to your target_url |
Content-Type |
application/json |
X-CP-Topic |
The topic, e.g. order.shipped |
X-CP-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> |
| Body | {"topic": string, "data": object, "sent_at": RFC 3339 UTC string}, compact JSON with escaped slashes (https:\/\/) |
All signatures on this page were computed with the example secret whsec_Xk2p9QvT4mLr7sWn1bYc8dHf3gJa6eZu over the exact raw body shown. Feed a raw body and its header to your verifier (with the timestamp tolerance disabled) and it must accept it; change one byte and it must reject it.
order.status_changed
A WooCommerce order placed through the API changes status (for example pending โ processing). Sent to the merchant who owns the order. When the same change also fires order.shipped, a second, reduced order.status_changed is sent carrying only wc_order_id and status.
Field (in data) |
Type | Present | Description |
|---|---|---|---|
wc_order_id |
integer | Always | WooCommerce order id (wc_order_id on the intake) |
status |
string | Always | New WooCommerce status without the wc- prefix |
previous_status |
string | Full event only | Status before the change |
external_order_id |
string | Full event only | The external_order_id you sent; empty string if none |
{
"topic": "order.status_changed",
"data": {
"wc_order_id": 50231,
"status": "processing",
"previous_status": "pending",
"external_order_id": "1001"
},
"sent_at": "2026-09-29T10:15:00+00:00"
}
POST /webhooks/ceeprinto HTTP/1.1
Content-Type: application/json
X-CP-Topic: order.status_changed
X-CP-Signature: t=1790676900,v1=d5dd3d9012f146631ada820fcc0c3b433f7d726bdf037cba4c64ae2cda33fca8
{"topic":"order.status_changed","data":{"wc_order_id":50231,"status":"processing","previous_status":"pending","external_order_id":"1001"},"sent_at":"2026-09-29T10:15:00+00:00"}
The reduced event sent alongside order.shipped:
POST /webhooks/ceeprinto HTTP/1.1
Content-Type: application/json
X-CP-Topic: order.status_changed
X-CP-Signature: t=1790841731,v1=8c31ab481cce00bfbbf0b35f56335296cbee66697e0b8cf4d438200fc7a97d4d
{"topic":"order.status_changed","data":{"wc_order_id":50231,"status":"completed"},"sent_at":"2026-10-01T08:02:11+00:00"}
order.shipped
An API order transitions to completed and has a live (non-cancelled) courier booking. Carries the tracking details, which are also on GET /orders/{id} โ order.tracking.
Field (in data) |
Type | Present | Description |
|---|---|---|---|
wc_order_id |
integer | Always | WooCommerce order id |
status |
string | Always | Always completed |
previous_status |
string | Always | Status before completion |
external_order_id |
string | Always | Your order number |
tracking_number |
string | Always | Courier consignment number |
tracking_company |
string | Always | Courier name, e.g. M&P Courier |
tracking_url |
string | Always | Public tracking page |
{
"topic": "order.shipped",
"data": {
"wc_order_id": 50231,
"status": "completed",
"previous_status": "processing",
"external_order_id": "1001",
"tracking_number": "KHI1234567",
"tracking_company": "M&P Courier",
"tracking_url": "https://mulphilog.com/tracking/KHI1234567"
},
"sent_at": "2026-10-01T08:02:11+00:00"
}
POST /webhooks/ceeprinto HTTP/1.1
Content-Type: application/json
X-CP-Topic: order.shipped
X-CP-Signature: t=1790841731,v1=cee1b4edfd917ad0906dee86730f60489237461410d49cfd4db21356843ef201
{"topic":"order.shipped","data":{"wc_order_id":50231,"status":"completed","previous_status":"processing","external_order_id":"1001","tracking_number":"KHI1234567","tracking_company":"M&P Courier","tracking_url":"https:\/\/mulphilog.com\/tracking\/KHI1234567"},"sent_at":"2026-10-01T08:02:11+00:00"}
design.updated
A saved design owned by the subscriber changes.
Field (in data) |
Type | Present | Description |
|---|---|---|---|
design_id |
integer | Always | The design that changed |
status |
string | Always | Design status, e.g. saved |
{
"topic": "design.updated",
"data": {
"design_id": 11,
"status": "saved"
},
"sent_at": "2026-09-29T10:15:00+00:00"
}
POST /webhooks/ceeprinto HTTP/1.1
Content-Type: application/json
X-CP-Topic: design.updated
X-CP-Signature: t=1790676900,v1=7b4759b08da6b57e7de5dfe5726b8a7e58a122618511d85a0ff47798b6251541
{"topic":"design.updated","data":{"design_id":11,"status":"saved"},"sent_at":"2026-09-29T10:15:00+00:00"}
design.publish_requested
A publish job is created (API, Products tab or Get Started tab). IDs only: fetch design, blank and hub product detail yourself, then POST /shops/{id}/listings/bulk. Jobs from the Get Started tab omit mode, product_id_hub, variants and per-item mode / external_product_id: treat them as create. Best-effort: poll pending items as well.
Field (in data) |
Type | Present | Description |
|---|---|---|---|
job_id |
integer | Always | Publish job id |
shop_id |
integer | Always | Target shop |
mode |
string | Not from Get Started | create, link or update |
product_id_hub |
integer or null | Not from Get Started | Hub product id when the job publishes a hub product |
items[].item_id |
integer | Always | Close this with complete / fail |
items[].design_id |
integer | Always | Design to publish |
items[].product_id |
integer or null | Always | Blank product id |
items[].variant_count |
integer | Always | Expected number of store variants |
items[].mode |
string | Not from Get Started | Per-item mode |
items[].external_product_id |
string or null | Not from Get Started | Store product for link / update |
variants[] |
array | Not from Get Started | Per-variant composition; empty unless the job came from a hub product |
variants[].variation_id |
integer | Blank variation | |
variants[].design_id |
integer or null | Effective design for that variation | |
variants[].price |
number or null | Variant price override | |
variants[].sku |
string or null | Variant SKU override | |
variants[].enabled |
boolean | Only enabled variants are included | |
price_mode |
string | Always | blank or flat |
flat_price |
number or null | Always | Set when price_mode is flat |
{
"topic": "design.publish_requested",
"data": {
"job_id": 3,
"shop_id": 5,
"mode": "create",
"product_id_hub": 42,
"items": [
{
"item_id": 7,
"design_id": 11,
"product_id": 37,
"variant_count": 12,
"mode": "create",
"external_product_id": null
}
],
"variants": [
{
"variation_id": 101,
"design_id": 11,
"price": null,
"sku": null,
"enabled": true
},
{
"variation_id": 102,
"design_id": 12,
"price": 2800,
"sku": "TEE-BLK-L",
"enabled": true
}
],
"price_mode": "blank",
"flat_price": null
},
"sent_at": "2026-09-29T10:15:00+00:00"
}
POST /webhooks/ceeprinto HTTP/1.1
Content-Type: application/json
X-CP-Topic: design.publish_requested
X-CP-Signature: t=1790676900,v1=ecac396f02a7139327ea77d1c162cabd11a69ae6416ae0492a40f299f71b1d9e
{"topic":"design.publish_requested","data":{"job_id":3,"shop_id":5,"mode":"create","product_id_hub":42,"items":[{"item_id":7,"design_id":11,"product_id":37,"variant_count":12,"mode":"create","external_product_id":null}],"variants":[{"variation_id":101,"design_id":11,"price":null,"sku":null,"enabled":true},{"variation_id":102,"design_id":12,"price":2800,"sku":"TEE-BLK-L","enabled":true}],"price_mode":"blank","flat_price":null},"sent_at":"2026-09-29T10:15:00+00:00"}
product.stock_changed
A blank product or variation changes stock status or quantity (quantity-only changes included). Sent only to merchants who have a listing referencing that product_id / variation_id.
Field (in data) |
Type | Present | Description |
|---|---|---|---|
product_id |
integer | Always | Blank (parent) product id |
variation_id |
integer or null | Always | Variation id; null for a simple product |
stock_status |
string | Always | instock, outofstock or onbackorder |
stock_quantity |
integer or null | Always | null when WooCommerce does not manage stock |
in_stock |
boolean | Always | Use this to toggle availability |
{
"topic": "product.stock_changed",
"data": {
"product_id": 37,
"variation_id": 101,
"stock_status": "outofstock",
"stock_quantity": 0,
"in_stock": false
},
"sent_at": "2026-09-29T10:15:00+00:00"
}
POST /webhooks/ceeprinto HTTP/1.1
Content-Type: application/json
X-CP-Topic: product.stock_changed
X-CP-Signature: t=1790676900,v1=3def3e51f29dd957977a630b4434fa06f557c406bebd180e580edec58eeae3e8
{"topic":"product.stock_changed","data":{"product_id":37,"variation_id":101,"stock_status":"outofstock","stock_quantity":0,"in_stock":false},"sent_at":"2026-09-29T10:15:00+00:00"}
product.updated
A hub product's default design or a variant override changes after it was published (PATCH /products/{id}, PATCH /products/{id}/variants/{variation_id} or the Products tab). The hub has already updated its listings; update your store images for each variant.
Field (in data) |
Type | Present | Description |
|---|---|---|---|
product_id_hub |
integer | Always | Hub product id |
shop_ids |
array of integer | Always | Shops the product is published to |
variants[].variation_id |
integer | Always | Blank variation |
variants[].design_id |
integer or null | Always | New effective design |
{
"topic": "product.updated",
"data": {
"product_id_hub": 42,
"shop_ids": [
5
],
"variants": [
{
"variation_id": 101,
"design_id": 11
},
{
"variation_id": 102,
"design_id": 12
}
]
},
"sent_at": "2026-09-29T10:15:00+00:00"
}
POST /webhooks/ceeprinto HTTP/1.1
Content-Type: application/json
X-CP-Topic: product.updated
X-CP-Signature: t=1790676900,v1=3109a19bca2da19790b920802b474111c7a423be628337ae57a6b1c67a9cc5cd
{"topic":"product.updated","data":{"product_id_hub":42,"shop_ids":[5],"variants":[{"variation_id":101,"design_id":11},{"variation_id":102,"design_id":12}]},"sent_at":"2026-09-29T10:15:00+00:00"}
Retry schedule
| Attempt | Sent | Timeout |
|---|---|---|
| 1 | Immediately (background job) | 10 s |
| 2 | 1 minute after attempt 1 fails | 10 s |
| 3 | 2 minutes after attempt 2 fails | 10 s |
| 4 | 4 minutes after attempt 3 fails | 10 s |
| 5 | 8 minutes after attempt 4 fails; then dropped | 10 s |
API ยท Errors
Errors and troubleshooting
Error codes, what causes them and how to fix the most common integration problems.
Match the status and error.code you got to the table below to find the likely cause and the fix, and include the X-CP-Request-Id when you need help.
Every error code
| HTTP | error.code | Likely cause | Fix |
|---|---|---|---|
| 400 | invalid_request |
Missing Idempotency-Key on POST /orders; shop_id missing or not yours on POST /orders; no design_ids/product_id or nothing left to publish; unknown webhook topic (details.valid_topics); bad target_url; listing without an external reference or design; missing city on shipping quote; unknown blank_product_id. |
Read error.message; add or correct the field. Do not retry unchanged. |
| 400 | rest_missing_callback_param, rest_invalid_param |
A required argument WordPress validates (e.g. channel on POST /shops, topic on POST /webhooks) is missing or the wrong type. details.params names it. |
Send the parameter with the right type. |
| 401 | unauthorized |
No credential; Bearer prefix missing; key mistyped, unknown or revoked; account deleted. |
Check Authorization: Bearer cp_live_โฆ. Create a new key if revoked or lost. |
| 403 | forbidden |
The key lacks the route's scope. details.required_scope says which; details.granted_scopes lists what it has. |
Use a key with that scope. Connection-code keys never have orders:read. |
| 404 | not_found |
Wrong id, or the resource belongs to another account (never 403 for that). | Re-list the collection to get valid ids. |
| 404 | rest_no_route |
Wrong path or HTTP method, or CP_BASE is missing /wp-json/ceeprinto/v2. |
Compare with the endpoint reference. |
| 409 | conflict |
POST /shops for a store another CeePrinto account already connected. |
The other account must disconnect it first; contact support if you own the store. |
| 422 | unprocessable_entity |
Unknown publish mode; link/update without external_product_id; order validation failed (details.issues[]: missing customer_price, quantity below 1, no line reference, missing address fields). |
Fix every item in details.issues, then resend with the same Idempotency-Key. |
| 429 | rate_limited |
More than 120 requests from this key in the current minute. | Sleep Retry-After seconds (also in details.retry_after), then retry. |
| 500 | server_error |
Hub-side failure: mockup renderer inactive (POST โฆ/variation-mockups), payouts unavailable, a row could not be saved. |
Retry with back-off. If it persists, open a ticket with the request id. |
Connection-code keys and 403
Keys named "Shopify App" or "WooCommerce Connect" come from cp1. connection codes and deliberately omit orders:read.
| Route | Connection-code key |
|---|---|
POST /orders |
Allowed (orders:write) |
GET /orders, GET /orders/{id}, GET /orders/{id}/events |
403 |
GET /payouts, GET /payouts/summary |
403 |
POST /shipping/quote |
403 |
Fix: create a key on My Account โ Store Connections โ API Keys with orders:read ticked, and use it for those reads.
Order intake failed
POST /orders answered 202 but GET /orders/{id} shows status: "failed". No WooCommerce order was created. last_error (and the failed event's reason) says why:
| last_error | Cause | Fix |
|---|---|---|
No listing, design or legacy product matched this line item (external_variant_id=โฆ). Create a listing for it, then retry. |
None of the line's references resolved: no listing on this shop with that listing_id, external_variant_id or external_sku, and no design_id you own. |
Create the listing (POST /shops/{id}/listings), or send design_id. Then Retry in My Account โ Store Connections โ Order Activity. |
WooCommerce product N no longer exists. |
The listing points at a blank or variation that was removed. | Re-publish on a current blank; update or recreate the listing; Retry. |
Order has no line items. |
The stored payload is empty. | Submit a new order with a new Idempotency-Key. |
A line item could not be added to the order. |
WooCommerce refused the product. | Contact support with the request id. |
| Anything else | Unexpected hub error. | Retry in Order Activity; contact support if it repeats. |
Resending the same Idempotency-Key returns the failed intake (200, idempotent_replay); it does not reprocess it. Reprocess with the Retry button in Order Activity after fixing the cause.
Common problems
| Symptom | Cause | Fix |
|---|---|---|
| Store product shows the garment's back on every variant | Mockup cells keyed by variation_id only; the back (stage_index 1) overwrote the front. |
Key by variation_id + stage_index; use stage_index 0 as the main image. |
| Blurry store images | Uploaded thumb_url (240px). |
Upload image_url (2048px). |
| Product published with no images | Uploaded before store_status was "ready". |
Poll GET /designs/{id}/variation-mockups until store_status is "ready" or "partial". |
| Merchant sees "publishing" forever | Publish item never closed, or webhook missed and no polling. | Poll GET /shops/{id}/publish-jobs/pending; always call complete or fail. |
| Duplicate orders | New Idempotency-Key per retry. |
Derive the key from your order: <external_shop_id>:<external_order_id>. |
| COD collects less than expected on multi-quantity lines | customer_price values are summed per line, not multiplied by quantity. |
Check order.cod_collection_total on GET /orders/{id} before COD is elected. |
| Webhook signature never matches | Signed the re-serialised JSON instead of the raw body, or used the wrong subscription's secret. | HMAC the raw bytes; each subscription has its own whsec_ secret (GET /webhooks). |
| Duplicate listings | Rows posted without external_variant_id. |
Always send external_variant_id; listings upsert on it. |
POST /publish-jobs is 400 "already published" |
create mode skips designs already listed on the shop. |
Use mode: "update" or "link" with external_product_id. |
| Rate-limit headers present but 429 never happens | The hub enforces the limit only with a persistent object cache. | Still honour the headers; the limit may be enforced at any time. |
Handling 429
async function withRateLimit(doRequest) {
for (let attempt = 1; attempt <= 5; attempt++) {
const res = await doRequest();
if (res.status !== 429) return res;
const wait = Number(res.headers.get('retry-after')) || 2 ** attempt;
await new Promise((r) => setTimeout(r, wait * 1000));
}
throw new Error('Still rate limited after 5 attempts');
}
Spread bulk work: at 120 requests per minute, sync with per_page=100 and use POST /shops/{id}/listings/bulk instead of one call per listing.
What to include in a support ticket
| Include | Where to find it |
|---|---|
X-CP-Request-Id |
Response header on every response, e.g. req_3f9a1c7e5b2d8a40. The most useful single item. |
| Time (UTC) of the request | Your logs |
| Method and path | e.g. POST /orders |
| Status and full error body | Response |
| Key id (never the secret) | GET /me โ api_key.key_id |
| Resource ids | Shop, design, job, item, intake or wc_order_id |
X-CP-Api-Version |
Response header |
API ยท Errors
Changelog
Dated list of changes to the v2 API and deprecations of older namespaces.
Every change to the ceeprinto/v2 API that affects integrators, newest first, so you can tell at a glance whether your client needs an update.
2.2.0 (2026-09-29)
| Type | Change | Action needed |
|---|---|---|
| Fixed | GET /products now honours per_page (default 20, max 100) like every other collection. It was fixed at 20. |
None. Remove any workaround that paged by 20. |
| Changed | POST /designs/{id}/variation-mockups answers 202 (was 200) with the status envelope. Mockups render on request at the returned URLs, so queued is 0. |
Accept 202. You can drop the POST and just GET the matrix. |
| Docs | openapi.yaml corrected: operationId, tags and x-scope on every operation, full request/response examples, response headers, and x-webhook-topics with per-topic payload schemas. |
Regenerate clients from openapi.yaml if you use a generator. |
| Docs | Status codes documented as implemented: POST /products with an unknown blank and POST /publish-jobs without designs are 400 (not 422); DELETE /shops/{id} is 200 with the shop; POST /orders replay is 200 with meta.idempotent_replay. |
Check your status handling. |
| Docs | GET /webhooks returns each subscription's secret; connection-code keys lack orders:read; GET /shops/{id}/publish-jobs/pending and GET /webhooks are unpaginated. |
None. |
| New | This documentation page, with Copy as prompt, llms.txt, llms-full.txt and a public openapi.yaml. | None. |
Deprecations
| Namespace | Status | Sunset |
|---|---|---|
integration/v1 |
Deprecated: Deprecation: true and Link: โฆ; rel="deprecation" headers |
Not set yet |
app/v1 |
Deprecated | Not set yet |
internal/v1 |
Deprecated | Not set yet |
ceeprinto/v1 |
Deprecated. Mockup image URLs returned by v2 (โฆ/ceeprinto/v1/mockup/{design_id}/{signature}.jpg) live here; use them exactly as returned. |
Not set yet |
A Sunset header will be added to legacy responses once a date is chosen, and the date will be listed here first.
Editor
How to design products in the CeePrinto design editor
Editor ยท Basics
Editor overview
What the design editor is, where to open it and a tour of its screen.
The CeePrinto design editor lets you put your own images and text on a product, preview every print side, and add the finished design to your cart or save it for later.
Who uses the editor
- Buyers customise a single product (a T-shirt, a hoodie, a mug) and order it.
- Merchants create designs, save them to their account, and publish them to their own online store so CeePrinto can print and ship each order.
Where the editor opens
On any product that can be customised, the product page shows a "Customize" button next to the normal cart button. Clicking it opens the editor on its own page at /product/{product-name}/design/. If the product comes in options (for example sizes or colours), pick an option first; the button stays greyed out until you do.
Some pages open the editor in a pop-up window instead. That window is titled "Customize Your Design" and has a "Close" button and a "Save & Add to Cart" button at the bottom.
The shop owner can rename the "Customize" button, so on some stores it may carry a different label.
The screen at a glance
| Area | Where | What it is for |
|---|---|---|
| Top bar | Top of the screen | Shows where you are (product name / "Design Tools" / current side), the Undo and Redo buttons, the tools for the selected layer, "My Designs" (signed-in customers) and the "Add to Cart" button with its menu. |
| Design Tools sidebar | Left | Three tabs: "Product" (product details and your layers), "Images" (upload and reuse pictures) and "Text" (add text). A "Design Area" picker appears below when a side has more than one printable area. |
| Canvas | Centre | The product photo with a dashed box. Only what sits inside the dashed box is printed. The line under the canvas shows the print size of the side and any placement notes. |
| Print Sides panel | Right | One card per printable side (for example "Front" and a back side). Each card shows the print size, any extra price, and how many layers are on it. |
| Layers | Left, "Product" tab | Every image and text you add, top layer first, with "Move up", "Move down" and "Delete layer" buttons. |
On a phone, the sidebar and the Print Sides panel slide in from the edges. Use the menu button at the top left ("Open design tools") and the grid button at the top right ("Open print sides"). The "Add to Cart" button sits in a bar at the bottom of the screen.
Design guidelines and pricing
Under the cart button on every product page you will find a short note asking you to make sure your design follows the CeePrinto guideline. Read the design guidelines before you order; CeePrinto is not responsible for designs that do not follow them. The same note links to the product's "Pricing Chart" tab.
You pay for each side you actually print on. A side you leave empty costs nothing extra. See Print sides and design areas.
What happens after "Add to Cart"
- The editor checks that nothing sticks out of the dashed print area and warns you if it does.
- It creates a preview and a high-resolution print file (300 DPI) for every side you used, and uploads them.
- It saves your design to the store and adds the product to your cart.
- The cart shows the item with a "Custom design" line and, when sides carry a price, a "Customization fee" line.
Editor ยท Basics
Your first design
Open a product, add text and an image, and add the finished design to your cart.
Follow these ten steps to go from a product page to a custom product in your cart in a few minutes.
-
Open the product and choose an option
Go to the product you want to customise. If it has options such as size or colour, choose one first. The "Customize" button stays greyed out until an option is selected.
-
Click "Customize"
The editor opens on its own page (
/product/{product-name}/design/). Wait for "Setting up your designerโฆ" to finish. The top bar shows the product name, then "Design Tools", then the side you are on, for example "Front". -
Pick the side you want to print on
In the "Print Sides" panel on the right, click a side card. Each card shows the print size in inches and any extra price for that side. On a phone, tap the grid button at the top right ("Open print sides").
-
Upload an image
In the left sidebar open the "Images" tab and drag a file onto "Drop images here or browse", or click the box to choose a file. The image appears on the canvas once the upload finishes. Pictures you uploaded before are listed underneath; click one to add it again.
-
Check the print quality badge
Click the image. The toolbar in the top bar shows a quality badge: "Good" is ready to print, "OK" may print slightly soft, "Low" will print blurry. Make the image smaller or upload a bigger file if you see "Low".
-
Add text
Open the "Text" tab and click "Add Text Layer". In the "Add New Text" window type your words under "Text content", choose a size, colour and font, then click "Add Text".
-
Move, resize and arrange
Drag a layer to move it and drag its corner handles to resize or rotate it. Keep everything inside the dashed box: the hint under the canvas reads "Place images and text inside the dashed design area." Use the "Product" tab to see your layers and change their order.
-
Repeat for other sides (optional)
Click another card in "Print Sides" and add content there too. Every side you use adds its own price, shown at the bottom of the "Print Sides" panel.
-
Click "Add to Cart"
Click "Add to Cart" in the top bar (on a phone, in the bar at the bottom). If part of a layer is outside the print area you will see "Some layers will be cropped" or "Some layers won't print". Choose "Fix automatically" to pull the layers back inside, or "Continue anyway".
-
Wait for the progress window
A window titled "Adding your design to cart" lists each step, starting with "Preparing your design" and ending with "Adding to cart". When it shows "Redirecting to cartโฆ" you are taken to your cart, where the item shows a "Custom design" line.
Want to keep designing after adding one item? Open the small arrow next to "Add to Cart" and choose "Add to cart and stay on page".
Editor ยท Basics
Print sides and design areas
Switch between print sides, pick a design area and see how sides affect the price.
Choose which sides of the product to print on, keep your design inside each side's dashed print area, and see exactly what each side adds to the price.
Switch between sides
-
Open "Print Sides"
The "Print Sides" panel is on the right. On a phone, tap the grid button at the top right ("Open print sides"). Products with only one side do not show side cards.
-
Click a side card
The canvas switches to that side and its name appears at the end of the top bar breadcrumb. The first side is usually "Front".
-
Design that side
Images and text you add go onto the side you are viewing. Each side keeps its own layers.
What a side card shows
| Item on the card | Meaning |
|---|---|
| Name | The side, for example "Front". |
| Price (for example +250) | Extra cost added when this side has any content. Not shown for sides that are included free. |
| Description | A short note from CeePrinto about the side, when set. |
| Print size (for example 12" ร 16") | The largest size CeePrinto can print on this side, width ร height in inches. |
| Layer count (for example 2 layers) | How many images and texts are on this side. |
Design areas and the dashed box
Every side has at least one printable area, drawn on the canvas as a dashed box. Only what sits inside the dashed box is printed; anything outside is cut off. The hint under the canvas reads "Place images and text inside the dashed design area." and, next to it, "Print" with the side's print size and, when set, "Placement" notes from CeePrinto (for example how far below the collar the print sits).
Some sides have more than one printable area. In that case a "Design Area" drop-down appears at the bottom of the left sidebar. Choose an area before you add an image or text; new layers are placed in the chosen area.
When something sticks out
If a layer crosses the dashed edge, the hint under the canvas turns amber and says how many layers are outside the print area, with a "Fit to area" button. The layer list marks those layers "Partly cropped" or "Won't print".
When you click "Add to Cart", "Save Design", "Update design", "Download PNG (current side)" or "Export PDF (current side)", the editor checks every side and shows a window if anything is outside:
| Window title | When | Your choices |
|---|---|---|
| "Some layers will be cropped" | At least one layer is partly outside the print area. | "Fix automatically" shrinks and moves the layers inside. "Continue anyway" keeps them as they are; the outside part will not print. |
| "Some layers won't print" | Every listed layer is completely outside the print area. | Same two buttons. With "Continue anyway" those layers are left off the print. |
How sides affect the price
You pay the product price plus the price of each side you actually use. A side with no layers adds nothing. The bottom of the "Print Sides" panel lists the sides you have used with their prices, and the note "Price applies when you add design to a side." In the cart the extra appears as a "Customization fee" line.
| Example | Sides used | Extra charge |
|---|---|---|
| Front only | Front (+250) | 250 |
| Front and back | Front (+250), Back (+300) | 550 |
| Nothing added to the back | Front (+250), Back empty | 250 |
Print specifications
| Specification | Value |
|---|---|
| Print method | DTF (direct to film) transfer |
| Print file resolution | 300 DPI |
| Print size | Set per side by CeePrinto, in inches (shown on each side card and under the canvas) |
| Files made per used side | A preview image, a print PNG and a print PDF |
Tips
- Leave a small margin inside the dashed box; edges of a print can shift slightly.
- Use "Fit to area" before adding to cart so you do not get the warning window.
- Check every side before ordering. A stray layer on the back adds that side's price.
Editor ยท Tools
Text
Add and style text: fonts, size, colour, spacing, alignment and effects.
Add text to the current side, style it with any of 13 fonts, and change it later from the toolbar without starting over.
Add text
-
Open the "Text" tab
In the left "Design Tools" sidebar, click "Text", then "Add Text Layer".
-
Type and style your text
The "Add New Text" window shows a live preview at the top. Type under "Text content" (the box says "Enter your text"), then set the size, colour, spacing, alignment, style and font.
-
Click "Add Text"
The text is placed inside the dashed area of the current side. Click "Cancel" to close the window without adding anything.
"Add New Text" fields
| Field | What it does | Allowed values |
|---|---|---|
| "Text content" | The words to print. | Any text |
| "Size (px)" | Text size on the canvas. | 8 to 200 |
| "Color" | Text colour, picked from a colour chooser. | Any colour |
| "Letter spacing (px)" | Space between letters. Negative values squeeze letters together. | -5 to 50, steps of 0.5 |
| "Line height" | Space between lines, as a multiple of the text size. | 0.8 to 3, steps of 0.1 |
| "Formatting" | Align left, centre or right, plus "Bold", "Italic", "Underline" and "Strikethrough" toggles (B, I, U, S). | On or off |
| "Font family" | The font. Type in "Search fontsโฆ" to filter the list. | 13 fonts, see below |
Available fonts
| Font | Type |
|---|---|
| Inter (default) | Web font, looks the same on every device |
| Roboto | Web font |
| Open Sans | Web font |
| Lato | Web font |
| Montserrat | Web font |
| Poppins | Web font |
| Playfair Display | Web font |
| Oswald | Web font |
| Source Sans 3 | Web font |
| Nunito | Web font |
| Arial (System) | Uses the font installed on your device |
| Times New Roman (System) | Uses the font installed on your device |
| Helvetica (System) | Uses the font installed on your device |
The three "(System)" fonts depend on your computer or phone. If a device does not have that font, a similar one is used instead. For a guaranteed look, choose one of the 10 web fonts.
Edit existing text
Click a text layer on the canvas (or in the "Product" tab's layer list). The text toolbar appears in the top bar (on a phone, just below it). Changes apply immediately.
| Control | What it does |
|---|---|
| "Edit textโฆ" box | Change the words. |
| Font button (its tooltip names the current font) | Opens the "Font family" list with "Search fontsโฆ". |
| "Font size" | Number box, 8 to 200. |
| "Text color" | Colour swatch; click to pick a new colour. |
| "Bold", "Italic", "Underline", "Strikethrough" | Turn each style on or off. |
| "Align left", "Align center", "Align right" | Align lines of text inside the text box. |
| "Bring forward" / "Send backward" | Move the text one step above or below other layers. |
| Drag handles on the canvas | Move, resize (keeps proportions) and rotate the text. |
Tips
- Keep text a little away from the dashed edge so nothing gets trimmed.
- Very thin fonts at small sizes may not print cleanly. Use "Bold" or a larger size for small lettering.
- Keyboard shortcuts (such as Delete) are paused while you type in a text box, so you will not delete a layer by accident.
Editor ยท Tools
Images and uploads
Upload artwork, check print quality, crop, remove backgrounds, mask and fit images.
Upload your artwork, check that it is sharp enough to print, then crop, clean up the background, shape and fit it without leaving the editor.
Upload an image
-
Open the "Images" tab
In the left sidebar, click "Images".
-
Drop or choose files
Drag one or more files onto "Drop images here or browse", or click the box (or press Enter when it is focused) to pick files. While you drag, the box says "Drop to upload".
-
Watch the progress list
Each file gets its own row with a progress bar showing how much has been sent. When a row shows "Uploaded", the image is placed in the middle of the dashed area on the current side.
| Item | Value |
|---|---|
| Recommended file types | PNG, JPG, WebP (GIF is also accepted) |
| SVG | Accepted. Vector artwork is cleaned on upload (scripts and external references are removed) and scales without losing sharpness, so it is the best choice for logos and text-based designs. |
| Largest file | 200 MB |
| Files at once | As many as you like; they upload one after another |
| Automatic retries per file | Up to 4 attempts |
| Best for transparent artwork | PNG with a transparent background |
Large files and poor connections
Big files are sent in small pieces. If your connection drops, the row shows "Waiting for connectionโฆ" and carries on by itself when you are back online. Short hiccups show "Retrying" with the attempt number. If a file still fails, its row turns red with the reason and a "Retry" button. Retrying continues from the last piece that arrived, so nothing is sent twice. Click the X on a row to cancel an upload or to clear a finished one.
Reuse earlier uploads
Below the upload box the editor lists the images you uploaded before. Type in "Search your uploadsโฆ" to find one by name and click a thumbnail to add it to the current side. If you have not uploaded anything yet it says "Nothing uploaded yet."
Print quality badge
When you select an image, the image toolbar starts with a coloured badge that tells you how sharp it will print at its current size on this side. Hover over the badge to see the exact DPI (dots per inch). Making an image bigger lowers its DPI; making it smaller raises it.
| Badge | DPI at current size | What it means | What to do |
|---|---|---|---|
| "Good" (green) | 300 or more | Prints sharp. | Nothing. |
| "OK" (amber) | 150 to 299 | "may print slightly soft" | Fine for large, bold artwork. For fine detail, shrink the image or upload a larger file. |
| "Low" (red) | Below 150 | "will print blurry" | Shrink the image or upload a higher-resolution file. |
The badge only appears when the side has a print size set, and on wide screens. Print files are always produced at 300 DPI; the badge tells you whether your picture has enough detail to fill that.
Image toolbar
Select an image to show these controls in the top bar (on a phone, just below it).
| Control | What it does |
|---|---|
| "Crop" | Opens the "Crop Image" window. Drag the box to keep only part of the picture, then "Apply crop". "Reset crop" brings back the full picture. |
| "Remove solid background" | Removes a plain background colour (white, green screen or any single colour). See below. |
| "Mask shape" | Shows the picture through a shape: "None", "Rectangle", "Circle" or "Ellipse". |
| "Fit mode" | How the picture fills its box: "Default", "Cover", "Contain", "Fill (stretch)", "Fit width" or "Fit height". |
| "Background fill" | Puts a solid colour behind a transparent PNG. "Clear" removes it. |
| "Flip horizontal" / "Flip vertical" | Mirror the picture left to right or top to bottom. |
| "Bring forward" / "Send backward" | Move the picture one step above or below other layers. |
| Drag handles on the canvas | Move, resize (keeps proportions) and rotate the picture. |
Remove a solid background
-
Open "Remove solid background"
Select the image and click "Remove solid background". The window explains: "Best for white or green-screen images. Not for photo backgrounds."
-
Choose the colour to remove
Click a preset ("White" or "Green screen"), click "Auto-detect", or click "Eyedropper" and then click the background on the canvas. While picking, the hint under the canvas reminds you to click the background colour. Press Esc or click "Cancel pick" to stop. You can also type a colour code.
-
Adjust and apply
Raise "Tolerance" to remove more shades close to the chosen colour, and raise "Edge softness" to smooth the cut edge. Click "Remove background".
Tips
- Use PNG with a transparent background for logos so no box prints around them.
- A light halo after background removal usually means "Tolerance" is too low; raise it a little and try again.
- "Background fill" is only useful for images with transparent areas; on a photo you will not see a difference.
Editor ยท Tools
Layers
Reorder, select and delete the elements on each print side.
Use the layer list to select, reorder and delete every image and text on the current side, and to spot anything that will not print.
Find the layer list
Open the "Product" tab in the left sidebar. Under "Layers" you see one card for each image or text on the side you are viewing. The top card is the layer in front; the bottom card is the one at the back. Each card shows a small preview, the layer's name and whether it is an image or text.
When a side has no content yet, the list reads "No layers yet" and "Upload an image or add text to start".
Work with layers
-
Select a layer
Click a card in the list, or click the layer on the canvas. The selected card is highlighted and the matching toolbar (text or image) appears in the top bar.
-
Change the order
Click "Move up" to bring a layer in front of the one above it, or "Move down" to send it behind. The top layer cannot move up and the bottom layer cannot move down. The toolbar buttons "Bring forward" and "Send backward" do the same thing.
-
Delete a layer
Click "Delete layer" (the red bin) on its card, or select the layer and press Delete or Backspace. Changed your mind? Click "Undo" in the top bar or press Ctrl+Z (Cmd+Z on a Mac).
Controls
| Control | What it does |
|---|---|
| Layer card | Selects the layer. |
| "Move up" | Moves the layer one step towards the front. |
| "Move down" | Moves the layer one step towards the back. |
| "Delete layer" | Removes the layer from the side. |
| "Partly cropped" badge (amber) | Part of the layer is outside the dashed print area. That part will not print. |
| "Won't print" badge (red) | The whole layer is outside the dashed print area. None of it will print. |
Tips
- The list only shows layers on the current side. Switch sides in "Print Sides" to see the others; each side card shows how many layers it holds.
- If a layer shows a warning badge, use "Fit to area" under the canvas to pull everything on that side back inside the print area.
- Layers cannot be hidden, locked or grouped. Delete a layer you do not want printed.
Editor ยท Tools
Designing per size and colour
Apply a change to every variation or only the one you are editing.
When you edit a saved design, you can keep one shared design for every size and colour of the product, or give a single option its own version.
When this applies
Per-option editing is available when you open a design you saved earlier (with "Use design" in Saved Designs, or from "My Designs" in the editor) on a product that comes in options such as sizes or colours. Merchants use it to adjust artwork for, say, a dark shirt colour or a small size, while every other option keeps the shared design.
In this mode the top bar shows "Update design" next to "Add to Cart", and the "Product" tab lists the product's options.
The option list
Open the "Product" tab. Under the product name you see one row per option, with a small picture and the option name. The option you are editing is highlighted.
| Item | What it means or does |
|---|---|
| Option row | Click to switch the canvas to that option. |
| Dot marked "Customized for this option" | This option has its own version of the design. Options without the dot use the shared (base) design. |
| "Reset to base design" | Shown when the selected option has its own version. Throws that version away so the option uses the shared design again. |
| "Change Product" | Move the design to a different product (see below). |
| "View product page" | Opens the product page. |
Change one option or all of them
-
Select an option and make an edit
Click an option row, then move, add or change anything on the canvas.
-
Answer "Apply this change toโฆ"
On your first edit the editor asks where the change belongs. Choose "Change this variation only" to give this option its own version, or "Change every variation" to update the shared design used by every option that does not have its own version. Closing the window cancels the edit.
-
Keep editing
The editor remembers your answer for that option, so it will not ask again for every small change. After "Change every variation" it stops asking for the rest of the session.
-
Click "Update design"
This saves the shared design and every option's own version. The progress window shows "Updating your design" and ends with "Design updated".
Switching options with unsaved changes
If you click another option (or another product) before saving, the editor asks "Save changes before switching?".
| Button | Result |
|---|---|
| "Save and switch" | Saves your changes, then opens the other option. |
| "Discard and switch" | Drops your unsaved changes and keeps the last saved version. |
| Close the window | Stays on the current option; nothing is lost. |
Move a design to another product
Click "Change Product" in the "Product" tab. The "Switch Product" window lets you search with "Search productsโฆ"; for products with options, pick one with "Search optionsโฆ". The last step, "How do you want to switch?", offers:
| Button | Result |
|---|---|
| "Keep my design, just change product" | Carries your layers over to the new product. |
| "Start a new design for this product" | Opens the new product with an empty canvas. |
Tips
- Make shared changes first with "Change every variation", then fine-tune individual options.
- Look for the "Customized for this option" dot before a big change; those options will not pick up shared edits.
Editor ยท Tools
Keyboard shortcuts
Every keyboard shortcut the editor supports.
Use these keyboard shortcuts to delete, undo and redo faster while you design.
| Action | Windows / Linux | Mac |
|---|---|---|
| Delete the selected layer | Delete or Backspace | Delete or Backspace |
| Undo | Ctrl+Z | Cmd+Z |
| Redo | Ctrl+Y or Ctrl+Shift+Z | Cmd+Y or Cmd+Shift+Z |
| Stop picking a colour with the "Eyedropper" | Esc | Esc |
| Close an open toolbar menu (font, "Mask shape", "Fit mode", "Background fill") or the "Add to Cart" menu | Esc | Esc |
| Open the file browser from a focused upload box | Enter or Space | Enter or Space |
Shortcuts are paused while you are typing in a box (for example "Edit textโฆ" or "Font size"), so pressing Backspace there edits your text instead of deleting the layer.
Buttons that do the same thing
The "Undo" and "Redo" buttons sit in the middle of the top bar; their tooltips read "Undo (Ctrl+Z)" and "Redo (Ctrl+Y)". "Delete layer" in the layer list removes a layer with the mouse.
Editor ยท Saving
Saving, downloading and adding to cart
Save a design to My Designs, download a PNG or PDF, or add it to your cart.
Add your design to the cart, save it to your account to reuse or sell later, or download a copy of the current side as a PNG or PDF.
Guests and signed-in customers
| You canโฆ | Guest | Signed in |
|---|---|---|
| Design and "Add to Cart" | Yes | Yes |
| "Add to cart and stay on page" | Yes | Yes |
| "Download PNG (current side)" / "Export PDF (current side)" | Yes | Yes |
| "Save Design" for later | No (sign in first) | Yes |
| Open "My Designs" in the editor | No | Yes (wide screens) |
| Manage designs under "Saved Designs" in My Account | No | Yes |
Guests: your design is attached to your cart item, but it is not kept in a library. Sign in before you start if you want to reuse the design later.
The "Add to Cart" button and its menu
The main button in the top bar (on a phone, in the bottom bar) is "Add to Cart". The small arrow beside it ("More actions") opens a menu.
| Menu item | What it does | Who sees it |
|---|---|---|
| "Add to Cart" (main button) | Saves the design, adds the product to your cart and takes you to the cart. | Everyone |
| "Add to cart and stay on page" | Adds to cart and keeps the editor open. The window says "Added to cart. You can keep editing." with a "View cart" link. | Everyone |
| "View Cart" | Opens your cart without adding anything. | Everyone |
| "Save Design" | Asks "Name your design (optional):" and saves the design to your account. | Signed-in customers, when not already editing a saved design |
| "Download PNG (current side)" | Downloads an image of the side you are viewing, at print scale. | Everyone |
| "Export PDF (current side)" | Downloads a PDF of the side you are viewing, sized to the side's print size in inches. | Everyone |
All of these (except "View Cart") first check for layers outside the print area and may show the "Some layers will be cropped" window. See Print sides and design areas.
Progress window
Adding to cart can take a little while because the editor makes high-resolution print files for every side you used. A window titled "Adding your design to cart" lists each step and ticks it off. Keep the page open until it finishes.
| # | Step | What happens |
|---|---|---|
| 1 | "Preparing your design" | Collects your layers and checks the sides you used. |
| 2 | "Creating preview" (per side) | Makes a preview picture of the side. |
| 3 | "Uploading print file" (per side) | Uploads the 300 DPI print PNG. Usually the longest step. |
| 4 | "Uploading print PDF" (per side) | Uploads the print PDF. |
| 5 | "Saving design to store" | Stores the design with the product and price. |
| 6 | "Adding to cart" | Puts the product in your cart. |
| 7 | "Redirecting to cartโฆ" | Takes you to the cart (skipped with "Add to cart and stay on page"). |
- If your internet drops, the window says it is waiting for your connection and resumes by itself.
- If a step fails, the window shows "Could not add to cart" with "Retry" and "Close". "Retry" carries on from where it stopped; work already saved is not redone.
Save a design to your account
-
Choose "Save Design"
Open the menu next to "Add to Cart" and choose "Save Design". You must be signed in.
-
Name it
Type a name in "Name your design (optional):" or keep the suggested one, then confirm. Cancel stops the save.
-
Wait for "Saved to My Designs"
The window runs "Preparing your design", "Uploading preview" and "Saving to My Designs".
-
Open it again later
Click "My Designs" in the top bar to open "My Saved Designs", then click a design. The editor shows "Loading saved designโฆ" and then "Design loaded. You can edit it and add to cart." To save changes to that same design, click "Update design".
Download PNG or PDF
"Download PNG (current side)" and "Export PDF (current side)" save a file to your device straight away. They cover only the side you are viewing, do not add anything to your cart, and are meant for proofs or sharing with a colleague. The files CeePrinto prints from are made when you click "Add to Cart".
"Saved Designs" in My Account
Signed-in customers find every saved design under My Account, "Saved Designs". Each card shows the design's name (or "Design #" and a number), "Last used on:" with the product, and the "Saved:" date.
| Button | What it does |
|---|---|
| "Use design" | Opens the product's design page with this design loaded, ready to edit or add to cart. |
| "Publish to store" | Merchants: opens Store Connections, "Get Started", with this design selected so you can publish it to your connected store. Tick several cards and use the "Publish to store" button above them to publish in bulk. |
| "Delete" | Asks "Delete this saved design?" and removes the design. |
| Message after "Delete" | Why |
|---|---|
| "Design deleted." | The design was removed. |
| "This design belongs to one of your orders and cannot be deleted." | An order uses this design, so CeePrinto keeps it for printing and reprints. |
| "This design is still linked to a store listing. Unlink it under Store Connections โ Listings first." | A product in your connected store uses it. Unlink the listing, then delete. |
Tips
- Use "Add to cart and stay on page" to order the same product in several designs without reopening the editor.
- Give saved designs clear names; the list and the cart show them.
- After you order, your order page shows a "Custom Design" panel with the print size, placement and "Design Files" links.
Editor ยท Saving
Editor troubleshooting
Fixes for upload failures, low-quality warnings, overflow prompts and other common issues.
Find the message or problem you are seeing, then follow the fix in the same row.
| Problem | Likely cause | Fix |
|---|---|---|
| Upload fails with "That file type is not accepted here." | The file is not PNG, JPG, WebP, GIF or SVG, or it is an SVG the sanitizer could not clean. | Save or export the artwork as PNG (transparent background for logos) or JPG and upload again. |
| Upload fails with "That file is too large" (limit 200 MB) | The file is over 200 MB. | Export a smaller file. A PNG sized for the side's print size at 300 DPI is plenty. |
| Upload row turns red with "Upload failed" or stops at "Waiting for connectionโฆ" | The connection dropped or timed out. | Wait for your connection to come back; the upload resumes on its own. Otherwise click "Retry"; it continues from where it stopped. |
| "That upload session has expired. Please start over." | An unfinished upload was left too long before retrying. | Click the X to clear the row and upload the file again. |
| Image badge shows "Low" or "OK" | The picture does not have enough pixels for its size on the product (below 150 DPI is "Low", 150 to 299 is "OK"). | Make the image smaller on the canvas, or upload a higher-resolution original. Aim for "Good" (300 DPI or more). |
| "Some layers will be cropped" or "Some layers won't print" appears | A layer crosses or sits outside the dashed print area. | Click "Fix automatically", or move and shrink the layer yourself. "Continue anyway" prints only the part inside the box. |
| Editor stays on "Setting up your designerโฆ" or the design never appears | A slow connection, a browser extension blocking scripts, or a very old browser. | Reload the page, turn off ad or script blockers for this site, and use an up-to-date Chrome, Edge, Firefox or Safari. |
| "No design configuration found for this product." or "This product has no design configuration yet." | The product (or the product you tried to switch to) is not set up for customisation. | Choose another product, or contact CeePrinto support. |
| "Could not load design" when opening a saved design | The design was deleted, belongs to another account, or you are signed out. | Sign in with the account that saved it and open it again from "Saved Designs" with "Use design". |
| "Customize" button is greyed out | The product has options and none is selected. | Choose a size, colour or other option on the product page first. |
| "Save Design" is missing from the menu | You are not signed in, or you are editing a saved design (use "Update design" instead). | Sign in (your cart is kept), then reopen the editor. When editing a saved design, click "Update design". |
| "My Designs" button is missing | You are signed out, or the screen is narrow (the button shows on wide screens only). | Sign in, or open your designs from My Account, "Saved Designs". |
| Cannot delete a saved design: "This design belongs to one of your orders and cannot be deleted." | An order uses this design and CeePrinto keeps it for printing. | Nothing to fix; the design stays in your list. |
| Cannot delete a saved design: "This design is still linked to a store listing." | A product in your connected store still uses the design. | Unlink it under Store Connections, Listings, then delete. |
| Light or coloured fringe after "Remove background" | "Tolerance" is too low for shades near the background colour, or the edge is too hard. | Undo, open "Remove solid background" again, raise "Tolerance" a little and add some "Edge softness". For photos, remove the background in another tool and upload a transparent PNG. |
| Text looks different in print than on screen | A "(System)" font (Arial, Times New Roman, Helvetica) was replaced by a similar font on your device, or the text was very thin or small. | Use one of the 10 web fonts (for example Inter or Montserrat), and use "Bold" or a bigger size for small lettering. |
| Price is higher than expected | A layer sits on a side you did not mean to use; every used side adds its price. | Check each card in "Print Sides" for a layer count and delete stray layers. |
| Progress window shows "Could not add to cart" | A network or server error during one of the steps. | Click "Retry"; finished steps are not repeated. If it keeps failing, click "Close", reload, and try again. |
Still stuck? Note the exact message and the product, and contact CeePrinto support.
Merchant
Selling with CeePrinto from My Account and Store Connections
Merchant ยท Setup
Selling with CeePrinto: the journey
The path from design to product, store, orders and payouts at a glance.
CeePrinto prints, packs, ships and collects cash on delivery for your brand, and this page shows the six steps from a first design to money in your bank account.
You are a merchant: a brand owner who sells print-on-demand products (you design, we print only when an order comes in). Everything you need lives under My Account on ceeprinto.com. Your own shop can be a Shopify store, a WooCommerce (WordPress) store, or your own software talking to the CeePrinto API.
The journey in six steps
-
1. Design
Open a blank product (an unprinted T-shirt, hoodie and so on) in the editor, add your artwork and click Save Design. It appears in My Account โ Saved Designs. See Saved Designs.
-
2. Build a product
In Store Connections โ Products, create a product from a blank, choose a Default design and, if you like, give some Size or Color variants their own design. See Products.
-
3. Connect your store
In Store Connections โ Get Started, install the Shopify app or the CeePrinto Connect WordPress plugin, then generate a connection code and paste it into the app. See Get Started.
-
4. Publish
Publish the product (or selected saved designs) to a connected store. The store app creates the product with every Size and Color variant and uploads the mockup images. See Products and Connected stores.
-
5. Orders and COD
When a buyer orders on your store, the order arrives in Order Activity and becomes an order in My Account โ Orders with status
Pending payment. You choose whether we collect cash on delivery (COD) from your buyer, then pay the order. You can also order directly in the cart. See Orders and COD and Cart order options. -
6. Payouts
After the courier confirms delivery we hold the collected cash for 7 days, then transfer it to the bank account saved on Pay Out. See Payouts.
Where everything lives in My Account
The My Account menu is ordered for sellers. Some items only appear when they apply to you.
| Menu item | What you do there | Docs |
|---|---|---|
| Dashboard | Overview, alerts that need action, orders waiting on you. | This section |
| Orders | Every order, including ones sent by your store. Open a Pending payment order to set COD. |
Orders and COD |
| Returns | Parcels returned by your buyers. Shown only when you have returned orders. | Orders and COD |
| Saved Designs | Designs saved from the editor: use, publish or delete. | Saved Designs |
| Store Connections | Get Started, Products, Stores, Orders and Developer tabs. | Get Started |
| Brand | Brand name and brand icon used on packaging. | Cart order options |
| Pay Out | COD collections, payout status and your bank details. | Payouts |
| Support | Open and follow support tickets. | FAQ |
| Addresses | Your billing address. Orders copy it, so keep it complete. | Orders and COD |
| Account details | Name, email and password. |
Store Connections tabs
| Top tab | Sub-tabs | Purpose |
|---|---|---|
| Get Started | Install links, connection codes and a quick bulk publish of saved designs. | |
| Products | Hub products: default design, per-variant overrides, publish. | |
| Stores | Your connected stores: rename, disconnect or reconnect. | |
| Orders | Order Activity, Listings, Fulfillment Profile | Incoming store orders, design-to-store mappings, address check. |
| Developer | API Keys, Webhooks, Product Mapping | For your own software or developer. |
Dashboard alerts
The Dashboard shows an alert card for each setup task you have not finished. Each card has a button that takes you to the fix.
| Alert | Button | What it means | Fix |
|---|---|---|---|
| Add your bank details | Add bank details | No bank account is saved, so payouts cannot be sent. Payouts show Missing bank details. |
Payouts |
| Complete your fulfillment profile | Update fulfillment profile | Your billing Phone, Address line 1, City or Country is empty. Store orders copy these, so shipments would be incomplete. | Orders and COD |
| Connect your store | Connect your store | No store is connected yet. | Get Started |
Merchant ยท Setup
Get Started: connect Shopify or WordPress
Connect your Shopify or WooCommerce store from Store Connections โ Get Started.
Connect a Shopify or WooCommerce store to CeePrinto in about five minutes from My Account โ Store Connections โ Get Started.
Get Started is the first tab of Store Connections. It walks you through four numbered steps: Install, Keys, Create Your Design and Publish. A step turns green when it is done. New brands land here straight after signing up.
What a connection code is
A connection code is a single line of text that starts with cp1.. It bundles the CeePrinto site address and a private access key made just for your store app, so you paste one thing instead of several. Treat it like a password: anyone holding it can publish to and send orders into your account.
| Fact | Value |
|---|---|
| Starts with | cp1. |
| Buttons that create one | Generate Shopify Code, Generate WordPress Key |
| How many times it is shown | 1 (on the page that loads right after you click) |
| How long the page waits to show it | 2 minutes. After that the code is never shown again. |
| How long the key inside stays valid | Until you generate a new code of the same type, or revoke the key |
| Active codes per type | 1 for Shopify, 1 for WordPress. Generating a new one switches off the old one. |
| What the store app can do with it | Read your designs and the blank catalog, create and remove store listings, send orders, register the store and webhooks |
| What it cannot do | Read your order list, payouts or shipping quotes through the API. The store apps do not need these. |
Connect a Shopify store
-
Install the Shopify app
In step Install, click Install Shopify App and approve the app in your Shopify admin.
-
Generate the code
Come back to Get Started. In step Keys, click Generate Shopify Code. The page reloads with a box titled Your Shopify connection code.
-
Paste it into Shopify
Copy the whole code (it starts with
cp1.) and paste it into the CeePrinto app Settings in Shopify. Save. -
Check the store is connected
Open Store Connections โ Stores. Your Shopify store is listed with status
Active, and Get Started shows how many stores are connected.
Connect a WooCommerce (WordPress) store
-
Download and install the plugin
In step Install, click Download WordPress Plugin. In your own WordPress admin, go to Plugins โ Add New โ Upload Plugin, upload the file and activate CeePrinto Connect.
-
Generate the code
Back on Get Started, in step Keys, click Generate WordPress Key. The page reloads with a box titled Your WordPress connection code.
-
Paste it into CeePrinto Connect
Copy the code, open CeePrinto Connect on your WooCommerce store, paste it and click Connect.
-
Done
The plugin registers your store and its webhooks automatically. Check Store Connections โ Stores: the store is listed as
Active.
If the code expired or you lost it
You cannot view a code again. Click the same button (Generate Shopify Code or Generate WordPress Key) to make a new one and paste the new code into your store app.
Regenerating switches off the old code. If a store is already connected with the old code, it stops talking to CeePrinto until you paste the new code into it. Only regenerate when you are ready to update the store app.
Where the install links come from
The Install Shopify App and Download WordPress Plugin buttons open links set by the CeePrinto team. If a button is greyed out with the note Configure in Settings โ CeePrinto API, the link has not been set yet. Open a ticket from My Account โ Support and we will send it to you.
The other two steps on this tab
| Step | What it shows |
|---|---|
| Create Your Design | If you have no saved designs, a Browse blanks button to open the shop. Otherwise the number of saved designs ready. |
| Publish | Pick a Target store, tick Designs and click Publish. Each design is published as one store product with every Size ร Color variant of its blank. Designs already on that store are greyed out and marked already published. Progress appears under Publish progress. |
For more control (a different design per variant, or a flat price) publish from Products instead.
Merchant ยท Setup
API keys and webhooks
Create and revoke API keys and manage webhook subscriptions from My Account.
If you or a developer connect your own software to CeePrinto, create a scoped API key and add webhooks from Store Connections โ Developer.
You only need this if you are building your own integration. The Shopify app and CeePrinto Connect get their keys from Get Started and set up webhooks for you.
An API key is a secret password for software. A scope (shown as Permissions) limits what the key may do. A webhook is a message CeePrinto sends to your server when something happens, for example an order ships.
Create an API key
-
Open API Keys
Go to Store Connections โ Developer โ API Keys and scroll to Create a key.
-
Name it
Type a Name you will recognise later, for example the name of the app that will use it.
-
Choose permissions
Tick only the Permissions the software needs (table below). At least one is required.
-
Create and copy
Click Create key. The next page shows Your new API key once: This is the only time it will be shown. Store it somewhere safe. Copy it into your software or a password manager.
| Scope | Label on screen | In plain words |
|---|---|---|
designs:read |
Read designs | See your saved designs and their mockups. |
products:read |
Read the blank-product catalog | See the blanks you can sell and their stock. |
listings:read |
Read store listings | See which store products are linked to which designs. |
listings:write |
Create and remove store listings | Link and unlink store products and designs; publish. |
orders:read |
Read orders | See your orders, payouts and shipping quotes. |
orders:write |
Submit orders | Send new orders to CeePrinto. |
shops:write |
Connect and disconnect stores | Register a store and disconnect it. |
webhooks:write |
Manage webhook subscriptions | Add and remove webhooks. |
Your keys
The Your keys table shows Name, Key ID (the public part of the key, safe to share with Support), Permissions, Last used and Status.
| Status | Meaning | Action |
|---|---|---|
Active |
Working. | Revoke |
Active (legacy) |
An old key from before API keys had permissions. It can do everything. | None here: Managed on Get Started |
Revoked |
Switched off for good. | None |
The keys your store apps use appear here too, named Shopify App and WooCommerce Connect.
Revoke a key
Click Revoke on the key. API key revoked. Any integration using it will stop working immediately. This cannot be undone; create a new key if you need access again. Revoke straight away if a key may have leaked.
Add a webhook
-
Open Webhooks
Go to Store Connections โ Developer โ Webhooks and find Add a webhook.
-
Pick a topic
Choose the Topic (the event) from the list below.
-
Enter your URL
Type the Target URL on your server, starting with
https://. Click Add webhook. -
Copy the signing secret
The webhook appears under Your webhooks with a Signing secret. Your server uses it to check that a message really came from CeePrinto.
| Topic | Sent when |
|---|---|
order.status_changed |
A CeePrinto order changes status. |
order.shipped |
An order moves to Completed with a courier booking. Includes the tracking number. |
design.updated |
One of your designs is saved again. |
design.publish_requested |
You click Publish, so your store app should create or update a product. |
product.stock_changed |
A blank's stock level or in-stock status changes. |
product.updated |
A hub product's default design or overrides change. |
Each subscription covers one topic; add one row per topic you need. Click Remove to delete a subscription. Recent deliveries lists the last attempts with When, Topic, URL, Response and Status, which helps when your server is not receiving messages.
The message format, signature check and retry schedule are in the developer docs: API authentication and Webhooks (API).
Product Mapping
Developer โ Product Mapping is the older, advanced screen for mapping products from earlier integrations. New stores do not need it; use Products.
Merchant ยท Selling
Saved Designs
Reuse, publish or delete the designs you saved from the editor.
Saved Designs is your design library: every design you save in the editor lands here, ready to reuse, publish to a store or delete.
Open it from My Account โ Saved Designs. A design is saved when you click Save Design in the editor (you must be logged in). See Saving, downloading and adding to cart for the editor side.
What is saved
A saved design keeps your artwork and text for every print side of the product you designed on, plus a preview picture. Each card shows:
| On the card | Meaning |
|---|---|
| Preview | A thumbnail of the design. No preview means the picture has not been made yet; the design itself is fine. |
| Name | The name you gave it, or Design #ID if you did not. |
| Last used on: | The blank product the design was made on. |
| Saved: | Date and time of the last save. |
The three actions
| Button | What happens | When to use it |
|---|---|---|
| Use design | Opens the editor on the same product with this design loaded. | Edit the artwork, or add it to your cart to order. |
| Publish to store | Opens Store Connections โ Get Started with this design ticked in the Publish step. | Put the design on a connected store as a new product. |
| Delete | Asks Delete this saved design? and removes it. | Clean up designs you no longer need. |
Publish several designs at once
-
Tick the designs
Tick the checkbox on each design card you want to publish.
-
Click Publish to store at the top
Use the Publish to store button above the grid. Get Started opens with those designs ticked.
-
Pick the store and publish
Choose the Target store and click Publish. See Get Started.
Why Delete can be blocked
We never delete artwork that is still needed to print something. You will see one of these messages instead:
| Message | Reason | What to do |
|---|---|---|
| This design belongs to one of your orders and cannot be deleted. | An order (past or current) uses this design, so we keep the print file. | Nothing. The design stays in your library. Rename your next version to tell them apart. |
| This design is still linked to a store listing. Unlink it under Store Connections โ Listings first. | A product on one of your stores still sells this design. | Go to Store Connections โ Orders โ Listings, click Unlink on its rows, then delete. Remove the product from your store too, or buyers could order a design we no longer have. |
Empty library
If you have no designs yet the page says You have no saved designs yet. Customize a product and click Save Design to add one here. Click Browse products, open a blank, design it and save.
Merchant ยท Selling
Products: default design and per-variant overrides
Build hub products with a default design, override it per variant and publish them.
A product on the Products tab is one blank plus a default design, with optional per-variant designs, that you can publish to any connected store and keep changing afterwards.
Open My Account โ Store Connections โ Products. We call these hub products: they live on CeePrinto first, before they exist on any store, so one product can be published to several stores and stay in sync.
| Word | Meaning |
|---|---|
| Blank | The unprinted item we print on, for example a T-shirt, with its Size and Color options. |
| Variant | One Size and Color combination of the blank, for example Medium / Black. |
| Default design | The design every variant uses unless you say otherwise. |
| Override | A different design given to one or more variants. |
| Mockup | The product photo with your design on it that we render for your store. |
Create a product from a blank
-
Open the create form
On Products, click Create a product from a blank.
-
Enter the blank ID
Type the Blank product ID: the number of the blank on ceeprinto.com (ask Support if you cannot find it). Click Create product.
-
Result
The product is created with every variant of the blank and opens straight away with the message to set a default design. It appears in the product list with the columns Product, Blank, Variants and Stores (Not published until you publish). Click Manage to return to it.
If you see This blank has no customize options configured, so the designer cannot open for it. the blank is not set up for designing yet. Pick another blank or contact Support.
Set the default design
At the top of the product, choose a saved design in Default design and click Set default. Every variant marked Inherits default now uses it.
Give some variants their own design
-
Tick the variants
In the variant table, tick the rows you want to change, for example all Black sizes.
-
Pick the design
Under Assign design to selected, choose a saved design and click Assign. Those rows now say Overrides default and show a dot next to the variant name.
-
Go back to the default
To undo an override, tick the rows, choose Inherit default and click Assign.
Each row has an Edit link that opens the editor for that variant with its current design loaded, so you can tweak the artwork.
Publish
The Publish box is at the bottom of the product. It only appears once you have an active store; otherwise it says Connect a store to publish this product.
-
Choose the store
Pick the Store.
-
Choose the pricing
Pick Pricing (see the table below). For Flat price, type the price in the box next to it.
-
Publish
Click Publish. The page shows Publish progress: one line per design with its status and a
previews N/Mcount of mockups rendered so far. The store app picks the job up, creates the product and uploads the images.
| Pricing option | Value sent to the store app | Store price |
|---|---|---|
| Match blank price | blank |
Each variant starts at the blank's price on CeePrinto. Change it in your store admin afterwards. |
| Flat price | flat |
Every variant gets the one price you type. |
Create, update and link
| Mode | When it happens | Result on your store |
|---|---|---|
create |
First publish of this product to that store. | A new store product with every enabled variant. |
update |
You publish again to a store that already has this product. | The existing store product is edited in place; nothing is duplicated. |
link |
Only through the API or a store app, not on this screen. | Attaches the design to a product that already exists on your store. See Publishing (API). |
Each progress line shows the mode (create or update) and a status.
| Status | Meaning |
|---|---|
pending |
Waiting for the store app to pick it up. |
processing |
Some designs are done, others are still running. |
completed |
The store confirmed the product was created or updated. |
failed |
The store app reported an error, shown after the status. Fix it and publish again. |
What changes after publishing
You do not need to republish to change designs. When you use Set default or Assign on a product that is already on one or more stores, the change goes to every store it is on, and the store apps swap the design for the variants involved. The buyer always gets the design shown in their variant.
Republish (click Publish again, which runs in update mode) when you want the store to refresh its images or prices.
Mockups and when images appear
Mockups are rendered in the background, one per variant and print side, when you publish. The store app waits until they are ready before uploading them. Watch the previews N/M count in Publish progress: when N equals M, all previews are rendered.
Troubleshooting: product published with no images
| Check | Fix |
|---|---|
Publish progress still shows pending or processing. |
Wait. Large blanks (many sizes and colours) take a few minutes. The page updates itself. |
previews N/M has N lower than M for a long time. |
Rendering is queued. Leave the page and come back later, then publish again so the store picks up the finished images. |
Status is failed with a reason. |
Read the reason after the status, fix it (for example reconnect the store), then publish again. |
Images are still missing after a completed publish. |
Click Publish again to the same store. It runs in update mode and re-uploads. If it still fails, open a ticket in Support with the job number shown as #ID. |
Merchant ยท Selling
Connected stores
See, manage and disconnect the stores linked to your account.
The Stores tab lists every store connected to your CeePrinto account and lets you rename, disconnect or reconnect each one.
Open My Account โ Store Connections โ Stores. The table is titled Your connected stores. A store appears here automatically when you paste a connection code into the Shopify app or CeePrinto Connect (see Get Started). If nothing is connected you will see No stores are connected yet. Connect one from your Shopify or WooCommerce app.
The table
| Column | What it shows |
|---|---|
| Store | The store name, or Unnamed store. You can rename it. |
| Channel | Which kind of store it is, for example shopify or woocommerce. |
| Identifier | The store's own address or ID, for example my-brand.myshopify.com. |
| Connected | The date the store was first connected. |
| Status | Active or Disconnected. |
Statuses
| Status | Publishing to it | Counts as connected on Get Started and the Dashboard |
|---|---|---|
Active |
Yes | Yes |
Disconnected |
No. It is left out of every Store and Target store list. | No |
Actions
| Button | What it does |
|---|---|
| Rename | Type a name in the New name box and click Rename. Only changes the name you see on CeePrinto. |
| Disconnect | Marks the store Disconnected. Shown on active stores. |
| Reconnect | Marks the store Active again. Shown on disconnected stores. |
What Disconnect does and does not do
| Thing | After Disconnect |
|---|---|
| Publishing | Stops. You cannot publish to the store until you reconnect. |
| Listings (the links between your designs and the store's products) | Kept. They still show under Orders โ Listings. |
| Order history and Order Activity | Kept. |
| Products already on your store | Not touched. We do not delete anything on your store. |
| The connection code the store app uses | Still valid. Disconnect does not switch off the key. |
To fully cut a store off, also uninstall the app (or deactivate CeePrinto Connect) on that store, or generate a new code of the same type on Get Started, which switches the old one off. Until then the store app can still send orders.
Moving to a new store
Connect the new store with a fresh code from Get Started, then publish your products to it from Products. Disconnect the old store when you no longer want to publish to it.
Merchant ยท Selling
Cart order options
Ordering for, Ship using, Ship under my brand and Set prices in the cart.
When you order through the ceeprinto.com cart, the Order options box above the cart decides who the order is for, who ships it, and whether it goes out under your brand.
You must be logged in to see Order options. Logged out, the cart shows Please login to your account to view the order options for My Customer and Daraz. Orders that come from your connected store skip the cart; for those see Orders and COD.
Ordering for
| Option | What happens | Choose it when |
|---|---|---|
| Myself | Ships to your address and is invoiced to you. No cash is collected. | Stock, samples, or orders your buyer already paid you for. |
| My Customer (White-label) | We ship straight to your buyer under your brand. The invoice shows your price, never ours. A Customer Price box appears under each item. | Dropshipping to a buyer, usually with cash on delivery. |
Switching back to Myself clears every Customer Price you entered.
Customer Price and Set prices for all items
With My Customer, type your selling price in each item's Customer Price box. Under it, You pay Rs X for this line. reminds you of your cost, so you can see your margin. The price is for the whole cart line, not per unit.
To price several items in one go, click Set prices for all items. A window titled Ordering For My Customer lists every item with a price box.
| Customer Price | Result |
|---|---|
| Blank or 0 on every line | Nothing is collected and no COD fee is charged. |
| Above 0 on at least one line | COD is turned on for the order. A COD Fee of Rs 80 is added to your cart, and a COD to collect row shows the total we will collect from your buyer. |
The collected amount is paid out to you in full 7 days after delivery. See Payouts.
Ship using
| Option | What happens | Choose it when |
|---|---|---|
| Ceeprinto (Easiest) | We pick the carrier, book the shipment and handle tracking. Nothing to upload. | Most orders, and every COD order you want us to collect for. |
| My Shipper / Daraz (Cheaper) | You ship on your own carrier account at your negotiated rates. Our shipping charge is replaced by a drop-off and handling charge. | You already have a courier account or sell on Daraz. |
Carrier and shipping label (My Shipper / Daraz)
-
Pick the carrier at checkout
Under Your shipping carrier, Select carrier: M&P, TCS, Leopards, TRAX or Daraz (Drop-off).
-
Book the shipment yourself
Create the booking in your courier or Daraz account and download the shipping label.
-
Upload the label
Click Upload your shipping label: PDF or image, up to 5MB. Tap to choose a file. You cannot place the order without it (Uploading a Shipper Label is required in order to checkout.).
| Carrier | Note |
|---|---|
| M&P, TCS, Leopards, TRAX | We pack the order, attach your label and hand it to that courier. |
| Daraz | We drop off at Daraz. Set your Daraz drop-off / pickup location to DHA Phase 2, Karachi, Sindh, Pakistan. Watch your email: we tell you when to mark the order Ready to Ship in your Daraz portal. See Read the Daraz FAQ at checkout. |
COD payouts start their 7-day hold only when M&P or TRAX tracking confirms delivery. If you ship on your own courier account, that courier collects any cash and settles with you; ask Support before relying on a CeePrinto COD payout for such an order.
Packaging: Ship under my brand
If you have set up a brand, a Packaging option appears: Ship under my brand, followed by your brand name in brackets. Tick it to put your brand icon on the packaging, hangtag and thank-you card for this order.
No brand yet? The cart shows Set up your brand. On My Account โ Brand, enter a Brand name (the name buyers see on the packaging and courier slip) and a Brand icon (PNG, JPG, GIF or WEBP, under 2MB, square works best), then click Save brand.
COD fee
| Fee | Amount | Charged when |
|---|---|---|
| COD Fee | Rs 80 per order | Any My Customer line has a Customer Price above 0, or you tick Collect COD from my customer on a pending order. |
Merchant ยท Money
Orders and COD From Customer
Track orders from your stores and how cash on delivery collected from customers is handled.
Orders from your connected stores arrive in Order Activity, become CeePrinto orders in Pending payment, and you decide per order whether we collect cash on delivery (COD) from your buyer before you pay us.
Orders reach CeePrinto in two ways:
| Source | Where you see it first | COD set up |
|---|---|---|
| Your connected store (Shopify app, CeePrinto Connect or the API) | Store Connections โ Orders โ Order Activity, then My Account โ Orders | On the order page, with Collect COD from my customer |
| You, in the ceeprinto.com cart | My Account โ Orders | In the cart, with Ordering for set to My Customer. See Cart order options. |
Order Activity
Every order your store sends is first recorded as an intake (an order we have received but not yet turned into a CeePrinto order). Order Activity lists them with the columns Received, External order (your store's order number), Status, WC order (a link to the CeePrinto order) and Detail.
| Status | Meaning | What to do |
|---|---|---|
Received |
The order arrived and is queued. | Nothing. It is processed within a minute or two. |
Processing |
We are matching its items to your products. | Nothing. |
Created |
A CeePrinto order was made. The WC order column links to it. | Open the order and set COD (below). |
Failed |
No order was made. Detail says why. | Fix the cause, then click Retry. |
Ignored |
Reserved for orders we skip on purpose. Current versions do not set it. | Contact Support if you see it. |
Failure reasons and fixes
| Detail text starts with | Cause | Fix |
|---|---|---|
| No listing, design or legacy product matched this line item | The store product the buyer ordered is not linked to a CeePrinto design. | Publish that product from Products (or link it in your store app), check it under Listings, then Retry. |
| WooCommerce product ... no longer exists. | The blank behind the listing was removed from CeePrinto. | Republish the product on a current blank, then Retry. |
| Order has no line items. | Your store sent an empty order. | Check the order in your store; nothing to retry. |
| A line item could not be added to the order. | A temporary error. | Click Retry. |
If any item in an order cannot be matched, no CeePrinto order is created at all, so a successful Retry always produces one complete order. After Retry you see Order re-processed successfully. or Retry failed. Check the reason and try again after fixing it.
Fulfillment Profile
Store orders copy your billing details. Orders โ Fulfillment Profile checks four fields and marks each Set or Missing: Phone, Address line 1, City, Country. Orders are still created when one is missing, but with an incomplete address. Click Edit billing address to fix it.
Order statuses
| Status | Meaning |
|---|---|
Pending payment |
Created and waiting for you. Store orders always start here. This is the only status in which you can change COD. |
Processing |
Paid and accepted. We are preparing it. |
On hold |
Paused, usually waiting for a payment check or for information from you. |
Printing |
In production (CeePrinto status). |
Completed |
Printed and handed to the courier. COD payouts only count completed orders. |
Returned From Customer |
The buyer refused or returned the parcel (CeePrinto status). It appears under Returns. |
Re Shipped |
A returned or failed parcel was sent out again (CeePrinto status). |
Cancelled, Refunded, Failed |
Standard WooCommerce outcomes: stopped, money returned, or payment failed. |
The Dashboard panel Orders waiting on you lists orders that are pending, processing or on hold.
COD From Customer, step by step
COD From Customer means our courier collects cash from your buyer on delivery and we pay that cash out to you. You pay us the order total (print, shipping and fees) as usual.
-
Open the order
Go to My Account โ Orders and open the order in
Pending payment(or click its number in Order Activity). The Cash on delivery panel is at the top. -
Turn COD on
Tick Collect COD from my customer. A COD fee of Rs 80 is added to what you pay us.
-
Check the amounts
Products total is filled in for you: the sum of the buyer prices your store sent for each item. Optionally type your own delivery or handling charge in Additional charges. Total to collect is what the courier collects from your buyer.
-
Save
Click Save COD settings. The order total updates to include the COD fee.
-
Pay the order
Pay the order total from My Account โ Orders. Once paid, the order leaves
Pending paymentand COD can no longer be changed.
Untick Collect COD from my customer and save if your buyer already paid you online. The COD fee is removed and nothing is collected.
Worked example (illustrative prices)
| Line | Amount (PKR) | Who pays whom |
|---|---|---|
| Buyer price for 1 T-shirt (sent by your store) | 2,500 | Buyer โ courier |
| Additional charges you added | 150 | Buyer โ courier |
| Total to collect | 2,650 | Collected on delivery, paid out to you |
| Print and blank cost (example) | 1,200 | You โ CeePrinto |
| Shipping (example) | 250 | You โ CeePrinto |
| COD fee | 80 | You โ CeePrinto |
| Order total you pay | 1,530 | You โ CeePrinto |
| Your margin (2,650 โ 1,530) | 1,120 |
The 2,650 appears on Pay Out and is transferred to your bank 7 days after delivery. Print, blank and shipping prices depend on the product; only the Rs 80 COD fee is fixed.
Tracking
When we book the shipment you get an email with the tracking number and a tracking link. Store apps subscribed to the order.shipped webhook receive the tracking number too and can mark the order shipped on your store. On Pay Out, the Delivered column shows the courier status until delivery is confirmed.
Returns
If a buyer refuses or returns a parcel, the order moves to Returned From Customer and My Account โ Returns appears. Each return shows a Return date and a Keep until date. Returned parcels are discarded or sold if you do not arrange shipment to yourself within 1 month of the return date. Returned orders are not delivered, so nothing is collected or paid out for them.
Merchant ยท Money
Payouts
How payouts are calculated, their statuses and when you get paid.
CeePrinto pays you the full cash collected from your buyers on COD orders, 7 days after the courier confirms delivery, into the bank account saved on My Account โ Pay Out.
Open My Account โ Pay Out. The page header reads Track COD collections and payouts. Funds release 7 days after delivery.
How the payout amount is worked out
You pay CeePrinto the order total (print, blank, shipping and the Rs 80 COD fee) when you pay the order. Nothing is deducted later. The payout is the cash the courier collected from your buyer:
Payout per order = sum of the Customer Price on each item
+ Additional charges you entered on the order
Only orders with COD turned on (Collect COD from my customer, or My Customer with a price in the cart) and in status Completed appear on Pay Out. See the worked example.
How the money flows
The How COD payouts work panel sums it up:
- Your customer pays cash on delivery
- We hold the amount for 7 days after delivery
- Then we transfer it to your bank account
The 7-day hold starts on the day the courier (M&P or TRAX) reports the parcel delivered, not on the day it was shipped.
Payout statuses
| Status shown | Code | Meaning | What to do |
|---|---|---|---|
Missing bank details |
missing_bank |
No bank account is saved, so we cannot pay. Checked before anything else. | Save your bank details (below). |
Awaiting delivery |
awaiting_delivery |
The courier has not confirmed delivery yet. The Delivered column shows the latest courier status. | Wait. If it stays here long after the buyer received it, open a ticket in Support. |
7-day hold |
in_hold |
Delivered; the 7-day hold is running. Shows Pays in N days (date). |
Nothing. |
Ready for payout |
ready |
Hold finished; in the next transfer. Shows Eligible since date. |
Nothing. |
Paid out |
processed |
Transferred. Paid on shows the date. | Check your bank statement. |
The Status filter also has All payouts and Not paid yet (every order that is not Paid out).
The Pay Out page
| Part | What it shows |
|---|---|
| Summary cards | Total COD collected, Still owed, Already paid out, Ready for payout and 7-day hold, each with an amount and an order count. |
| Next payouts | Up to 5 orders closest to their payout date: Eligible now or the date they pay. |
| All orders | Columns Order, Amount, Delivered, Payout due, Status, Paid on. Filter by Status, From and To dates, then Apply or Clear. |
| Bank details | Where you save the account we pay into. |
Add or change bank details
-
Choose your bank
In Bank details, pick your bank in Bank name. Wallets such as SadaPay and NayaPay are in the list.
-
Enter the account number
Type it in Account number. If you use SadaPay, enter your IBAN. Double-check it: wrong details delay payments.
-
Save
Click Save bank details. You see Bank details saved. Orders that were
Missing bank detailsmove to their real status straight away.
Until bank details are saved, Pay Out shows Missing payout information! and the Dashboard shows the Add your bank details alert.
Export
There is no export button on your Pay Out page. The CSV and XLSX bank transfer file is produced by the CeePrinto team when sending payouts. To keep your own records, filter All orders by Paid out and a date range, or ask Support for a statement.
Common questions
| Question | Answer |
|---|---|
| Why is my order not on Pay Out? | It is not Completed yet, or COD was not turned on for it. Orders your buyer paid online never appear. |
| Why is the payout bigger than my profit? | Because it is the whole cash collected. Your costs were paid when you paid the order. |
| The buyer refused the parcel. Do I get anything? | No cash was collected, so there is no payout. The order moves to Returned From Customer. See Returns. |
| Can I change the COD amount after paying? | No. COD can only be changed while the order is Pending payment. |
| How long from delivery to money? | 7 days on hold, then the order is Ready for payout and included in the next transfer. |
Merchant ยท Money
FAQ
Answers to common merchant questions.
Short answers to the questions merchants ask most, each with a link to the full explanation.
How do I connect my Shopify store?
Install the app from Store Connections โ Get Started, click Generate Shopify Code and paste the cp1. code into the app Settings. Get Started
I closed the page before copying my connection code. What now?
Codes are shown once. Click the same generate button again; the new code replaces the old one, so paste it into your store app right away. Get Started
My store app stopped working after I generated a new code. Why?
Generating a code switches off the previous one of the same type. Paste the newest code into the store app. Get Started
Can I use a different design on some sizes or colours?
Yes. On Products, tick those variants, choose a design under Assign design to selected and click Assign. Products
Do I need to republish after changing a design on a product?
No. Changing the default design or a variant override updates every store the product is already on. Products
My product appeared on my store without pictures. What should I do?
Mockups render in the background. Wait until previews N/M is complete in Publish progress, then click Publish again to refresh the images. Products
Why can't I delete a saved design?
It is used by an order, or still linked to a store listing. Unlink it under Listings first; designs used by orders are kept for good. Saved Designs
An order from my store is not in my Orders. Where is it?
Check Order Activity. If it says Failed, read the Detail, fix it (usually a missing listing) and click Retry. Orders and COD
How do I make CeePrinto collect cash from my buyer?
Open the Pending payment order, tick Collect COD from my customer, click Save COD settings, then pay the order. Orders and COD
How much is the COD fee?
Rs 80 per order, added to what you pay us when COD is on. Cart order options
Can I add my own delivery charge for the buyer?
Yes. Enter it in Additional charges on the order; it is collected and paid out with the rest. Orders and COD
When do I get paid?
7 days after the courier confirms delivery the order becomes Ready for payout and is included in the next transfer. Payouts
Why does my payout say Missing bank details?
No bank account is saved. Add it under Bank details on Pay Out and click Save bank details. Payouts
Is anything deducted from my payout?
No. You pay print, shipping and the COD fee when you pay the order; the payout is the full cash collected. Payouts
An order has been Awaiting delivery for weeks. What should I do?
The courier has not confirmed delivery, so the hold never started. Open a ticket in Support with the order number. Payouts
Can orders ship in my own brand's packaging?
Yes. Save a brand on My Account โ Brand, then tick Ship under my brand in the cart. Cart order options
Can I use my own courier or Daraz?
Choose My Shipper / Daraz in the cart, pick the carrier at checkout and upload your shipping label. Cart order options
What happens to a parcel my buyer refused?
It moves to Returned From Customer and appears under Returns. Arrange shipment to yourself within 1 month of the return date or it is discarded or sold. Orders and COD
What happens if I disconnect a store?
You can no longer publish to it; listings and history are kept and nothing is deleted on your store. Regenerate the code to stop the app sending orders too. Connected stores
I want my developer to use the API. Where are the keys?
Create a scoped key on Developer โ API Keys; it is shown once. API keys and webhooks
Still stuck? Open a ticket from My Account โ Support, or use Get help with this order on the order page.