# POD Dropship API — combined documentation

Base URL: https://www.pod-sourcing.com/api/v1

This file combines the API v1 reference and the site push/pull contract. Feed it to an AI assistant as-is.

---

# Partner API v1 — Technical Documentation

**Base URL:** `https://www.pod-sourcing.com/api/v1`
**Version:** 1.0 · 2026-11-03
**Audience:** approved dropshipping partners (independent sites)

---

## 1. What this API does

- Sync our product links (with print templates and personalization regions) to your site.
- Upload customer images.
- Create orders with custom text/images.
- Read order status and tracking.
- (Sandbox) simulate production/shipping transitions without charges or production.

You build your own product pages and editor. We return structured JSON; there is no iframe/embed.

---

## 1.1 Fulfillment modes

Your partner account has one of two modes:

| Mode | We ship to | Shipping cost | Typical partner |
|---|---|---|---|
| `consolidation` | Your domestic consolidation warehouse (Shenzhen or another city) | CNY 6 first kg + CNY 2.50/kg | Independent site / 集运 |
| `direct` | The end customer's receipt address (international) | Live Yanwen quote; channel and DDP/DDU configured by sales | Etsy / direct-to-consumer |

The mode is set by our sales team when your account is approved. In the application form, **filling a consolidation warehouse address selects consolidation; leaving it empty selects direct**.

---

## 2. Access and authentication

### 2.1 Apply and tokens

Ask your account manager to open a partner account. **Approval does not issue a token automatically.**

After approval, open `account/dropship → API tokens`:

- **Create test token** — `pd_test_...` (sandbox; no production, no charges)
- **Create live token** — `pd_live_...` (production; use after the sandbox pilot passes)

Each token can be **bound to a shop**. Orders created with that token default to that shop; sending a different `shop_id` is rejected. A token created for **All shops** (legacy) applies to every shop. This lets you keep one live token for an existing shop and a separate test token for a new shop.

Tokens are shown **once**. Store them in your secret manager. We store only a SHA-256 hash. You can **revoke** any token from the same page; our admin can also revoke it.

### 2.2 Send the token

```
Authorization: Bearer pd_test_xxxxxxxxxxxxxxxx
```

Every request must include it. Tokens must not be placed in query strings or request bodies.

### 2.3 Scopes

| Scope | What it allows |
|---|---|
| `catalog:read` | List/read links |
| `catalog:select` | Add/remove links from your catalogue |
| `assets:write` | Upload/read customer images |
| `orders:write` | Create orders |
| `orders:read` | Read order status |
| `balance:read` | Read balance |
| `sandbox:simulate` | Advance sandbox order status |

A missing scope returns `403 forbidden_scope`.

### 2.4 Rate limits

120 requests/minute per token by default. Over the limit returns `429` with `Retry-After: 60`.

---

## 3. First call

```bash
curl -s https://www.pod-sourcing.com/api/v1/ping \
  -H "Authorization: Bearer pd_test_..."
```

```json
{
  "ok": true,
  "time": "2026-11-03T01:50:02+00:00",
  "mode": "test",
  "scopes": ["catalog:read", "catalog:select", "assets:write", "orders:write", "orders:read", "balance:read", "sandbox:simulate"]
}
```

`whoami`:

```bash
curl -s https://www.pod-sourcing.com/api/v1/whoami \
  -H "Authorization: Bearer pd_test_..."
```

```json
{
  "ok": true,
  "partner": { "id": 2, "name": "99Plus", "slug": "99plus" },
  "mode": "test",
  "scopes": ["catalog:read", "..."],
  "sandbox_balance_usd": 1000.0
}
```

---

## 4. Catalogue

### 4.1 List links

```
GET /catalog/links?scope=available|selected&since=2026-11-01T00:00:00Z&ids=123,124&page=1&limit=50
```

- `scope=available` — links we have opened for dropshipping
- `scope=selected` — links you added to your catalogue (default)
- `since` — only rows updated after this ISO-8601 time
- `ids` — optional comma-separated filter
- `page` / `limit` (max 50)

Response:

