# CeePrinto Documentation

> Full text of https://ceeprinto.com/documentation/. API base URL: https://ceeprinto.com/wp-json/ceeprinto/v2. Machine-readable spec: https://ceeprinto.com/documentation/openapi.yaml

# API

CeePrinto public API v2 for integrators

## API overview

Source: https://ceeprinto.com/documentation/#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](#api-authentication).

### 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](llms.txt) | An index of every section with a one-line summary and a link, in the llmstxt.org format. |
| [llms-full.txt](llms-full.txt) | The whole documentation as one Markdown file. Best for "read this, then write my integration". |
| [openapi.yaml](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](llms-full.txt) or the relevant copied sections.

**For AI assistants**

- Base URL is `https://ceeprinto.com/wp-json/ceeprinto/v2`; all routes in these docs are relative to it.
- Authenticate with `Authorization: Bearer cp_live_<key_id>.<secret>`.
- Use only `ceeprinto/v2`. The `integration/v1`, `app/v1`, `internal/v1` and `ceeprinto/v1` namespaces are deprecated.
- The Shopify app and CeePrinto Connect are clients of this same API; there is no separate Shopify or WooCommerce API.
- The current version is `2.2.0`, reported in the `X-CP-Api-Version` header.
- Money amounts default to PKR.

## Getting started

Source: https://ceeprinto.com/documentation/#api-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`.

1. **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](#api-authentication).
2. **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.

   **Shell**

   ```bash
   export CP_BASE="https://ceeprinto.com/wp-json/ceeprinto/v2"
   export CP_KEY="cp_live_xxxxxxxxxxxxxxxxxxxxxxxx.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
   ```
3. **Call GET /me**

   `/me` needs no scope, so it is the right smoke test for any key.

   **GET /me — curl**

   ```bash
   curl -s "$CP_BASE/me" \
     -H "Authorization: Bearer $CP_KEY"
   ```

   **GET /me — PHP**

   ```php
   <?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";
   ```

   **GET /me — Node**

   ```javascript
   // 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(', ')}`);
   ```

   **GET /me — Python**

   ```python
   # 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.php` is 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.
4. **Read the response**

   **Response 200**

   ```json
   {
     "data": {
       "id": 214,
       "email": "brand@example.pk",
       "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.id` | The merchant id. Store it; it never changes. |
   | `data.api_key.name` | The key's name. `Shopify App` and `WooCommerce Connect` are connection-code keys. |
   | `data.api_key.is_legacy` | `true` for an old 24-character key. |
   | `data.scopes` | What this key may do. Compare with the [scopes table](#api-authentication). |
   | `data.fulfillment_profile.complete` | `false` means billing phone, address, city or country is missing in My Account; `missing` lists them. |
5. **Check what scopes you got**
   If `scopes` lacks `orders:read` (as in the example above, a connection-code key), `GET /orders`, `GET /payouts` and `POST /shipping/quote` will answer 403. Create a key on the API Keys tab with `orders:read` ticked 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. |

**For AI assistants**

- Keys are created in My Account → Store Connections → API Keys and shown once; they cannot be retrieved later.
- Read the base URL and key from `CP_BASE` and `CP_KEY`; never hard-code the key.
- `GET /me` needs no scope and returns `data.id` (the merchant id) and `data.scopes`.
- Node examples use global `fetch` and top-level `await` (Node 18+, `.mjs`); Python uses `requests`; PHP uses plain cURL.
- If `scopes` lacks `orders:read`, order, payout and shipping-quote reads return 403.

## Authentication and scopes

Source: https://ceeprinto.com/documentation/#api-authentication

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

_Accepted credential headers, in the order they are checked_

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

**Both forms work**

```bash
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}` |

**Response 403**

```json
{
  "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.

**Decoding a connection code**

```python
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.

**For AI assistants**

- Send `Authorization: Bearer cp_live_<key_id>.<secret>`; `X-CP-Key` is the fallback, `auth` is legacy only.
- A missing, malformed, unknown or revoked key is 401 `unauthorized`; a missing scope is 403 `forbidden` with `details.required_scope`.
- Scopes: `designs:read`, `products:read`, `listings:read`, `listings:write`, `orders:read`, `orders:write`, `shops:write`, `webhooks:write`. `GET /me` needs none.
- "Shopify App" and "WooCommerce Connect" keys lack `orders:read`, so `GET /orders*`, `/payouts*` and `POST /shipping/quote` are 403 for them.
- A `cp1.` code is base64url JSON `{v, base, key}`, shown once for 2 minutes.
- Legacy 24-character keys hold every scope.
- The secret is shown once and never retrievable; create a new key if it is lost.

## Conventions: envelope, errors, pagination, rate limits

Source: https://ceeprinto.com/documentation/#api-conventions

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`.

**Success**

```json
{
  "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**

```json
{
  "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](#api-errors).

### 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` |

**GET /catalog/products?page=2&per_page=100**

```http
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](#api-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:

**Retry wrapper — curl**

```bash
#!/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
```

**Retry wrapper — PHP**

```php
<?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']);
```

**Retry wrapper — Node**

```javascript
// 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);
}
```

**Retry wrapper — Python**

```python
# 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. |

**For AI assistants**

- Success is `{"data": …, "meta": {…}}` (`meta` optional); failure is `{"error": {"code", "message", "details"?}}`. Branch on `error.code`.
- Paginated lists take `page` and `per_page` (default 20, max 100) and return `meta.total_pages` plus `X-CP-Total`, `X-CP-Total-Pages` and `Link`.
- `GET /shops/{id}/publish-jobs/pending`, `GET /webhooks` and `GET /orders/{id}/events` return a bare array with no `meta`.
- Rate limit is 120 requests per minute per key; on 429 wait `Retry-After` seconds.
- Every response has `X-CP-Request-Id`; log it and include it in support requests.
- Timestamps are RFC 3339 UTC strings or `null`.
- `POST /orders` must send `Idempotency-Key`; a replay answers 200 with `meta.idempotent_replay: true`.

## Core concepts

Source: https://ceeprinto.com/documentation/#api-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

1. The merchant saves a **design** on a **blank** in the Design Configurator.
2. Optionally they compose a **hub product**: default design plus per-variant overrides.
3. A **shop** is connected (`POST /shops`).
4. 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 item `complete`.
5. A buyer orders in the store. The connector submits an **intake** (`POST /orders`) with `customer_price` per line.
6. The hub resolves each line through the listings, creates a Pending payment **WooCommerce order**, and the merchant elects **COD From Customer**.
7. CeePrinto prints and ships; `order.shipped` carries tracking.
8. After delivery and the 7-day hold, the amount appears as a **payout**.

**For AI assistants**

- A blank is CeePrinto's product (`/catalog/products`); a hub product is the merchant's (`/products`). Do not confuse them.
- A hub variant with `design_id: null` inherits `default_design_id`; always read `effective_design_id`.
- `stage_index` 0 is the front; a mockup matrix has one cell per variation × stage.
- Listings map store variants to designs; without a matching listing (or `design_id`) an order line cannot resolve.
- An intake becomes a Pending payment WooCommerce order; `wc_order_id` is null until then.
- `customer_price` is what the buyer pays and is required on every order line.
- Payouts are held 7 days after courier-confirmed delivery; display `payout_status_label` as given.

## Quickstart (curl)

Source: https://ceeprinto.com/documentation/#api-quickstart

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.

1. **Set your base URL and key**

   ```bash
   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"'
   ```
2. **Who am I (200)**

   ```bash
   cp "$CP_BASE/me"
   ```

   Returns your numeric account id (`data.id`), granted `scopes`, and whether your billing details are complete (`fulfillment_profile.complete`).
3. **Connect a store (201)**

   ```bash
   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=5
   ```

   `channel` is a free-form label: `custom` here, `woocommerce` for a Woo plugin, `shopify` for the app. Re-posting the same `external_shop_id` refreshes the shop instead of duplicating it. 409 means another CeePrinto account already owns it.
4. **Browse designs and the catalog (200)**

   ```bash
   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_id` is the blank it was made on. The catalog product lists that blank's `variations` and print `stages`.
5. **Get the design's product images (200)**

   ```bash
   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_status` is `"ready"` (or `"partial"`, skipping cells with `failed: true`) before uploading. `POST` to the same URL is kept for older clients and answers 202 with `queued: 0`.

   | Rule | Why |
   | --- | --- |
   | Upload `image_url` (2048px), never `thumb_url` (240px) | Thumbs are for the hub UI and look blurry in a store. |
   | `stage_index` 0 is the front | Use it as the main product image. |
   | Key cells by `variation_id` + `stage_index` | There is one cell per variation per side. A plain `variation_id → url` map lets the back overwrite the front. |
6. **Link the design to your store product (201)**

   For one variant:

   ```bash
   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`:

   ```bash
   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_sku` are how _your_ store identifies the item; CeePrinto resolves incoming orders back to the design through these listings.
7. **Or publish through a publish job (201) and poll it**

   ```bash
   # 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"}'
   ```

   `mode` defaults to `create`. An unknown mode is 422, so a typo never creates a duplicate product. `link` and `update` require `external_product_id` (422 without). Full walkthrough: [Publish a design to a store](#api-publish).
8. **Submit an order (202)**

   ```bash
   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-Key` header is required | 400 `invalid_request` |
   | `customer_price` on every line (what the buyer pays, collected on COD) | 422 with `details.issues[]`; never defaulted |
   | `shop_id` must be yours | 400 (not 404) |
   | Same `Idempotency-Key` again | 200 with the original intake and `meta.idempotent_replay: true`; nothing new is created, so retrying a timeout is safe |
9. **Watch it become an order (200)**

   ```bash
   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 `failed` with a specific `last_error`: create the missing listing and retry from My Account → Store Connections → Order Activity.
10. **Receive events (201)**

    ```bash
    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](#api-webhooks) for verification code.

**For AI assistants**

- Send `Authorization: Bearer $CP_KEY` and `Content-Type: application/json` on every call.
- Status codes: `POST /shops` 201, `POST …/listings` 201, `…/listings/bulk` 200, `POST /publish-jobs` 201, item `complete`/`fail` 200, `POST /orders` 202 (200 on replay), `POST /webhooks` 201.
- Poll `GET /shops/{id}/publish-jobs/pending` and close every item with `complete` or `fail`.
- Mockups render on request; wait for `store_status: "ready"`, upload `image_url` only, key by `variation_id` + `stage_index`.
- `POST /orders` needs `Idempotency-Key` and `customer_price` per line.
- A new order lands as Pending payment in My Account → Orders.

## Connect a store

Source: https://ceeprinto.com/documentation/#api-connect-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) and `listings: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) |

1. **Register the shop**

   `POST /shops` upserts on (`channel`, `external_shop_id`), so it is safe to call on every connector start-up.

   **POST /shops — curl**

   ```bash
   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"}'
   ```

   **POST /shops — PHP**

   ```php
   <?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 this
   ```

   **POST /shops — Node**

   ```javascript
   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.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}`);
   ```

   **POST /shops — Python**

   ```python
   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}")
   ```

   **Response 201**

   ```json
   {
     "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"
     }
   }
   ```
2. **Store the shop id**
   Save `data.id` (here `5`). You need it for `/shops/{id}/listings`, `/shops/{id}/publish-jobs/pending`, `POST /publish-jobs` and `POST /orders`.
3. **Check the connection**

   ```bash
   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.
4. **Subscribe to events (optional)**
   Subscribe to `design.publish_requested` and `order.shipped` so your store reacts quickly. See [Webhooks](#api-webhooks). Always poll as well; webhooks are best-effort.
5. **Disconnect or reconnect**

   ```bash
   # 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` |

**For AI assistants**

- `POST /shops` needs `shops:write` and upserts on (`channel`, `external_shop_id`); it always answers 201.
- `channel` is free-form, 1 to 32 characters: `custom`, `woocommerce`, `shopify`.
- 409 `conflict` means another account already owns that store.
- Persist `data.id`; it is the `shop_id` for publish jobs, listings and orders.
- `DELETE /shops/{id}` is a soft disconnect answering 200 with the shop, not 204.
- Shopify and WooCommerce merchants connect with a `cp1.` code in the official app or plugin; no custom code is needed.

## Publish a design to a store

Source: https://ceeprinto.com/documentation/#api-publish

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](#api-connect-store)).
- A key with `listings:write`, `listings:read` and `designs: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 |

1. **Create the publish job**

   Send `design_ids` for one or more designs, or `product_id` for a hub product (designs and per-variant composition are derived from it). Answers 201 and fires `design.publish_requested`.

   **POST /publish-jobs — curl**

   ```bash
   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"}'
   ```

   **POST /publish-jobs — PHP**

   ```php
   <?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";
   }
   ```

   **POST /publish-jobs — Node**

   ```javascript
   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`);
   }
   ```

   **POST /publish-jobs — Python**

   ```python
   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")
   ```

   **Other shapes**

   ```bash
   # 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"}'
   ```
2. **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.

   ```bash
   curl -s "$CP_BASE/shops/5/publish-jobs/pending" -H "Authorization: Bearer $CP_KEY"
   ```

   **Response 200**

   ```json
   {
     "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`.
3. **Work out each variant's design**
   | Item has | Read variants from |
   | --- | --- |
   | `product_id_hub` set | `GET /products/{product_id_hub}`: use each variant's `effective_design_id`, skip `enabled: false`, use its `price`/`sku` when set |
   | `product_id_hub: null` | `GET /catalog/products/{product_id}`: every variation uses the item's `design_id` |
4. **Fetch the images**

   ```bash
   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 (`queued` is `0`).

   | Check | Rule |
   | --- | --- |
   | `store_status` | Upload only when `"ready"`. `"partial"` means some cells failed: upload the rest, skip `failed: true`. `"generating"`: wait 5 seconds and GET again. `"unavailable"`: the renderer is off; fail the item. |
   | Which URL | `image_url` (2048px). Never `thumb_url` (240px). |
   | Which side | `stage_index` 0 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_url` and upload the bytes to your store. Retry a failed download once after a few seconds. |
5. **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:

   ```bash
   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. Check `meta.failed`; rows fail independently.
6. **Close the item**

   ```bash
   # 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, `complete` also back-fills `product_id_hub` on 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` |

**For AI assistants**

- Connectors must poll `GET /shops/{id}/publish-jobs/pending`; `design.publish_requested` is best-effort.
- Close every pending item with `POST /publish-jobs/items/{item_id}/complete` or `/fail`.
- `mode` is `create`, `link` or `update`; anything else is 422, and `link`/`update` require `external_product_id`.
- Wait for `store_status: "ready"` before uploading; upload `image_url`, never `thumb_url`.
- Key mockup cells by `variation_id` + `stage_index`; `stage_index` 0 is the front.
- For hub-product items read `GET /products/{product_id_hub}` and use each variant's `effective_design_id`.
- Register one listing per store variant with `external_variant_id` via `POST /shops/{id}/listings/bulk`.

## Submit and track orders

Source: https://ceeprinto.com/documentation/#api-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 needs `orders: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](#api-publish)), 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.

1. **Submit the order**

   A new key answers **202** with `status: "received"`. A background worker then builds the WooCommerce order, usually within seconds.

   **POST /orders — curl**

   ```bash
   curl -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" }
       ]
     }'
   ```

   **POST /orders — PHP**

   ```php
   <?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");
   ```

   **POST /orders — Node**

   ```javascript
   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)' : ''}`);
   ```

   **POST /orders — Python**

   ```python
   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 ""))
   ```

   **Response 202**

   ```json
   {
     "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
     }
   }
   ```
2. **Retry safely**
   | You got | Do |
   | --- | --- |
   | 202 | Store `data.id`. Done. |
   | 200 with `meta.idempotent_replay: true` | This key was already accepted. `data` is the original intake (it may already be `created`). 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.
3. **Follow the intake**

   **Needs orders:read**

   ```bash
   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 |
   | --- | --- |
   | `received` | Accepted and queued. |
   | `processing` | The worker is building the order. `attempts` counts tries. |
   | `created` | WooCommerce order exists: `wc_order_id` set, `order` block present on `GET /orders/{id}`. |
   | `failed` | No order was created. `last_error` says why. |
   | `ignored` | Reserved; not set by the current API. |