```json
{
  "ok": true,
  "data": [
    {
      "link_id": 123,
      "title": "Custom Family Mugs",
      "cover": "https://cdn.../cover.jpg",
      "item_count": 1,
      "customizable": true,
      "version": "ddc3fa23380e3ca5",
      "updated_at": "2026-11-03T01:47:35+00:00",
      "selected": true
    }
  ],
  "page": 1,
  "limit": 50,
  "total": 1,
  "next_page": null,
  "scope": "selected"
}
```

### 4.2 Read one link

```
GET /catalog/links/{link_id}
```

Returns the complete template JSON:

```json
{
  "ok": true,
  "data": {
    "link_id": 80,
    "title": "customize personalize name baby romper",
    "cover": "https://.../composite_80.jpg",
    "currency": "USD",
    "customizable": true,
    "version": "ddc3fa23380e3ca5",
    "updated_at": "2026-11-03 01:47:35",
    "selected": true,
    "items": [
      {
        "product_id": 80,
        "title": "customize personalize name baby romper",
        "print_no": "TR1305805",
        "cover": "https://.../composite_80.jpg",
        "gallery": ["https://.../composite_80.jpg"],
        "customizable": true,
        "sizes": [
          { "size_id": 1020, "name": "6 month" },
          { "size_id": 1021, "name": "12 month" }
        ],
        "template": {
          "id": 836,
          "name": "TR-15cm x 15cm 烫画",
          "craft": "TH",
          "format": "png",
          "density": 99,
          "size_cm": { "w": 15, "h": 15 },
          "source_px": { "w": 585, "h": 585 },
          "clip": { "x": 0, "y": 0, "width": 573, "height": 573, "unit": "templogo_px_top_left" },
          "clip_normalized": { "x": 0, "y": 0, "width": 979.49, "height": 979.49, "unit": "canvas1000_top_left" },
          "full_bleed": false,
          "clip_ratio": 1
        },
        "colors": [
          {
            "color_id": 830,
            "name": "Blue",
            "label": "Blue",
            "mockup": "https://.../composite_80.jpg",
            "blank_mockup": "https://.../placement.jpg",
            "full_bleed": false,
            "clip_ratio": 1,
            "placement": {
              "template_id": 836,
              "clip_x": 455,
              "clip_y": 440,
              "clip_width": 358,
              "clip_height": 291,
              "clip_adj_y": 360,
              "clip_angle": 0,
              "unit": "canvas1000_center",
              "mask_url": "https://.../mask.png"
            },
            "regions": [
              {
                "id": "r21",
                "type": "text",
                "left": 381, "top": 465, "width": 148, "height": 108,
                "angle": 0,
                "fontLabel": "name",
                "hint": "mike",
                "required": true,
                "fontMinLen": 0,
                "fontMaxLen": 20,
                "fontColor": "#1a1a1a"
              },
              {
                "id": "r22",
                "type": "image",
                "left": 363, "top": 292, "width": 196, "height": 167,
                "angle": 0,
                "fontLabel": "photo",
                "hint": "",
                "required": true,
                "imgMinX": 1000,
                "imgMinY": 1000
              }
            ],
            "sizes": [
              {
                "size_id": 1020,
                "sku_id": 2601,
                "name": "6 month",
                "variant_id": "80:2601",
                "sku": "@TR1305805-A007BU-66/j",
                "raw_sku": "A007BU-66",
                "price_usd": 3.3,
                "in_stock": true,
                "stock": null,
                "weight_g": 52,
                "price_tiers": [
                  { "qty": 1, "percent": 200, "price_usd": 3.3 },
                  { "qty": 5, "percent": 180, "price_usd": 3.27 },
                  { "qty": 50, "percent": 130, "price_usd": 3.2 },
                  { "qty": 100, "percent": 120, "price_usd": 3.18 },
                  { "qty": 1000, "percent": 110, "price_usd": 3.17 }
                ]
              }
            ]
          }
        ]
      }
    ]
  }
}
```

**Price and template data:**

- Link level: `price_from_usd` / `price_to_usd` — our wholesale price range (your cost).
- Item level: `price_usd` / `price_max_usd` and per-variant `price_usd`.
- Each item also carries `template` and `tempdzinfo` — the complete template/placement/regions JSON (`tempdzinfo.colors[colorId]` = placement + regions + full_bleed + clip_ratio).
- `tempdzinfo` is the data your editor needs to reproduce the print area exactly.

**Important:** prices are indicative. The authoritative amount is recalculated when the order is created.

### 4.3 Select / unselect

```bash
curl -s -X POST https://www.pod-sourcing.com/api/v1/catalog/links/select \
  -H "Authorization: Bearer pd_test_..." \
  -H "Content-Type: application/json" \
  -d '{"link_ids":[80]}'
```

```json
{ "ok": true, "selected": [80], "ignored": [] }
```

```bash
curl -s -X POST https://www.pod-sourcing.com/api/v1/catalog/links/unselect \
  -H "Authorization: Bearer pd_test_..." \
  -H "Content-Type: application/json" \
  -d '{"link_ids":[80]}'
```

```json
{ "ok": true, "removed": 1 }
```

Only links in our dropship catalogue can be selected. Unknown ids are returned in `ignored`.

---

## 5. Images

### 5.1 Upload

```
POST /assets
Content-Type: multipart/form-data

file=<binary>
```

```bash
curl -s -X POST https://www.pod-sourcing.com/api/v1/assets \
  -H "Authorization: Bearer pd_test_..." \
  -F "file=@photo.png"
```

```json
{
  "ok": true,
  "asset_id": "pa_e118bd131f4e071a7d1469d1",
  "mime": "image/png",
  "bytes": 8381,
  "width": 1200,
  "height": 1200,
  "sha256": "bebb2231..."
}
```

- Accepted: JPEG, PNG, WebP
- Max upload: 12 MB; stored near 1 MB
- Minimum long edge: 300 px
- **Order validation is stricter:** each image region checks `imgMinX` / `imgMinY`.

### 5.2 Read

```
GET /assets/{asset_id}
Authorization: Bearer ...
```

Returns the binary image. Assets are scoped to the same partner and mode (test/live).

---

## 6. Orders

`pd_test_` creates a sandbox order (no production, no charge). `pd_live_` creates a **real order**, debits your prepaid balance, and enters our production queue. Admin still books the waybill and pushes to production.

### 6.1 Create

```
POST /orders
Authorization: Bearer pd_test_...
Idempotency-Key: 99P-TEST-0001
Content-Type: application/json
```

```json
{
  "partner_order_id": "99P-TEST-0001",
  "lines": [
    {
      "variant_id": "80:2601",
      "quantity": 1,
      "values": {
        "r21": "LUCA",
        "r22": "pa_e118bd131f4e071a7d1469d1"
      }
    }
  ],
  "recipient": {
    "name": "Shenzhen Consolidation Warehouse / Member 12345",
    "phone": "+86 13800000000",
    "address": "深圳市南山区 ...",
    "postcode": "518000",
    "consolidation_code": "99P-12345"
  },
  "note": "optional"
}
```

Rules:

- `Idempotency-Key` required (8–120 chars). Repeating the same key returns the original result with `Idempotent-Replay: 1`.
- `partner_order_id` required (max 120).
- 1–50 lines; each quantity 1–999; total ≤ 5000.
- Prefer `variant_id`; `sku` is accepted as a fallback.
- `values` is keyed by region id. Image regions take an `asset_id`; text regions take a string.
- Per-piece content: use `pieces: [{seq, values}]`; count must equal `quantity`.
- Recipient rules depend on your **fulfillment mode** (see §1.1):
  - `consolidation`: `country_code: CN`, `postcode` and `consolidation_code` required.
  - `direct`: the end customer's international address (`country_code`, `name`, `phone`, `address`); no `consolidation_code`.
- The mode is configured by our sales team on your partner account.
- Optional `shop_id`: which site the order belongs to. With one shop it is inferred; with multiple, pass it so order webhooks go to the right shop.
- **HS code is not an input.** We derive it from the product's underlying blank record (the same one your link's SKUs point to) for customs/PI. Partners do not send HS codes.
- Stock is managed internally; the API does not accept inventory changes. Treat `in_stock` from the catalogue as authoritative.