4. **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 send `design_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-Key` through the API does not reprocess a failed intake; it returns it. Reprocessing is the Retry button in Order Activity.
5. **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_changed` and `order.shipped` ([Webhooks](#api-webhooks)). `order.shipped` carries `tracking_number`, `tracking_company` and `tracking_url`; if you miss it, `GET /orders/{id}` → `order.tracking` has 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. |

**For AI assistants**

- `POST /orders` requires an `Idempotency-Key` header (400 without); reuse the same key on every retry of the same order.
- New key: 202 with `status: "received"`. Replay: 200 with the original intake and `meta.idempotent_replay: true`.
- `customer_price` is required on every line item and is never defaulted; missing it is 422 with `details.issues[]`.
- Line items resolve by `listing_id`, then `external_variant_id`, then `external_sku`, then `design_id`.
- An unknown `shop_id` on `POST /orders` is 400, not 404.
- Orders are created as Pending payment in the merchant's My Account → Orders, where COD From Customer is elected.
- Reading orders needs `orders:read`, which connection-code keys lack.

## Webhooks

Source: https://ceeprinto.com/documentation/#api-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](#api-webhook-events).

### 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}`. |

1. **Subscribe**

   One subscription per topic. The response contains the signing `secret`; store it with the subscription id.

   ```bash
   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 /webhooks` lists your subscriptions, each with its `secret` (treat the response as sensitive). `DELETE /webhooks/{id}` removes one (204).
2. **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 — curl**

   ```bash
   #!/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"
   ```

   **Verify X-CP-Signature — PHP**

   ```php
   <?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);
   ```

   **Verify X-CP-Signature — Node**

   ```javascript
   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'));
   }
   ```

   **Verify X-CP-Signature — Python**

   ```python
   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](#api-recipes).
3. **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.
4. **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` + `status` for orders, `job_id` / `item_id` for publishes, `product_id` + `variation_id` + `stock_status` for stock.
5. **Keep polling**
   After 5 failed attempts an event is dropped. Poll `GET /shops/{id}/publish-jobs/pending` every 30 to 60 seconds, and re-read `GET /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 |

**For AI assistants**

- Verify `X-CP-Signature: t=<ts>,v1=<hex>` as HMAC-SHA256 of `"<t>.<raw body>"` with the `whsec_` secret, using the raw bytes and a constant-time compare.
- Reject deliveries whose `t` is more than 5 minutes from the current time.
- Answer 2xx within 10 seconds; failures are retried up to 5 attempts with 1, 2, 4 and 8-minute back-off.
- Body is `{topic, data, sent_at}`; the topic is also in `X-CP-Topic`.
- Topics: `order.status_changed`, `order.shipped`, `design.updated`, `design.publish_requested`, `product.stock_changed`, `product.updated`.
- Webhooks are best-effort: also poll `GET /shops/{id}/publish-jobs/pending` and `GET /orders/{id}`.
- Handlers must be idempotent; `order.shipped` is accompanied by a reduced `order.status_changed`.

## Payouts and shipping quotes

Source: https://ceeprinto.com/documentation/#api-payouts-shipping

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 |

1. **List payouts still owed**

   `status` takes `all` (default), `pending` (everything not yet paid out) or one bucket. `date_from` / `date_to` (`YYYY-MM-DD`, inclusive) filter on order creation date.

   **GET /payouts — curl**

   ```bash
   curl -s "$CP_BASE/payouts?status=pending&date_from=2026-09-01&per_page=100" \
     -H "Authorization: Bearer $CP_KEY"
   ```

   **GET /payouts — PHP**

   ```php
   <?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']);
   }
   ```

   **GET /payouts — Node**

   ```javascript
   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}`);
   }
   ```

   **GET /payouts — Python**

   ```python
   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']}")
   ```
2. **Show the totals**

   ```bash
   curl -s "$CP_BASE/payouts/summary" -H "Authorization: Bearer $CP_KEY"
   ```

   **Response 200**

   ```json
   {
     "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.
3. **Quote shipping**

   ```bash
   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 `city` or a `shipping_address` object (only its `city` is read). Missing city is 400. The same rate is added as the shipping line when `POST /orders` creates the WooCommerce order.

   _Defaults; CeePrinto can change the table_

   | City (case-insensitive) | Default rate |
   | --- | --- |
   | `Karachi`, `KHI` | 200 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. |

**For AI assistants**

- `GET /payouts`, `GET /payouts/summary` and `POST /shipping/quote` all require `orders:read`; connection-code keys get 403.
- `/payouts` `status` is `all`, `pending`, `processed`, `ready`, `in_hold`, `awaiting_delivery` or `missing_bank`; unknown values fall back to `all`.
- `/payouts/summary` is unfiltered and cached 15 minutes; `meta.cached` tells you which.
- Display `payout_status_label` verbatim; the hold is 7 days after delivery.
- Render a pay action only when `payment_url` is non-null.
- Shipping quotes are PKR flat rates keyed by city: 200 for Karachi, 250 elsewhere by default.

## Recipes: full scripts