Response:

```json
{
  "ok": true,
  "sandbox": true,
  "order_no": "PDT261103015002772",
  "partner_order_id": "99P-TEST-0001",
  "status": "paid",
  "tier": { "qty_12m": 0, "effective_qty": 1 },
  "charges": {
    "goods_usd": 3.3,
    "shipping_usd": 0.9,
    "total_usd": 4.2,
    "shipping_weight_g": 52,
    "shipping_kg": 1,
    "shipping_cny": 6
  },
  "balance_usd": 1000,
  "created_at": "2026-11-03T01:50:02+00:00"
}
```

**Sandbox orders do not charge the balance, do not enter production, and do not create a real order.** They are stored in separate `partner_sandbox_*` tables — they never appear in your order list or in the POD buyer order list. You can delete all sandbox orders and test data at any time from `Dropship → API tokens → Clear sandbox data`. Live orders (`pd_live_`) do create a real order and debit the prepaid balance.

### 6.2 Read

```
GET /orders/{order_no}
```

```json
{
  "ok": true,
  "data": {
    "ok": true,
    "sandbox": true,
    "order_no": "PDT261103015002772",
    "partner_order_id": "99P-TEST-0001",
    "status": "shipped",
    "currency": "USD",
    "charges": { "goods_usd": 3.3, "shipping_usd": 0.9, "total_usd": 4.2 },
    "tracking_no": "SF123456789",
    "carrier": "SF Express",
    "created_at": "2026-11-03 01:50:02",
    "items": [
      {
        "line_no": 1,
        "sku": "@TR1305805-A007BU-66/j",
        "name": "customize personalize name baby romper",
        "color": "Blue",
        "size": "6 month",
        "quantity": 1,
        "unit_price": 3.3,
        "line_total": 3.3
      }
    ]
  }
}
```

### 6.3 Automatic order pull & de-duplication

POD also **pulls orders automatically every ~5 minutes**:

- Etsy shops: POD reads paid, not-yet-shipped receipts via `getShopReceipts`.
- Independent sites with an **Orders pull URL**: POD calls it every run.
- The manual **Pull orders** button performs the same pull immediately.

Whatever path creates the order (automatic pull or `POST /orders`), POD de-duplicates
on **`partner_order_id`**:

- `partner_orders(partner_id, partner_order_id)` is UNIQUE.
- If the order already exists, `POST /orders` returns the original order (HTTP 200,
  replay) instead of creating a second one — even with a different `Idempotency-Key`.
- Successful imports are skipped on later pulls; **failed imports are retried on the
  next pull** (the sync cursor is not advanced while a pull has errors).

If the prepaid balance is insufficient, the order is **not created at all** (no unpaid
order): the API returns `402 insufficient_balance`, and the partner receives an
in-site message + email (at most once per 12 hours). After topping up, the next
automatic pull imports the pending order.

### 6.4 Simulate status (sandbox only)

```
POST /sandbox/orders/{order_no}/advance
```

Request:

```json
{ "status": "in_production" }
```

Response (same shape as `GET /orders/{order_no}`):

```json
{
  "ok": true,
  "data": {
    "ok": true,
    "sandbox": true,
    "order_no": "PDT261103015002772",
    "partner_order_id": "99P-TEST-0001",
    "status": "shipped",
    "currency": "USD",
    "charges": { "goods_usd": 3.3, "shipping_usd": 0.9, "total_usd": 4.2 },
    "tracking_no": "SF123456789",
    "carrier": "SF Express",
    "created_at": "2026-11-03 01:50:02",
    "items": []
  }
}
```

Allowed transitions:

| From | To |
|---|---|
| `paid` | `in_production`, `cancelled` |
| `in_production` | `shipped`, `cancelled` |
| `shipped` | `delivered`, `refunded` |
| `delivered` | `refunded` |

Invalid transitions return `409 invalid_transition`.

---

## 7. Balance

```
GET /balance
```

Test token:

```json
{
  "ok": true,
  "sandbox": true,
  "currency": "USD",
  "balance_usd": 1000,
  "pending_balance_usd": 0,
  "transactions": []
}
```

Live token returns the real balance and the last 20 wallet transactions.

---

## 7.5 Order webhooks

In `account/dropship → Shops`, each site has its own **Order webhook URL** and signing secret (separate from its Link push URL). Orders can pass `shop_id`; with one shop it is inferred automatically. A partner-level fallback webhook can still be configured by our team.

Events:

- `order.paid`
- `order.in_production`
- `order.shipped` (with `tracking_no`)
- `order.delivered`
- `order.cancelled`
- `order.refunded`
- `ping` (test button)

Headers:

```
X-Partner-Event: order.shipped
X-Partner-Delivery: evt_...
X-Partner-Timestamp: 1793672738
X-Partner-Signature: sha256=<hex>
```

Signature:

```
expected = "sha256=" + hex(HMAC-SHA256(secret, timestamp + "." + rawBody))
```

Respond `2xx` quickly. Failed deliveries retry at 1m / 5m / 30m / 2h / 6h / 24h (max 6 attempts), then stop.

`order.paid` is queued when a live order is created. Later statuses are enqueued **immediately** when the order status changes (shipped / received / cancelled / refunded); the `partner:sync-events` cron (every 5 minutes) remains a safety net. All events are delivered by `partner:webhooks` (every minute); sandbox status events are queued by `advance`. You can watch the queue from `Dropship → Sync queue`.

---

## 8. Pricing

Your unit price follows the **same volume tiers as our website** (1 / 5 / 50 / 100 / 1,000 pieces).

Tiers are tracked **per link**, exactly like our buyer checkout: all colours and sizes of one link combine, but different links do not.

Your tier for a link is selected by that link's **rolling 12-month order quantity**:

| 12-month units for that link | Tier used |
|---|---|
| 0–4 | 1-piece tier |
| 5–49 | 5-piece tier |
| 50–99 | 50-piece tier |
| 100–999 | 100-piece tier |
| 1,000+ | 1,000-piece tier |

The effective tier is `max(that link's historical tier, this order's quantity tier for the link)`. The order total is always recalculated by us; the catalogue price is for display only.

Sandbox orders do **not** update the 12-month quantity.

---

## 9. Shipping

### 9.1 Consolidation mode

We ship to your domestic consolidation warehouse (Shenzhen or any other city).

| Item | Rate |
|---|---|
| First 1 kg | CNY 6.00 |
| Additional weight | CNY 2.50 per kg (rounded up) |

- Charged per order
- Converted to USD at the current rate
- Included in `charges.shipping_usd`
- Recipient must include `postcode` and `consolidation_code`

### 9.2 Direct mode

We ship directly to the end customer's receipt address (for example an Etsy order address).

- Shipping is quoted live from our carrier network (Yanwen), using the channel and tax mode configured by our sales team on your partner account.
- `recipient` is the end customer's international address; no consolidation code.
- `charges.shipping_usd` includes the international freight; `charges.tax_mode` is `ddu` or `ddp`.
- If no channel can quote the destination, the order is rejected with `shipping_unavailable`.

---

## 10. Errors

```json
{
  "ok": false,
  "error": {
    "code": "missing_required_region",
    "message": "Region \"r21\" is required (line)."
  }
}
```

| HTTP | Meaning |
|---|---|
| 400/422 | Validation error |
| 401 | Missing/invalid/revoked/expired token |
| 403 | Missing scope; not selected |
| 404 | Link/SKU/order/asset not found |
| 409 | Idempotency in progress / invalid transition |
| 429 | Rate limited |
| 500/503 | Server or upstream unavailable |

Common codes: `invalid_token`, `token_revoked`, `forbidden_scope`, `link_not_found`, `sku_not_found`, `sku_not_selected`, `ambiguous_sku`, `not_customizable`, `missing_required_region`, `text_too_long`, `asset_too_small`, `invalid_asset`, `invalid_recipient`, `unsupported_destination`, `insufficient_balance`, `idempotency_key_reuse`, `idempotency_in_progress`, `invalid_transition`.