Source: https://ceeprinto.com/documentation/#api-recipes

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](#api-recipe-publish) | `CP_BASE`, `CP_KEY`, `CP_SHOP_ID`, `CP_DESIGN_ID` | `listings:write`, `listings:read`, `designs:read`, `products:read` |
| [Verify a webhook](#api-recipe-webhook) | `CP_WEBHOOK_SECRET` (`whsec_…`) | None (receiver) |
| [Submit an order with idempotent retry](#api-recipe-order) | `CP_BASE`, `CP_KEY`, `CP_SHOP_ID`, `CP_EXTERNAL_SHOP_ID` | `orders:write` (+ `orders:read` for the status check) |
| [Sync catalog and stock](#api-recipe-catalog) | `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.

**Publish and poll — curl**

```bash
#!/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
```

**Publish and poll — PHP**

```php
<?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 — Node**

```javascript
// 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 — Python**

```python
# 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.

**Verify a webhook — curl**

```bash
#!/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}"
```

**Verify a webhook — PHP**

```php
<?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']);
}
```

**Verify a webhook — Node**

```javascript
// 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'));
```

**Verify a webhook — Python**

```python
# 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.

**Submit an order with idempotent retry — curl**

```bash
#!/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
```

**Submit an order with idempotent retry — PHP**

```php
<?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 an order with idempotent retry — Node**

```javascript
// 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 an order with idempotent retry — Python**

```python
# 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.

**Sync catalog and stock — curl**

```bash
#!/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"
```

**Sync catalog and stock — PHP**

```php
<?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 and stock — Node**

```javascript
// 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 and stock — Python**

```python
# 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")
```

**For AI assistants**

- Scripts read `CP_BASE` and `CP_KEY` from the environment; never hard-code keys.
- Publish flow: `POST /publish-jobs` → poll `GET /shops/{id}/publish-jobs/pending` → wait for `store_status` `ready`/`partial` → `POST …/listings/bulk` → `complete`, or `fail` on any error.
- Mockup cells are keyed by `variation_id` + `stage_index`; upload `image_url`, never `thumb_url`.
- Webhook receivers verify HMAC-SHA256 of `"<t>.<raw body>"` on raw bytes with a constant-time compare and a 300-second tolerance, then answer 2xx before doing work.
- Order retries reuse the same `Idempotency-Key` (`<external_shop_id>:<order number>`); retry only timeouts, network errors, 429 and 5xx.
- Pagination follows `Link` `rel="next"` with `per_page=100` until it is absent.
- On 429 sleep for `Retry-After` seconds before retrying.

## Endpoint reference

Source: https://ceeprinto.com/documentation/#api-endpoints

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](openapi.yaml).

_Routes per group_

| Group | Routes | Scopes |
| --- | --- | --- |
| [Me](#api-ref-tag-me) | 1 | `none` |
| [Catalog](#api-ref-tag-catalog) | 2 | `products:read` |
| [Products](#api-ref-tag-products) | 5 | `listings:read`, `listings:write` |
| [Designs](#api-ref-tag-designs) | 4 | `designs:read` |
| [Shops](#api-ref-tag-shops) | 4 | `listings:read`, `shops:write` |
| [Listings](#api-ref-tag-listings) | 6 | `listings:read`, `listings:write` |
| [Publish jobs](#api-ref-tag-publish-jobs) | 5 | `listings:write`, `listings:read` |
| [Orders](#api-ref-tag-orders) | 4 | `orders:read`, `orders:write` |
| [Payouts](#api-ref-tag-payouts) | 2 | `orders:read` |
| [Shipping](#api-ref-tag-shipping) | 1 | `orders:read` |
| [Webhooks](#api-ref-tag-webhooks) | 3 | `webhooks:write` |

### Me

The account and API key behind the credential.

### GET `/me`

- Scope: none
- Status: 200

The account behind the key.

**Response 200**

```json
{
  "data": {
    "id": 214,
    "email": "brand@example.pk",
    "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**

```bash
curl -s "$CP_BASE/me" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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.id` as the merchant id. It is the numeric WordPress user id and never changes.
- `api_key.key_id` is the public 24-character id (the part between `cp_live_` and the dot). For a legacy key it is masked except for the last 4 characters.
- `fulfillment_profile.complete` is `false` until 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](#api-errors).

### Catalog

Blank WooCommerce products that designs are printed on, with stock.

### GET `/catalog/products`

- Scope: `products:read`
- Status: 200

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

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/catalog/products?search=tee&per_page=50" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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_pages` or follow the `Link` header.
- `stock_quantity` is `null` when WooCommerce is not managing stock; use `in_stock`.
- Error statuses: 401, 403, 429. See [Errors](#api-errors).

### GET `/catalog/products/{id}`

- Scope: `products:read`
- Status: 200

One blank with attributes, variations, stock and print config.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Yes | Catalog (blank) product id. |

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/catalog/products/37" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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[].index` matches `stage_index` in the mockup matrix. Index `0` is the front.
- Subscribe to `product.stock_changed` to keep stock in sync instead of polling this.
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### Products

Hub products: a blank plus a default design and per-variant overrides, publishable to any connected store.

### GET `/products`

- Scope: `listings:read`
- Status: 200

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

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/products" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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_page` is honoured since 2.2.0.
- Error statuses: 401, 403, 429. See [Errors](#api-errors).

### POST `/products`

- Scope: `listings:write`
- Status: 201

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`. |

**Request body**

```json
{
  "blank_product_id": 37,
  "title": "Karachi Skyline Tee",
  "default_design_id": 11,
  "price_mode": "flat",
  "flat_price": 2500
}
```

**Response 201**

```json
{
  "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**

```bash
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**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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 with `variation_id: 0`.
- An unknown or missing `blank_product_id` is **400** `invalid_request`, not 422.
- Error statuses: 400, 401, 403, 429, 500. See [Errors](#api-errors).

### GET `/products/{id}`

- Scope: `listings:read`
- Status: 200

One hub product's full composition.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Yes | Hub product id. |

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/products/42" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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`, not `design_id`: `design_id: null` means the variant inherits `default_design_id`.
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### PATCH `/products/{id}`

- Scope: `listings:write`
- Status: 200

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`. |

**Request body**

```json
{
  "default_design_id": 12,
  "status": "active"
}
```

**Response 200**

```json
{
  "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**

```bash
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**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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_id` re-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.updated` fires.
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### PATCH `/products/{id}/variants/{variation_id}`

- Scope: `listings:write`
- Status: 200

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 |  |

**Request body**

```json
{
  "design_id": 12,
  "price": 2800,
  "sku": "TEE-BLK-L"
}
```

**Response 200**

```json
{
  "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**

```bash
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**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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: 0` to clear an override and inherit the product default again.
- Projects onto published stores and fires `product.updated`, like `PATCH /products/{id}`.
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### Designs

The merchant's saved designs and their per-variation, per-side mockups.

### GET `/designs`

- Scope: `designs:read`
- Status: 200

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

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/designs?product_id=37&per_page=20" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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_id` is the blank the design was made on.
- Error statuses: 401, 403, 429. See [Errors](#api-errors).

### GET `/designs/{id}`

- Scope: `designs:read`
- Status: 200

One design owned by the key's account.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Yes | Design id. |

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/designs/11" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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_url` is the print file; `preview_url` is a small preview.
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### GET `/designs/{id}/variation-mockups`

- Scope: `designs:read`
- Status: 200

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

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/designs/11/variation-mockups" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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_index` 0 is the front.
- Cells render on request at the returned URLs; nothing is queued, so `queued` is always `0`.
- Wait for `store_status: "ready"` (or `"partial"`, skipping cells with `failed: true`) before uploading.
- Upload `image_url` (2048px) to stores. Never upload `thumb_url` (240px).
- If the design configurator is inactive this answers 200 with `status: "unavailable"` and empty `items`.
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### POST `/designs/{id}/variation-mockups`

- Scope: `designs:read`
- Status: 202

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

**Response 202**

```json
{
  "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**

```bash
curl -s -X POST "$CP_BASE/designs/11/variation-mockups" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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_error` when 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](#api-errors).

### Shops

Connected storefronts (Shopify, WooCommerce, custom).

### GET `/shops`

- Scope: `listings:read`
- Status: 200

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

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/shops" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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](#api-errors).

### POST `/shops`

- Scope: `shops:write`
- Status: 201

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

**Request body**

```json
{
  "channel": "custom",
  "external_shop_id": "my-store-01",
  "name": "My Store"
}
```

**Response 201**

```json
{
  "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**

```bash
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**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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** `conflict` when 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](#api-errors).

### GET `/shops/{id}`

- Scope: `listings:read`
- Status: 200

One connected storefront.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Yes | Shop id (from POST /shops). |

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/shops/5" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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](#api-errors).

### DELETE `/shops/{id}`

- Scope: `shops:write`
- Status: 200

Disconnect a storefront (soft).

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Yes | Shop id (from POST /shops). |

**Response 200**

```json
{
  "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**

```bash
curl -s -X DELETE "$CP_BASE/shops/5" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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 /shops` with the same `external_shop_id` reconnects.
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### Listings

Links between a design and a store product/variant, used to resolve incoming orders.

### GET `/shops/{id}/listings`

- Scope: `listings:read`
- Status: 200

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

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/shops/5/listings" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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](#api-errors).

### POST `/shops/{id}/listings`

- Scope: `listings:write`
- Status: 201

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

**Request body**

```json
{
  "design_id": 11,
  "product_id": 37,
  "variation_id": 101,
  "external_product_id": "9001",
  "external_variant_id": "9001-1",
  "external_sku": "TEE-L"
}
```

**Response 201**

```json
{
  "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**

```bash
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**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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 of `design_id`, `product_id` (else 400).
- Upserts on `external_variant_id` only. Re-posting a row without one creates a new listing every time.
- Error statuses: 400, 401, 403, 404, 429, 500. See [Errors](#api-errors).

### DELETE `/shops/{id}/listings`

- Scope: `listings:write`
- Status: 204

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**

```bash
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**

```php
<?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";
```

**Node**

```javascript
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');
```

**Python**

```python
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_id` is 400.
- Error statuses: 400, 401, 403, 404, 429. See [Errors](#api-errors).

### POST `/shops/{id}/listings/bulk`

- Scope: `listings:write`
- Status: 200

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

**Request body**

```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"
    }
  ]
}
```

**Response 200**

```json
{
  "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**

```bash
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**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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.failed` and `meta.errors[].index`.
- One row per store variant, each with its own `design_id`.
- Error statuses: 400, 401, 403, 404, 429. See [Errors](#api-errors).

### PATCH `/shops/{id}/listings/{listing_id}`

- Scope: `listings:write`
- Status: 200

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

**Request body**

```json
{
  "design_id": 12
}
```

**Response 200**

```json
{
  "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**

```bash
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**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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 and `product.updated` fires.
- A `design_id` you do not own is 404.
- Error statuses: 400, 401, 403, 404, 429. See [Errors](#api-errors).

### DELETE `/shops/{id}/listings/{listing_id}`

- Scope: `listings:write`
- Status: 204

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**

```bash
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "$CP_BASE/shops/5/listings/89" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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";
```

**Node**

```javascript
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');
```

**Python**

```python
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](#api-errors).

### Publish jobs

Hub-driven publishing. Connectors poll pending items and close them out.

### POST `/publish-jobs`

- Scope: `listings:write`
- Status: 201

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

**Request body**

```json
{
  "shop_id": 5,
  "design_ids": [
    11
  ],
  "price_mode": "blank",
  "mode": "create"
}
```

**Response 201**

```json
{
  "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**

```bash
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**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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_ids` or `product_id` (a hub product). Fires `design.publish_requested`.
- Status order: unknown `mode` 422; shop not yours 404; product not yours 404; nothing to publish 400; `link`/`update` without `external_product_id` 422; every design already published 400.
- In `create` mode, 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](#api-errors).

### GET `/publish-jobs/{id}`

- Scope: `listings:read`
- Status: 200

One publish job with its items.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Yes | Publish job id. |

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/publish-jobs/3" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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 `status` follows its items: `pending`, `processing`, `completed`, `failed`.
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### GET `/shops/{id}/publish-jobs/pending`

- Scope: `listings:read`
- Status: 200

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). |

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/shops/5/publish-jobs/pending" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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: `data` is a bare array and there is no `meta`.
- Connectors **must** poll this. Items carry `mode`, `external_product_id`, `shop_id`, `price_mode` and `flat_price`, so a polled item is handled exactly like a webhook one.
- For hub-product items (`product_id_hub` set), read the per-variant designs from `GET /products/{id}`.
- Close every item with `complete` or `fail`, or it stays pending forever.
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### POST `/publish-jobs/items/{item_id}/complete`

- Scope: `listings:write`
- Status: 200

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

**Request body**

```json
{
  "external_product_id": "9001"
}
```

**Response 200**

```json
{
  "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**

```bash
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**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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_hub` back-filled.
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### POST `/publish-jobs/items/{item_id}/fail`

- Scope: `listings:write`
- Status: 200

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." |

**Request body**

```json
{
  "error": "variant matrix rejected"
}
```

**Response 200**

```json
{
  "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**

```bash
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**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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

- `error` is shown to the merchant in My Account. Defaults to `Publish failed.`
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### Orders

Order intake (idempotent, queued) and the resulting WooCommerce order with COD fields.

### GET `/orders`

- Scope: `orders:read`
- Status: 200

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

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/orders?per_page=20" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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_id` is set once the WooCommerce order exists.
- Error statuses: 401, 403, 429. See [Errors](#api-errors).

### POST `/orders`

- Scope: `orders:write`
- Status: 202 (200 on replay)

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`. |

**Request body**

```json
{
  "shop_id": 5,
  "external_order_id": "1001",
  "currency": "PKR",
  "shipping_address": {
    "first_name": "Bilal",
    "last_name": "Ahmed",
    "phone": "03001234567",
    "email": "bilal@example.pk",
    "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"
    }
  ]
}
```

**Response 202**

```json
{
  "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**

```bash
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":"bilal@example.pk","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**

```php
<?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' => 'bilal@example.pk',
      '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']);
```

**Node**

```javascript
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: 'bilal@example.pk',
      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);
```

**Python**

```python
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": "bilal@example.pk",
            "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-Key` header 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 and `meta.idempotent_replay: true`; nothing new is created.
- Line items resolve in this order: `listing_id`, then `external_variant_id`, then `external_sku` (through your listings), then `design_id`.
- `customer_price` is required on every line (422 with `details.issues[]` if missing). The hub sums `customer_price` across lines and does not multiply it by `quantity`; check `cod_collection_total` on `GET /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](#api-errors).

### GET `/orders/{id}`

- Scope: `orders:read`
- Status: 200

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`. |

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/orders/1" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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

- `order` is present only once the WooCommerce order exists (`status: "created"`).
- `tracking` is `null` until a courier booking exists. Use it to reconcile a missed `order.shipped`.
- `cod_collection_total` = `customer_price_total` + `additional_cod_from_customer`.
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### GET `/orders/{id}/events`

- Scope: `orders:read`
- Status: 200

Intake event history.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Yes | Intake id returned by `POST /orders`. |

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/orders/1/events" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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 `failed` event carries `reason`.
- The `processing` event has `attempts` and no `at`.
- Error statuses: 401, 403, 404, 429. See [Errors](#api-errors).

### Payouts

Read-only COD payout ledger (money CeePrinto owes the brand).

### GET `/payouts`

- Scope: `orders:read`
- Status: 200

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

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/payouts?status=pending&per_page=50" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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_url` is non-null only when `needs_payment` is 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](#api-errors).

### GET `/payouts/summary`

- Scope: `orders:read`
- Status: 200

Payout totals for the merchant.

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/payouts/summary" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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.cached` says which you got. Always the whole ledger (no filters).
- Requires `orders:read`.
- Error statuses: 401, 403, 429. See [Errors](#api-errors).

### Shipping

Shipping rate quotes (PKR).

### POST `/shipping/quote`

- Scope: `orders:read`
- Status: 200

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

**Request body**

```json
{
  "city": "Karachi"
}
```

**Response 200**

```json
{
  "data": {
    "city": "Karachi",
    "total": 200,
    "currency": "PKR",
    "method_id": "flat_rate",
    "method_title": "Flat Rate"
  }
}
```

**curl**

```bash
curl -s -X POST "$CP_BASE/shipping/quote" \
  -H "Authorization: Bearer $CP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"city":"Karachi"}'
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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](#api-errors).

### Webhooks

Signed outbound event subscriptions.

### GET `/webhooks`

- Scope: `webhooks:write`
- Status: 200

The account's webhook subscriptions.

**Response 200**

```json
{
  "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**

```bash
curl -s "$CP_BASE/webhooks" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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](#api-errors).

### POST `/webhooks`

- Scope: `webhooks:write`
- Status: 201

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

**Request body**

```json
{
  "topic": "order.status_changed",
  "target_url": "https://my-store-01.pk/webhooks/ceeprinto"
}
```

**Response 201**

```json
{
  "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**

```bash
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**

```php
<?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']);
```

**Node**

```javascript
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);
```

**Python**

```python
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 verify `X-CP-Signature`.
- Error statuses: 400, 401, 403, 429. See [Errors](#api-errors).

### DELETE `/webhooks/{id}`

- Scope: `webhooks:write`
- Status: 204

Remove a subscription.

#### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | Yes | Webhook subscription id. |

**curl**

```bash
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE "$CP_BASE/webhooks/9" \
  -H "Authorization: Bearer $CP_KEY"
```

**PHP**

```php
<?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";
```

**Node**

```javascript
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');
```

**Python**

```python
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-errors).

**For AI assistants**

- Base URL is `https://ceeprinto.com/wp-json/ceeprinto/v2`; send `Authorization: Bearer cp_live_<key_id>.<secret>` on every call.
- Success bodies are `{data, meta}`; errors are `{error: {code, message, details}}`. Branch on `error.code`, not the message.
- `POST /orders` requires an `Idempotency-Key` header and `customer_price` on every line item; it answers 202, or 200 with `meta.idempotent_replay` on replay.
- `GET /shops/{id}/publish-jobs/pending` and `GET /webhooks` are not paginated; every other list takes `page`/`per_page` (max 100).
- Mockup cells: key by `variation_id` + `stage_index` (0 is the front), wait for `store_status: "ready"`, upload `image_url`, never `thumb_url`.
- `DELETE /shops/{id}` answers 200 with the disconnected shop; the other DELETE routes answer 204 with no body.
- Connection-code keys lack `orders:read`: `GET /orders*`, `/payouts*` and `POST /shipping/quote` return 403.

## Webhook events reference

Source: https://ceeprinto.com/documentation/#api-webhook-events

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 |

**Body (pretty-printed)**

```json
{
    "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"
}
```

**Signed delivery (raw bytes)**

```http
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`:

**Reduced order.status_changed**

```http
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 |

**Body (pretty-printed)**

```json
{
    "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"
}
```

**Signed delivery (raw bytes)**

```http
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` |

**Body (pretty-printed)**

```json
{
    "topic": "design.updated",
    "data": {
        "design_id": 11,
        "status": "saved"
    },
    "sent_at": "2026-09-29T10:15:00+00:00"
}
```

**Signed delivery (raw bytes)**

```http
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` |

**Body (pretty-printed)**

```json
{
    "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"
}
```

**Signed delivery (raw bytes)**

```http
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 |

**Body (pretty-printed)**

```json
{
    "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"
}
```

**Signed delivery (raw bytes)**

```http
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 |

**Body (pretty-printed)**

```json
{
    "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"
}
```

**Signed delivery (raw bytes)**

```http
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

_A non-2xx status or a timeout counts as a failure_

| 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 |

**For AI assistants**

- Every delivery body is `{topic, data, sent_at}` with headers `X-CP-Topic` and `X-CP-Signature: t=<ts>,v1=<hex>`.
- The signature is HMAC-SHA256 over `"<t>.<raw body>"`; verify against raw bytes because slashes are escaped in the JSON.
- `order.shipped` fires on completion with a courier booking and is paired with a reduced `order.status_changed` (only `wc_order_id`, `status`).
- `design.publish_requested` from the Get Started tab lacks `mode`, `product_id_hub` and `variants`; treat as `create`.
- `product.stock_changed` has `variation_id: null` for simple products and `stock_quantity: null` when stock is unmanaged.
- `product.updated` carries the new per-variant `design_id`s and the `shop_ids` to update.
- Up to 5 attempts at 0, 1, 2, 4 and 8-minute intervals; then the event is dropped.

## Errors and troubleshooting

Source: https://ceeprinto.com/documentation/#api-errors

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](#api-endpoints). |
| 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

**Node**

```javascript
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 |

**For AI assistants**

- Branch on `error.code`: `invalid_request` 400, `unauthorized` 401, `forbidden` 403, `not_found` 404, `conflict` 409, `unprocessable_entity` 422, `rate_limited` 429, `server_error` 500.
- WordPress codes `rest_no_route` (404) and `rest_missing_callback_param`/`rest_invalid_param` (400) can also appear in the same envelope.
- Retry only 429 (after `Retry-After`), 5xx and network errors; never retry 4xx unchanged.
- A 403 lists `details.required_scope`; connection-code keys lack `orders:read`.
- Another account's resource is 404, not 403.
- A failed intake is reprocessed from My Account → Store Connections → Order Activity, not by replaying the `Idempotency-Key`.
- Log `X-CP-Request-Id` for every call and quote it in support tickets.

## Changelog

Source: https://ceeprinto.com/documentation/#api-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](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.txt), [llms-full.txt](llms-full.txt) and a public [openapi.yaml](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.

**For AI assistants**

- Current version is 2.2.0 (`X-CP-Api-Version`).
- `POST /designs/{id}/variation-mockups` returns 202 with `queued: 0`; mockups render on request.
- `GET /products` honours `per_page` up to 100 since 2.2.0.
- `POST /products` with a bad blank and `POST /publish-jobs` without designs return 400, not 422.
- Legacy namespaces are deprecated with no sunset date yet; build on `ceeprinto/v2`.

# Editor

How to design products in the CeePrinto design editor

## Editor overview

Source: https://ceeprinto.com/documentation/#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

_Main parts of the editor_

| 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](https://ceeprinto.com/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](#editor-print-sides).

### What happens after "Add to Cart"

1. The editor checks that nothing sticks out of the dashed print area and warns you if it does.
2. It creates a preview and a high-resolution print file (300 DPI) for every side you used, and uploads them.
3. It saves your design to the store and adds the product to your cart.
4. The cart shows the item with a "Custom design" line and, when sides carry a price, a "Customization fee" line.

**For AI assistants**

- The editor opens from the "Customize" button on a product page, or directly at `/product/{slug}/design/`. Store owners can rename that button.
- For products with options (variations) the customer must choose an option before the "Customize" button becomes active.
- Layout: top bar, left "Design Tools" sidebar with tabs "Product", "Images", "Text"; canvas in the middle; "Print Sides" panel on the right. Layers live in the "Product" tab.
- Only content inside the dashed design area is printed.
- Price is per side used: each printed side adds its own price; empty sides add nothing.
- The editor has no zoom controls, shapes or clip-art, ready-made templates, sharing or SVG export. Do not tell users these exist.

## Your first design

Source: https://ceeprinto.com/documentation/#editor-getting-started

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.

1. **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.
2. **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".
3. **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").
4. **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.
5. **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".
6. **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".
7. **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.
8. **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.
9. **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".
10. **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".

**For AI assistants**

- Order of the basic flow: choose product option, "Customize", pick side in "Print Sides", "Images" tab upload, "Text" tab "Add Text Layer", keep inside the dashed box, "Add to Cart".
- The "Customize" button is disabled on variable products until an option is chosen.
- Images are only placed on the canvas after the upload has fully finished.
- Guests can add a design to the cart; saving a design for later ("Save Design") requires signing in.
- Before saving or adding to cart the editor warns about layers outside the print area and offers "Fix automatically" or "Continue anyway".

## Print sides and design areas

Source: https://ceeprinto.com/documentation/#editor-print-sides

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

1. **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.
2. **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".
3. **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

_Side card contents_

| 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:

_Overflow warning_

| 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 side pricing (illustrative amounts; see the product's Pricing Chart for real prices)_

| 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

_What CeePrinto prints from_

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

**For AI assistants**

- Sides are chosen in the "Print Sides" panel; the first side is usually "Front". Each side has its own layers.
- Only content inside the dashed design area is printed. Sides with several areas show a "Design Area" drop-down in the left sidebar.
- Overflow warning windows: "Some layers will be cropped" or "Some layers won't print", with "Fix automatically" and "Continue anyway". The canvas hint also offers "Fit to area".
- Price = product price + the price of every side that has at least one layer; empty sides are free. Shown in the cart as "Customization fee".
- Print files are generated at 300 DPI for the DTF print size (inches) CeePrinto sets for each side.

## Text

Source: https://ceeprinto.com/documentation/#editor-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

1. **Open the "Text" tab**
   In the left "Design Tools" sidebar, click "Text", then "Add Text Layer".
2. **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.
3. **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

_Fields in the Add New Text window_

| 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

_13 fonts in the font picker_

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

_Text toolbar_

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

**For AI assistants**

- Text is added from the "Text" tab via "Add Text Layer", which opens the "Add New Text" window; confirm with "Add Text".
- Text size range is 8 to 200 px; letter spacing -5 to 50 px; line height 0.8 to 3.
- Styles: "Bold", "Italic", "Underline", "Strikethrough"; alignment left, centre, right.
- Fonts: Inter (default), Roboto, Open Sans, Lato, Montserrat, Poppins, Playfair Display, Oswald, Source Sans 3, Nunito, plus Arial, Times New Roman and Helvetica marked "(System)".
- Selected text is edited from the toolbar: "Edit text…", font, "Font size", "Text color", styles, alignment, "Bring forward", "Send backward".
- There is no text outline, shadow, curved text or text effect feature. Do not suggest one.

## Images and uploads

Source: https://ceeprinto.com/documentation/#editor-images

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

1. **Open the "Images" tab**
   In the left sidebar, click "Images".
2. **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".
3. **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.

_Upload limits_

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

_DPI badge thresholds_

| 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).

_Image toolbar_

| 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

1. **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."
2. **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.
3. **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.

**For AI assistants**

- Upload from the "Images" tab: "Drop images here or browse". Recommended types: PNG, JPG, WebP (GIF also accepted). Maximum file size: 200 MB.
- SVG uploads are accepted. Each SVG is sanitized on upload (scripts and external references are stripped); recommend SVG for logos and vector art.
- Large uploads are sent in pieces, retry automatically up to 4 times, pause while offline, and can be resumed with "Retry" without re-sending finished pieces.
- DPI badge: "Good" at 300 DPI or more; "OK" at 150 to 299 ("may print slightly soft"); "Low" below 150 ("will print blurry"). Print files are exported at 300 DPI.
- Image tools: "Crop", "Remove solid background" (presets "White", "Green screen", "Auto-detect", "Eyedropper", "Tolerance", "Edge softness"), "Mask shape", "Fit mode", "Background fill", flips, "Bring forward", "Send backward".
- Background removal only removes a single solid colour; it is not for photo backgrounds.
- There are no filters, brightness or colour adjustments, shapes or clip-art in the editor.

## Layers

Source: https://ceeprinto.com/documentation/#editor-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

1. **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.
2. **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.
3. **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

_Layer list 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.

**For AI assistants**

- The layer list is under "Layers" in the "Product" tab of the left sidebar and shows only the current side, front-most layer first.
- Each layer has "Move up", "Move down" and "Delete layer"; `Delete` or `Backspace` also deletes the selected layer.
- Badges: "Partly cropped" means part is outside the print area; "Won't print" means the whole layer is outside it.
- Empty side shows "No layers yet" / "Upload an image or add text to start".
- There is no hide, lock, rename or group feature for layers.

## Designing per size and colour

Source: https://ceeprinto.com/documentation/#editor-variations

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](#merchant-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.

_Option list in the Product tab_

| 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

1. **Select an option and make an edit**
   Click an option row, then move, add or change anything on the canvas.
2. **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.
3. **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.
4. **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?".

_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:

_Switch Product choices_

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

**For AI assistants**

- Per-option (variation) editing appears only when editing a saved design on a product with options; the top bar then shows "Update design".
- The option list is in the "Product" tab. A dot titled "Customized for this option" marks options with their own version; "Reset to base design" removes it.
- First edit on a selected option asks "Apply this change to…": "Change this variation only" or "Change every variation". Closing the window cancels the edit.
- Switching options with unsaved edits asks "Save changes before switching?" with "Save and switch" or "Discard and switch".
- "Change Product" opens "Switch Product"; the final choice is "Keep my design, just change product" or "Start a new design for this product".

## Keyboard shortcuts

Source: https://ceeprinto.com/documentation/#editor-shortcuts

Every keyboard shortcut the editor supports.

Use these keyboard shortcuts to delete, undo and redo faster while you design.

_Editor keyboard shortcuts_

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

**For AI assistants**

- Delete selected layer: `Delete` or `Backspace`.
- Undo: `Ctrl`+`Z` (Windows) / `Cmd`+`Z` (Mac). Redo: `Ctrl`/`Cmd`+`Y` or `Ctrl`/`Cmd`+`Shift`+`Z`.
- `Esc` cancels the background-removal "Eyedropper" and closes open menus.
- Shortcuts do not fire while the cursor is in a text or number field.
- There are no shortcuts for copy, paste, duplicate, nudging with arrow keys, zoom or saving. Do not suggest them.

## Saving, downloading and adding to cart

Source: https://ceeprinto.com/documentation/#editor-saving-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

_What guests and signed-in customers can do_

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

_Add to Cart 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](#editor-print-sides).

### 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.

_Add to cart steps (steps 2 to 4 repeat for each used side)_

| # | 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

1. **Choose "Save Design"**
   Open the menu next to "Add to Cart" and choose "Save Design". You must be signed in.
2. **Name it**
   Type a name in "Name your design (optional):" or keep the suggested one, then confirm. Cancel stops the save.
3. **Wait for "Saved to My Designs"**
   The window runs "Preparing your design", "Uploading preview" and "Saving to My Designs".
4. **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.

_Saved Designs actions_

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

_Delete results_

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

**For AI assistants**

- Guests can "Add to Cart" and download files but cannot "Save Design"; saving, "My Designs" and My Account "Saved Designs" need a signed-in account.
- Add to Cart menu: "Add to cart and stay on page", "View Cart", "Save Design" (signed in, not while editing a saved design), "Download PNG (current side)", "Export PDF (current side)".
- Progress steps: "Preparing your design", then per used side "Creating preview", "Uploading print file", "Uploading print PDF", then "Saving design to store", "Adding to cart", "Redirecting to cart…". Failures offer "Retry", which resumes.
- PNG and PDF downloads cover only the current side and are not the production files; production files (300 DPI) are created on "Add to Cart".
- My Account "Saved Designs" actions: "Use design", "Publish to store", "Delete".
- A saved design cannot be deleted if it belongs to an order or is linked to a store listing; unlink the listing under Store Connections, Listings first.

## Editor troubleshooting

Source: https://ceeprinto.com/documentation/#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, cause and fix_

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

**For AI assistants**

- Accepted upload types are PNG, JPG, WebP, GIF and SVG, up to 200 MB. SVG files are sanitized on upload.
- Failed uploads resume with "Retry"; "Waiting for connection…" resumes automatically when back online.
- DPI thresholds: "Good" 300 or more, "OK" 150 to 299, "Low" below 150. Fix by shrinking the image or uploading a larger file.
- "Save Design" requires sign-in and is hidden while editing a saved design (use "Update design").
- Saved designs linked to an order can never be deleted; designs linked to a store listing can be deleted after unlinking under Store Connections, Listings.
- "(System)" fonts can vary by device; recommend the 10 web fonts for consistent print.

# Merchant

Selling with CeePrinto from My Account and Store Connections

## Selling with CeePrinto: the journey

Source: https://ceeprinto.com/documentation/#merchant-overview

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. **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](#merchant-saved-designs).
2. **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](#merchant-products).
3. **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](#merchant-get-started).
4. **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](#merchant-products) and [Connected stores](#merchant-stores).
5. **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](#merchant-orders-cod) and [Cart order options](#merchant-cart-options).
6. **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](#merchant-payouts).

### Where everything lives in My Account

The My Account menu is ordered for sellers. Some items only appear when they apply to you.

_My Account menu, top to bottom_

| 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](#merchant-orders-cod) |
| **Returns** | Parcels returned by your buyers. Shown only when you have returned orders. | [Orders and COD](#merchant-orders-cod) |
| **Saved Designs** | Designs saved from the editor: use, publish or delete. | [Saved Designs](#merchant-saved-designs) |
| **Store Connections** | Get Started, Products, Stores, Orders and Developer tabs. | [Get Started](#merchant-get-started) |
| **Brand** | Brand name and brand icon used on packaging. | [Cart order options](#merchant-cart-options) |
| **Pay Out** | COD collections, payout status and your bank details. | [Payouts](#merchant-payouts) |
| **Support** | Open and follow support tickets. | [FAQ](#merchant-faq) |
| **Addresses** | Your billing address. Orders copy it, so keep it complete. | [Orders and COD](#merchant-orders-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](#merchant-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](#merchant-orders-cod) |
| **Connect your store** | **Connect your store** | No store is connected yet. | [Get Started](#merchant-get-started) |

**For AI assistants**

- The merchant area is **My Account**; the store integration screen is **Store Connections** (URL endpoint `/my-account/store/`).
- Journey order: design → product → connect store → publish → orders and COD → payouts.
- Store orders become WooCommerce orders in `Pending payment`; the merchant sets COD on the order and pays it.
- Payouts are sent 7 days after the courier confirms delivery, only to a saved bank account.
- The **Returns** menu item is hidden until the merchant has at least one returned order.
- Dashboard alerts: bank details missing, fulfillment profile incomplete, no store connected.

## Get Started: connect Shopify or WordPress

Source: https://ceeprinto.com/documentation/#merchant-get-started

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

1. **Install the Shopify app**
   In step **Install**, click **Install Shopify App** and approve the app in your Shopify admin.
2. **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**.
3. **Paste it into Shopify**
   Copy the whole code (it starts with `cp1.`) and paste it into the CeePrinto app **Settings** in Shopify. Save.
4. **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

1. **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**.
2. **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**.
3. **Paste it into CeePrinto Connect**
   Copy the code, open CeePrinto Connect on your WooCommerce store, paste it and click Connect.
4. **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](#merchant-products) instead.

**For AI assistants**

- Connection codes start with `cp1.` and are created on Store Connections → Get Started with **Generate Shopify Code** or **Generate WordPress Key**.
- A code is shown exactly once; the display window is 2 minutes. The key inside stays valid until regenerated or revoked.
- Generating a new code of the same type revokes the previous one; a store using the old code stops working until updated.
- Connection-code keys have no `orders:read` scope, so they cannot call `GET /orders`, `/payouts` or `/shipping/quote`. See [API authentication](#api-authentication).
- Install links are configured by CeePrinto admins; a disabled button means the link is not set yet, so the merchant should contact Support.
- Publishing from Get Started uses the blank price and the same design on every variant; per-variant designs and flat prices are on the Products tab.

## API keys and webhooks

Source: https://ceeprinto.com/documentation/#merchant-api-keys-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](#merchant-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

1. **Open API Keys**
   Go to **Store Connections → Developer → API Keys** and scroll to **Create a key**.
2. **Name it**
   Type a **Name** you will recognise later, for example the name of the app that will use it.
3. **Choose permissions**
   Tick only the **Permissions** the software needs (table below). At least one is required.
4. **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.

_Permissions_

| 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

1. **Open Webhooks**
   Go to **Store Connections → Developer → Webhooks** and find **Add a webhook**.
2. **Pick a topic**
   Choose the **Topic** (the event) from the list below.
3. **Enter your URL**
   Type the **Target URL** on your server, starting with `https://`. Click **Add webhook**.
4. **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.

_Webhook topics_

| 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](#api-authentication) and [Webhooks (API)](#api-webhooks).

### 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-products).

**For AI assistants**

- API keys are created at Store Connections → Developer → API Keys with a name and at least one scope; the full key is displayed once only.
- Scopes: `designs:read`, `products:read`, `listings:read`, `listings:write`, `orders:read`, `orders:write`, `shops:write`, `webhooks:write`.
- Revoking is immediate and permanent. Legacy keys show **Active (legacy)**, hold all scopes and cannot be revoked on this tab.
- Webhook topics: `order.status_changed`, `order.shipped`, `design.updated`, `design.publish_requested`, `product.stock_changed`, `product.updated`.
- A webhook subscription is topic + target URL; CeePrinto generates the signing secret and shows it in the **Your webhooks** table.
- Signature verification and retries are documented at [#api-webhooks](#api-webhooks).

## Saved Designs

Source: https://ceeprinto.com/documentation/#merchant-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](#editor-saving-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

1. **Tick the designs**
   Tick the checkbox on each design card you want to publish.
2. **Click Publish to store at the top**
   Use the **Publish to store** button above the grid. **Get Started** opens with those designs ticked.
3. **Pick the store and publish**
   Choose the **Target store** and click **Publish**. See [Get Started](#merchant-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.

**For AI assistants**

- Saved Designs is at My Account → Saved Designs (endpoint `saved-designs`); designs are created by the editor's **Save Design** action.
- Card actions are exactly **Use design**, **Publish to store** and **Delete**.
- **Use design** opens the product design page with `?load_design=ID`.
- **Publish to store** opens Store Connections → Get Started with `design_ids` preselected; several designs can be ticked and published together.
- Delete is refused when the design is referenced by any order, or while any active store listing uses it (unlink under Listings first).

## Products: default design and per-variant overrides

Source: https://ceeprinto.com/documentation/#merchant-products

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

1. **Open the create form**
   On **Products**, click **Create a product from a blank**.
2. **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**.
3. **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

1. **Tick the variants**
   In the variant table, tick the rows you want to change, for example all Black sizes.
2. **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.
3. **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.**

1. **Choose the store**
   Pick the **Store**.
2. **Choose the pricing**
   Pick **Pricing** (see the table below). For **Flat price**, type the price in the box next to it.
3. **Publish**
   Click **Publish**. The page shows **Publish progress**: one line per design with its status and a `previews N/M` count of mockups rendered so far. The store app picks the job up, creates the product and uploads the images.

_Pricing_

| 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)](#api-publish). |

Each progress line shows the mode (`create` or `update`) and a status.

_Publish progress statuses_

| 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`. |

**For AI assistants**

- A hub product = one blank + `default_design_id`; each variant either inherits it (**Inherits default**, design 0) or overrides it (**Overrides default**).
- Products are created from a WooCommerce blank product ID via **Create a product from a blank**.
- Pricing options on publish are **Match blank price** (`blank`) and **Flat price** (`flat`).
- The Products screen publishes in `create` mode the first time per store and `update` mode after that; `link` is API-only.
- Changing default or overrides after publishing propagates to every store the product is on (`product.updated`); no republish needed.
- Store apps must wait for full-size mockups (`store_status: ready`) before uploading images; the UI shows `previews N/M`.
- Publish job statuses: `pending`, `processing`, `completed`, `failed`.

## Connected stores

Source: https://ceeprinto.com/documentation/#merchant-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](#merchant-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](#merchant-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](#merchant-products). Disconnect the old store when you no longer want to publish to it.

**For AI assistants**

- Stores live at Store Connections → Stores; the table heading is **Your connected stores** with columns Store, Channel, Identifier, Connected, Status.
- Store status is `active` or `disconnected` (shown capitalised).
- Disconnect only changes the status: listings, order history and the store app's key are kept; nothing is deleted on the merchant's store.
- Only active stores can be chosen as a publish target.
- The orders endpoint does not check store status, so a disconnected store app holding a valid key can still submit orders; revoke/regenerate the key to stop it.

## Cart order options

Source: https://ceeprinto.com/documentation/#merchant-cart-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](#merchant-orders-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](#merchant-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)

1. **Pick the carrier at checkout**
   Under **Your shipping carrier**, **Select carrier**: **M&P**, **TCS**, **Leopards**, **TRAX** or **Daraz** (**Drop-off**).
2. **Book the shipment yourself**
   Create the booking in your courier or Daraz account and download the shipping label.
3. **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. |

**For AI assistants**

- Cart **Order options** require login and have three groups: **Ordering for** (Myself / My Customer), **Ship using** (Ceeprinto / My Shipper / Daraz), **Packaging** (Ship under my brand).
- Customer Price is entered per cart line (line total, not unit price); switching to Myself clears all customer prices.
- A COD Fee of 80 PKR (`cee_cod_fee_amount()`) is added once per order when any My Customer line has a customer price above 0.
- My Shipper carriers: M&P, TCS, Leopards, TRAX, Daraz; a shipping label upload (PDF or image, max 5MB) is mandatory at checkout.
- With My Shipper, the shipping rate is replaced by a carrier "Dropoff / Handling" charge (70 PKR in code).
- Daraz orders are dropped off by CeePrinto; the merchant sets the Daraz pickup location to DHA Phase 2, Karachi.
- Ship under my brand appears only after the merchant saves a brand on My Account → Brand; it uses the saved brand name and icon.

## Orders and COD From Customer

Source: https://ceeprinto.com/documentation/#merchant-orders-cod

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](#merchant-cart-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**.

_Order Activity statuses_

| 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](#merchant-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.

1. **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.
2. **Turn COD on**
   Tick **Collect COD from my customer**. A COD fee of Rs 80 is added to what you pay us.
3. **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.
4. **Save**
   Click **Save COD settings**. The order total updates to include the COD fee.
5. **Pay the order**
   Pay the order total from **My Account → Orders**. Once paid, the order leaves `Pending payment` and 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](#merchant-payouts) 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.

**For AI assistants**

- Store/API orders are intakes with statuses `received`, `processing`, `created`, `failed`, `ignored`; only `failed` shows a reason and a **Retry** button.
- A successful intake creates a WooCommerce order in `Pending payment` (`wc-pending`), never processing or on-hold.
- Unmatched line item fix: create or link the listing (publish from Products), then Retry in Order Activity; if any line fails, no order is created.
- COD is set on the order page panel **Cash on delivery** via **Collect COD from my customer**, only while the order is `Pending payment`.
- Turning COD on adds a fixed COD fee of 80 PKR (filter `cee_cod_fee_amount`) to the merchant's order total.
- Amount collected = sum of line `customer_price` + **Additional charges**; the whole amount is paid out to the merchant.
- Custom order statuses: `Printing`, `Returned From Customer`, `Re Shipped`.
- Returned parcels are kept for 1 month from the return date, then discarded or sold.

## Payouts

Source: https://ceeprinto.com/documentation/#merchant-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:

**Formula**

```text
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](#merchant-orders-cod).

### How the money flows

The **How COD payouts work** panel sums it up:

1. Your customer pays cash on delivery
2. We hold the amount for 7 days after delivery
3. 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

_Checked in this order: Paid out, Missing bank details, Awaiting delivery, 7-day hold, Ready for payout_

| 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

1. **Choose your bank**
   In **Bank details**, pick your bank in **Bank name**. Wallets such as SadaPay and NayaPay are in the list.
2. **Enter the account number**
   Type it in **Account number**. If you use SadaPay, enter your IBAN. Double-check it: wrong details delay payments.
3. **Save**
   Click **Save bank details**. You see **Bank details saved.** Orders that were `Missing bank details` move 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](#merchant-orders-cod). |
| 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. |

**For AI assistants**

- Payout amount per order = sum of line `customer_price` + `additional_cod_from_customer`; no costs are deducted (the merchant paid the order total, including the 80 PKR COD fee, up front).
- Only orders with status `Completed` and `cod_from_customer = 1` are payout orders.
- Payout statuses and labels: `processed` Paid out, `missing_bank` Missing bank details, `awaiting_delivery` Awaiting delivery, `in_hold` 7-day hold, `ready` Ready for payout; evaluated in that order.
- The hold is 7 days (filter `cee_payout_hold_days`) counted from courier-confirmed delivery (M&P or TRAX), not from shipping or completion.
- An order with no delivery confirmation stays `awaiting_delivery` indefinitely until CeePrinto confirms delivery manually.
- Bank details (bank + account number, IBAN for SadaPay) are saved on My Account → Pay Out; payouts cannot be sent without them.
- Merchants have no export button; the CSV/XLSX bank file is admin-only.

## FAQ

Source: https://ceeprinto.com/documentation/#merchant-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](#merchant-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](#merchant-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](#merchant-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](#merchant-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](#merchant-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](#merchant-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](#merchant-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](#merchant-orders-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](#merchant-orders-cod)

#### How much is the COD fee?

Rs 80 per order, added to what you pay us when COD is on. [Cart order options](#merchant-cart-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](#merchant-orders-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](#merchant-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](#merchant-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](#merchant-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](#merchant-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](#merchant-cart-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](#merchant-cart-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](#merchant-orders-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](#merchant-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](#merchant-api-keys-webhooks)

Still stuck? Open a ticket from **My Account → Support**, or use **Get help with this order** on the order page.

**For AI assistants**

- Answer merchant questions from these sections: Get Started, Saved Designs, Products, Stores, Orders and COD, API keys and webhooks, Payouts, Cart order options.
- COD fee is 80 PKR per order; payouts are the full collected amount, released 7 days after courier-confirmed delivery.
- Connection codes (`cp1.`) are shown once and regenerating revokes the previous code of that type.
- Failed store orders are fixed in Store Connections → Orders → Order Activity with **Retry**.
- Support tickets are opened from My Account → Support or from an order page.