---

## 11. Sandbox testing flow

1. Receive a `pd_test_...` token.
2. `GET /ping` and `GET /whoami`.
3. `GET /catalog/links?scope=available`.
4. `POST /catalog/links/select` with the sample link.
5. `GET /catalog/links/{id}` and build your product page/editor.
6. `POST /assets` with a customer image.
7. `POST /orders` with `Idempotency-Key`.
8. `POST /sandbox/orders/{order_no}/advance` to simulate production/shipping.
9. `GET /orders/{order_no}` to poll.

**Sandbox never charges, produces, ships, or creates a real order.**

---

## 12. Preview parity — matching preview to print

### 12.1 Coordinate systems

| Object | Space | Origin |
|---|---|---|
| Region `left/top/width/height` | 1000 × 1000 | top-left |
| Template `clip` | templogo pixels | top-left |
| Template `clip_normalized` | 1000 × 1000 | top-left |
| Placement `clip_x/clip_y` | 1000 × 1000 | **centre** |

To convert a placement to top-left:

```
left = clip_x - clip_width / 2
top  = clip_y - clip_height / 2
```

### 12.2 Crop and mask

- `full_bleed = true` (template `xy_ratio != 0`): centre-crop the asset to `clip_ratio`, then fill the clip box. No letterboxing.
- `full_bleed = false`: fit the whole asset inside the box preserving aspect ratio (contain), no crop.
- `mask_url` is the printable mask. Outside the mask is not printed.
- Image regions use the uploaded asset as-is; the server does not re-crop at order time.
- Text regions: the contract guarantees the box, rotation, label, colour and length limits, but **not** font family, size or glyph rendering.

### 12.3 Text and Hebrew

- If you render the final text into an image on your side and upload that image, preview and print use the same pixels.
- If you send raw text, our production team prepares the print file; the final typography may differ slightly from your preview.
- For the first orders, we can send a print proof for approval before production.

### 12.4 Asset rules

- JPEG/PNG/WebP
- At least `imgMinX × imgMinY` for the target image region
- sRGB
- No external URLs; only `asset_id`s from the same partner and mode

---

## 13. Current limitations

- **Live orders are enabled** — they debit the prepaid balance and create a real order. Our admin still books the waybill and pushes to production; the API does not do that automatically.
- **Webhooks are enabled** — each site has its own order webhook URL in `Dropship → Shops`. Polling (`GET /orders/{order_no}`) remains available as a fallback.
- **Orders are pulled automatically every ~5 minutes** — Etsy via OAuth `getShopReceipts`; independent sites via *Orders pull URL* (`X-POD-Secret`). The **Pull orders** button does the same on demand. Orders are de-duplicated by `partner_order_id`; failed imports are retried on the next pull.
- **Insufficient balance** — no order is created (no unpaid record); the API returns `402 insufficient_balance`, and the partner gets an in-site message + email (≤ once per 12 hours). Topping up makes the next pull import it.
- **Fulfillment status** — POD pushes `order.in_production / shipped / delivered / cancelled / refunded` immediately on status change; Etsy gets tracking back automatically when the order is marked shipped.
- **Stock is boolean and managed by us.** `in_stock` is provided; `stock` stays `null`. We do not expose real inventory levels — assume default availability unless `in_stock` is `false`.
- **HS code is derived from our blank/product record**, never supplied by the partner. It is used on the PI and customs documents.
- **Consolidation addresses must be `CN`.** Direct orders accept international end-customer addresses.

---

## 14. Support

For integration questions, contact your account manager. Include the `X-Partner-Request-Id` response header when reporting an error.

---

*POD Sourcing · JIANGKAO TRADING CO., LIMITED*


---

# Partner Link Push Contract (v1)

**Audience:** partners who want us to push selected links to their own site.
**Direction:** POD Sourcing → your site.

---

## 1. What this is

In your buyer account → **Dropship**, you select links and click **Publish selected**.
We send the full link JSON to the HTTPS endpoint you configure. Your site creates or updates the product.

Orders still flow the other way: your site calls our order API (`POST /api/v1/orders`).

---

## 2. Configure the site

In `Dropship → Shops`, add:

- **Name** — e.g. `My site`
- **Push endpoint** — a public HTTPS URL, e.g. `https://your-site.com/api/pod/links/import`
- **Secret** — a shared secret used for HMAC signing

We reject non-HTTPS URLs, private IPs and non-443 ports.

---

## 3. Request

```
POST https://your-site.com/api/pod/links/import
Content-Type: application/json
X-POD-Signature: sha256=<hex>
X-POD-Timestamp: <unix seconds>
X-POD-Delivery: <random id>
```

### 3.1 Signature

```
signed = timestamp + "." + raw_request_body
expected = "sha256=" + hex(HMAC-SHA256(secret, signed))
```

Use the **raw request body bytes**, not a re-encoded JSON object.

### 3.2 Ping

```json
{
  "action": "ping",
  "partner_id": 12,
  "shop_id": 3,
  "time": "2026-11-03T10:00:00+00:00"
}
```

Respond `2xx` with `{"ok":true}`.

### 3.3 Upsert (create or update a product)

```json
{
  "action": "upsert",
  "version": "ddc3fa23380e3ca5",
  "partner_id": 12,
  "shop_id": 3,
  "link": {
    "link_id": 80,
    "title": "customize personalize name baby romper",
    "cover": "https://.../composite_80.jpg",
    "currency": "USD",
    "customizable": true,
    "items": [
      {
        "product_id": 80,
        "title": "customize personalize name baby romper",
        "cover": "https://.../composite_80.jpg",
        "gallery": ["https://.../composite_80.jpg"],
        "template": { "id": 836, "craft": "TH", "size_cm": { "w": 15, "h": 15 }, "..." : "..." },
        "colors": [
          {
            "color_id": 830,
            "name": "Blue",
            "mockup": "https://.../composite_80.jpg",
            "regions": [
              { "id": "r21", "type": "text", "left": 381, "top": 465, "width": 148, "height": 108, "fontMaxLen": 20, "required": true },
              { "id": "r22", "type": "image", "left": 363, "top": 292, "width": 196, "height": 167, "imgMinX": 1000, "imgMinY": 1000, "required": true }
            ],
            "sizes": [
              {
                "size_id": 1020,
                "sku_id": 2601,
                "name": "6 month",
                "variant_id": "80:2601",
                "sku": "@TR1305805-A007BU-66/j",
                "raw_sku": "A007BU-66",
                "price_usd": 3.30,
                "in_stock": true,
                "weight_g": 52
              }
            ],
            "placement": { "clip_x": 455, "clip_y": 440, "clip_width": 358, "clip_height": 291, "unit": "canvas1000_center", "mask_url": "https://.../mask.png" }
          }
        ]
      }
    ]
  }
}
```

Rules:

- `version` changes when any field changes. **If your stored version is the same, you may skip the update.**
- `action: upsert` is idempotent: use `link_id` + `version` as your key.
- `link_id` is our product group ID; `product_id` is the item inside it.

### 3.4 Delete

```json
{
  "action": "delete",
  "version": "ddc3fa23380e3ca5",
  "partner_id": 12,
  "shop_id": 3,
  "link_id": 80
}
```

Remove or unpublish the product from your site. Return `2xx`.

---

## 4. Response

```json
{
  "ok": true,
  "external_id": "your-product-123"
}
```

- Return `2xx` for success.
- `external_id` is optional but recommended; we store it so support can trace the link.
- Return a non-2xx status to trigger retry.
- Respond quickly (under ~5 seconds). Queue heavy work on your side.

---

## 5. Retry

Link pushes are **not retried automatically**. A non-2xx response marks the job
`failed` immediately, and the partner receives an in-site message with the
error. Fix the endpoint / secret, then press **Retry** on the failed job in
`Dropship → Sync queue`.

Order webhooks are different: they retry at 1 min → 5 min → 30 min → 2 h →
6 h → 24 h (max 6 attempts).

---

## 6. Security requirements

- **HTTPS only**, public host, port 443.
- Reject requests with `|now - X-POD-Timestamp| > 300` seconds.
- Verify `X-POD-Signature` with a constant-time compare.
- Use the **raw body** for the HMAC.
- You may deduplicate on `X-POD-Delivery`.

### PHP verification example

```php
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_POD_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_POD_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);

if (abs(time() - (int) $ts) > 300 || ! hash_equals($expected, $sig)) {
    http_response_code(401);
    exit;
}

$payload = json_decode($raw, true);
// handle action: ping / upsert / delete
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'external_id' => $externalId]);
```

---

## 7. Receiver responsibilities

1. Verify the signature and timestamp.
2. `ping` → return `{ok:true}`.
3. `upsert` → create or update the product using `link_id` + `version`.
4. `delete` → remove/unpublish by `link_id`.
5. Return `2xx` quickly; store `external_id` if you have one.
6. Be idempotent: the same `version` may arrive twice after a retry.

---

## 8. Orders (optional, two-way)

A site can also sync orders in both directions. Both are triggered from the **Pull orders** / **Push status** buttons on each site, and the status side also runs on a cron.

### 8.1 Pull paid orders (your site → POD)

If you set **Orders pull URL** on the site, POD calls it **automatically every ~5
minutes** (and immediately when the partner clicks *Pull orders*). Failed imports are
retried on the next pull; already-imported orders are de-duplicated by
`partner_order_id`:

```
GET <pull_url>?since=2026-11-03T00:00:00Z&shop_id=12&limit=50
X-POD-Secret: <the same push secret>
Accept: application/json
```

Return `200` with:

```json
{
  "orders": [
    {
      "partner_order_id": "99P-0001",
      "lines": [{ "sku": "TEE-WHT-M", "quantity": 1, "values": { "r21": "LUCA" } }],
      "recipient": { "name": "...", "phone": "...", "address": "...", "country_code": "US", "city": "...", "state": "...", "postcode": "..." },
      "note": "optional"
    }
  ]
}
```

`orders[]` uses the same body as `POST /api/v1/orders`. Include only orders that are **paid and not yet fulfilled**. POD deduplicates by `partner_order_id`, so returning the same order twice is safe. Invalid orders are skipped and shown in the site's *Last sync* error.

Etsy sites do not need `pull_url`: POD reads paid receipts from the Etsy API using the OAuth connection.

### 8.2 Status webhook (POD → your site)

Set an **Order webhook URL** on the site. POD pushes `order.paid`,
`order.in_production`, `order.shipped`, `order.delivered`, `order.cancelled`
and `order.refunded`.

Headers (⚠️ these are **`X-Partner-*`**, not the link-push `X-POD-*` headers):

```
X-Partner-Event: order.shipped
X-Partner-Delivery: evt_...
X-Partner-Timestamp: 1793672738
X-Partner-Signature: sha256=<hex(HMAC-SHA256(secret, timestamp + "." + rawBody))>
```

The secret is the site's **Order webhook secret** (separate from the link-push
secret). Body:

```json
{
  "id": "evt_...",
  "event": "order.shipped",
  "created_at": "2026-11-03T10:00:00+00:00",
  "data": { "order_no": "...", "status": "shipped", "tracking_no": "..." }
}
```

Respond `2xx` quickly. Status changes are enqueued **immediately** (shipped,
received, cancelled, refunded, and waybill booking → in_production);
`partner:sync-events` (every 5 minutes) remains a safety net. `partner:webhooks`
delivers within about a minute.

---

## 9. Testing

1. Add the site in `Dropship → Shops`.
2. Click **Ping**; it must return `{ok:true}`.
3. Tick a link and click **Publish selected**.
4. Check your receiver logs for `action: upsert` and verify the signature.
5. Open **Sync queue** in the Dropship page; the link job should be `success`.
6. Click **Unpublish** to send `action: delete`.

If a job is `failed`, the last error is shown in the job table.

---

*POD Sourcing · JIANGKAO TRADING CO., LIMITED*
