# Open API v1 Integration Guide

## 1. Overview

| Item | Description |
| --- | --- |
| Base URL | `https://{your-domain}/api/open/v1` |
| Format | UTF-8 JSON, except `multipart/form-data` file uploads. |
| Authentication | Every request uses Client ID, timestamp, nonce, and HMAC-SHA256 signature. |
| Pricing and payment | The server recalculates pricing, inventory, and balance for the bound settlement account. Do not submit prices. Agent pricing can be enabled per Client. |
| Shipping labels | Upload external labels before creating orders. This API never buys or creates labels online. |
| Factory dispatch | A successful order is immediately queued for factory dispatch. |

Super administrators create Clients from **Open API Clients** in the administration menu. Each Client is permanently bound to one internal company and one active settlement account. Scopes, allowed parent products, agent-pricing switch, IP allowlist, webhook, and Client status are configurable there. New Clients have no allowed products by default; an unconfigured Client receives an empty catalog and cannot order unapproved products.

By business requirement, `Client Secret` is stored in plaintext and viewable in the administrator page. Restrict administrator access and deliver secrets only through secure channels.

## 2. Authentication and signing

### 2.1 Required headers

| Header | Required | Description |
| --- | --- | --- |
| `X-Open-Client-Id` | Yes | Client ID created in the admin panel. |
| `X-Open-Timestamp` | Yes | Unix timestamp in seconds; must be within 5 minutes of server time. |
| `X-Open-Nonce` | Yes | Unique random value per request, preferably UUID. It cannot be reused by the same Client within 6 minutes. |
| `X-Open-Signature` | Yes | Lowercase hexadecimal HMAC-SHA256 signature. |
| `X-Open-Content-SHA256` | Files only | Lowercase SHA-256 digest of the uploaded file bytes. |
| `X-Idempotency-Key` | Recommended for order creation | Order idempotency key, maximum 128 characters. |

### 2.2 String to sign

Use Client Secret to HMAC-SHA256 the following UTF-8 text:

```text
TIMESTAMP\nNONCE\nMETHOD\nPATH\nBODY_SHA256
```

| Field | Description |
| --- | --- |
| `TIMESTAMP` | Exact value of `X-Open-Timestamp`. |
| `NONCE` | Exact value of `X-Open-Nonce`. |
| `METHOD` | Uppercase HTTP method, such as `GET` or `POST`. |
| `PATH` | Path only, without host or query string, e.g. `/api/open/v1/orders`. |
| `BODY_SHA256` | SHA-256 of the exact raw JSON request body. For a bodyless request, use SHA-256 of an empty string. |

SHA-256 of an empty string:

```text
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```

The exact bytes used for signing must be sent as the HTTP request body. Do not reformat JSON after signing.

```python
import hashlib, hmac, json, time, uuid

path = '/api/open/v1/orders'
payload = {'external_order_no': 'partner-10001', 'shipments': []}
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
timestamp = str(int(time.time()))
nonce = str(uuid.uuid4())
body_hash = hashlib.sha256(body).hexdigest()
to_sign = '\n'.join([timestamp, nonce, 'POST', path, body_hash])
signature = hmac.new(client_secret.encode(), to_sign.encode(), hashlib.sha256).hexdigest()
```

### 2.3 Multipart upload signing

Do not sign the complete `multipart/form-data` body because browsers and HTTP clients generate the multipart boundary. Instead:

1. Calculate SHA-256 from the uploaded file bytes.
2. Send that digest as `X-Open-Content-SHA256`.
3. Use the same digest as `BODY_SHA256` in the string to sign.
4. Send one form field named `file`.

Do not manually set a multipart `Content-Type` boundary.

## 3. Endpoints

| Method | Path | Scope | Purpose |
| --- | --- | --- | --- |
| `GET` | `/products` | `catalog` | List parent products and variants. |
| `GET` | `/products/{main_product_id}` | `catalog` | Get one parent product and all sellable variants. |
| `POST` | `/uploads/images` | `uploads` | Upload artwork. |
| `POST` | `/uploads/shipping-labels` | `uploads` | Upload a partner-provided external label. |
| `POST` | `/orders` | `orders` | Create an external order and queue factory dispatch. |
| `GET` | `/orders` | `orders` | List orders created by the current Client. |
| `GET` | `/orders/{external_order_no}` | `orders` | Get one external order. |
| `POST` | `/orders/{external_order_no}/cancel` | `orders` | Cancel an order within 10 minutes of creation. |
| `GET` | `/balance` | `account` | Get the bound settlement account balance. |
| `GET` | `/inventory/{main_product_id}` | `catalog` | Get the latest system inventory for one authorized parent product. |

Available scopes are `*`, `catalog`, `uploads`, `orders`, and `account`. `*` grants all scopes.

## 4. Product endpoints

### 4.1 List products

```http
GET /api/open/v1/products?per_page=20
```

`per_page` is optional, `1–100`, default `20`. `data` is a Laravel paginator and the products are in `data.data`.

```json
{
  "code": "OK",
  "data": {
    "current_page": 1,
    "data": [{
      "id": 2,
      "name": "Canvas Wrap",
      "description": "",
      "image_url": "/storage/images/products/main_product_2.jpg",
      "upload_num": 1,
      "updated_at": "2026-07-16T08:00:00+00:00",
      "variants": [{
        "id": 29,
        "name": "11\" x 14\"",
        "model": "CW-11X14",
        "type": "fixed_size",
        "width": 11,
        "height": 14,
        "price": 12.5,
        "currency": "USD",
        "quantity": 100,
        "image_url": null,
        "updated_at": "2026-07-16T08:00:00+00:00"
      }]
    }],
    "per_page": 20,
    "total": 1
  }
}
```

### 4.2 Get product detail

```http
GET /api/open/v1/products/2
```

The response uses the same fields as one list item. A missing or disabled product returns:

```json
{"code":"PRODUCT_NOT_FOUND","message":"Product not found."}
```

### 4.3 Product fields and ordering rules

| Field | Description |
| --- | --- |
| `id` | Parent product ID; used by the detail endpoint. |
| `variants[].id` | `product_id` used when creating an order. |
| `type` | `fixed_size` or `custom_size`. |
| `width`, `height` | Fixed-size orders must use catalog dimensions. Custom-size orders must send positive values. |
| `price` | Current USD unit price for the settlement account. It is for display/estimation only; the server recalculates it. |
| `price_tiers` | Returned only when agent pricing is enabled. These are Discount Management tiers with `min_quantity` and `price`; otherwise this is an empty array. |
| `quantity` | Current stock of the variant. The server validates and decrements it when orders are placed. |
| `upload_num` | Number of artwork images required per item. |

### 4.4 Get inventory

```http
GET /api/open/v1/inventory/2
```

Returns the **latest system stock quantity** (`products.quantity`) for every variant of the specified parent product. Returns the following when the parent product is missing, disabled, or not authorized for the current Client:

```json
{"code":"PRODUCT_NOT_FOUND","message":"Product not found."}
```

```json
{
  "code": "OK",
  "data": {
    "main_product_id": 2,
    "main_product_name": "Canvas Wrap",
    "variants": [{
      "product_id": 29,
      "name": "11\" x 14\"",
      "model": "CW-11X14",
      "type": "fixed_size",
      "width": 11,
      "height": 14,
      "quantity": 100,
      "updated_at": "2026-07-16T08:00:00+00:00"
    }]
  }
}
```

| Field | Description |
| --- | --- |
| `data.main_product_id` | Parent product ID. |
| `data.main_product_name` | Parent product name. |
| `data.variants` | Inventory list of sellable variants under this parent product. |
| `variants[].product_id` | Variant ID; matches `variants[].id` from the catalog endpoint. |
| `variants[].quantity` | Latest system stock quantity of this variant (`products.quantity`). |

## 5. Upload endpoints

### 5.1 Upload artwork

```http
POST /api/open/v1/uploads/images
Content-Type: multipart/form-data
```

Form field: `file`. Allowed files: `JPG`, `JPEG`, `PNG`. Maximum size: 200 MB.

### 5.2 Upload an external shipping label

```http
POST /api/open/v1/uploads/shipping-labels
Content-Type: multipart/form-data
```

Form field: `file`. Allowed files: `JPG`, `JPEG`, `PNG`, `PDF`. Maximum size: 200 MB.

Successful response:

```json
{
  "code": "OK",
  "data": {
    "upload_id": "450a8630-518f-452a-ba47-4f5f80758123",
    "kind": "image",
    "original_name": "artwork.jpg",
    "file_size": 39988,
    "width": 500,
    "height": 500,
    "expires_at": "2026-07-23T08:00:00+00:00",
    "url": "/storage/cart-images/550e8400-e29b-41d4-a716-446655440000.jpg"
  }
}
```

| Rule | Description |
| --- | --- |
| Expiry | Upload records expire after 7 days. |
| Ownership | Only the Client that uploaded the file can use it in an order. |
| Artwork | Artwork can be used by multiple orders; declare the use count with `images[].quantity`. |
| Label | One label can be used by one shipment only; it is claimed when the order succeeds. |
| Storage | Artwork uses the existing `cart-images/` directory; labels use the existing `shipping-label/` directory. |

## 6. Create an order

```http
POST /api/open/v1/orders
Content-Type: application/json
X-Idempotency-Key: partner-10001-key
```

One external order can contain multiple `shipments`. Each shipment creates one internal `factory_order` and must use a distinct, previously uploaded external label.

```json
{
  "external_order_no": "partner-10001",
  "shipments": [{
    "external_shipment_no": "parcel-a",
    "shipping_label_upload_id": "11111111-1111-4111-8111-111111111111",
    "items": [{
      "external_line_no": "line-a",
      "product_id": 29,
      "quantity": 2,
      "images": [
        {"upload_id":"22222222-2222-4222-8222-222222222222", "quantity":1, "degree":0},
        {"upload_id":"33333333-3333-4333-8333-333333333333", "quantity":1, "degree":90}
      ],
      "option": {"frame":"black"},
      "job_id": "partner-job-a",
      "comments": "Handle with care"
    }]
  }]
}
```

### 6.1 Request fields

| Field | Required | Rules |
| --- | --- | --- |
| `external_order_no` | Yes | Unique per Client, maximum 128 characters. |
| `shipments` | Yes | Array of `1–100` shipments. |
| `shipments[].external_shipment_no` | Yes | Unique within the order, maximum 128 characters. |
| `shipments[].shipping_label_upload_id` | Yes | UUID returned by label upload. |
| `shipments[].items` | Yes | `1–100` item lines per shipment. |
| `external_line_no` | Yes | Partner line number, maximum 128 characters. |
| `product_id` | Yes | `variants[].id` from the product endpoint. |
| `quantity` | Yes | Integer `1–100000`. |
| `width`, `height` | Required for custom size | Positive values for `custom_size`; fixed-size values must match the catalog if sent. |
| `images` | Yes | Artwork array of `1–100` entries. |
| `images[].upload_id` | Yes | UUID returned by artwork upload. |
| `images[].quantity` | Yes | Artwork use count, integer `1–100000`. |
| `images[].degree` | No | Rotation angle `0–359`, default `0`. |
| `option` | No | JSON object. |
| `job_id` | No | Maximum 255 characters. |
| `comments` | No | Maximum 2000 characters. |

Every item line must satisfy:

```text
sum(images[].quantity) = quantity × upload_num
```

For example, for `upload_num=1` and two items, send two images with `quantity=1` each, or reuse one image with `quantity=2`. For `upload_num=2` and one item, total artwork use count must be two.

### 6.2 Idempotency, inventory, and balance

- A retry with the same Client, order number or idempotency key, and same request body returns the existing order (HTTP `200`). A different body returns HTTP `409`.
- Existing inventory of the bound settlement account is used first. Shortages are purchased from balance at the current price.
- When agent pricing is enabled, every item line uses the Discount Management tier with the greatest `min_quantity <= quantity`; if no tier matches, standard price is used.
- Insufficient balance or purchasable product stock rolls back the complete transaction; no order is created.
- Each successful shipment maps to one `factory_order` and is immediately sent to the factory dispatch queue.

The first successful create returns HTTP `201`:

```json
{
  "code":"OK",
  "message":"Order accepted and queued for factory dispatch.",
  "data": {
    "external_order_no":"partner-10001",
    "status":"submitted",
    "amount":25,
    "currency":"USD",
    "created_at":"2026-07-16T08:00:00+00:00",
    "factory_orders":[{
      "id":124335,
      "print_job_id":"20260716080000000-00000097",
      "status":"Ready To Proceed",
      "dispatch_status":"pending",
      "dispatch_error":null,
      "external_shipment_no":"parcel-a"
    }]
  }
}
```

An idempotent replay returns HTTP `200` and `message` is `Existing order returned.`.

## 7. Order queries

### 7.1 List orders

```http
GET /api/open/v1/orders?per_page=20
```

Only orders created by the current Client are returned. `per_page` is `1–100`, default `20`; `data` is a paginator.

### 7.2 Get an order

```http
GET /api/open/v1/orders/partner-10001
```

Returned fields match the `data` object from order creation. If not found:

```json
{"code":"ORDER_NOT_FOUND","message":"Order not found."}
```

| Field | Meaning |
| --- | --- |
| `status` | Top-level Open API order status: `submitted` after creation, `canceled` after cancellation. |
| `factory_orders[].status` | Internal factory order status, such as `Ready To Proceed`, `Factory Shipped`, `Completed`, or `Canceled`. |
| `factory_orders[].dispatch_status` | Factory API dispatch state: `pending`, `sent`, or `failed`. |
| `factory_orders[].dispatch_error` | Dispatch error when failed; otherwise `null`. |

## 8. Cancel an order

```http
POST /api/open/v1/orders/partner-10001/cancel
```

An order can be canceled within **10 minutes** of creation. After that window the API returns HTTP `409`:

```json
{"code":"ORDER_CANCEL_WINDOW_EXPIRED","message":"Orders can only be canceled within 10 minutes of creation."}
```

On success:

- the order `status` becomes `canceled` and every `factory_orders[].status` becomes `Canceled`;
- inventory out-records are voided and the balance charged at creation is refunded to the account that paid;
- the shipping label uploads claimed by the order are released and can be reused in later orders;
- shipments already dispatched to the factory are canceled at the factory as well; repeated calls return the same result without refunding twice.

The response `data` matches the order detail structure, with `message` set to `Order canceled.`.

## 9. Balance

```http
GET /api/open/v1/balance
```

Returns the USD balance of the settlement account bound to the current Client.

```json
{
  "code": "OK",
  "data": {
    "balance": 1250.75,
    "currency": "USD"
  }
}
```

| Field | Description |
| --- | --- |
| `balance` | Available balance of the bound settlement account. |
| `currency` | Balance currency, always `USD`. |

## 10. Webhooks

When `webhook_url` is configured in the Client settings, callbacks are sent through the queue. Each callback is retried up to 3 times with a 15-second HTTP timeout.

| Header | Description |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Open-Event` | Event name. |
| `X-Open-Signature` | HMAC-SHA256 over the raw callback JSON string. |

The signing key is configured `webhook_secret`, or Client Secret when no webhook secret is configured.

Events:

- `order.created`: the order was created successfully.
- `order.canceled`: the order was canceled inside the 10-minute cancellation window.
- `factory_order.status_changed`: factory callback updated factory order status.

```json
{
  "event":"order.created",
  "occurred_at":"2026-07-16T08:00:00+00:00",
  "data":{
    "external_order_no":"partner-10001",
    "status":"submitted",
    "factory_orders":[{"id":124335,"status":"Ready To Proceed","dispatch_status":"pending"}]
  }
}
```

Return any `2xx` promptly from the receiver. Non-`2xx` responses are retried. Implement deduplication using the event and order number.

## 11. Error codes

Business errors use:

```json
{"code":"ERROR_CODE","message":"Human-readable explanation."}
```

Validation errors use Laravel's normal HTTP `422` response with `message` and `errors`.

| HTTP | Code | Meaning |
| --- | --- | --- |
| 401 | `UNAUTHORIZED` | Missing/invalid auth header, expired timestamp, invalid signature, reused nonce, unavailable Client/account, rejected IP, or invalid file digest. |
| 403 | `FORBIDDEN` | Client lacks the required scope. |
| 403 | `BALANCE_PERMISSION_DENIED` | Bound account cannot purchase inventory from balance. |
| 404 | `PRODUCT_NOT_FOUND` | Parent product is missing or disabled. |
| 404 | `ORDER_NOT_FOUND` | The current Client does not have this order. |
| 409 | `IDEMPOTENCY_CONFLICT` | Same order number or idempotency key was used with a different request body. |
| 409 | `CLIENT_CONFIGURATION_INVALID` | Client, bound user, or company configuration is inconsistent. |
| 409 | `PRODUCT_CONFIGURATION_INVALID` | Fixed-size product has no valid configured dimensions. |
| 409 | `BALANCE_ACCOUNT_NOT_FOUND` | Bound company has no active balance account. |
| 409 | `ORDER_CANCEL_WINDOW_EXPIRED` | The order was created more than 10 minutes ago and cannot be canceled. |
| 422 | `INVALID_IDEMPOTENCY_KEY` | Idempotency key is longer than 128 characters. |
| 422 | `DUPLICATE_SHIPMENT_NUMBER` | Shipment number is duplicated in one order. |
| 422 | `DUPLICATE_SHIPPING_LABEL` | A label is reused in one order. |
| 422 | `UPLOAD_UNAVAILABLE` | Upload expired, has wrong type, belongs to another Client, or a label was already used. |
| 422 | `PRODUCT_UNAVAILABLE` | Product or parent product is unavailable. |
| 403 | `PRODUCT_NOT_AUTHORIZED` | Product is not authorized for the current Client. |
| 422 | `INVALID_PRODUCT_SIZE` | Dimensions are invalid or differ from fixed-size catalog dimensions. |
| 422 | `ARTWORK_QUANTITY_MISMATCH` | Total artwork quantity does not equal `quantity × upload_num`. |
| 422 | `INSUFFICIENT_BALANCE` | Insufficient balance. |
| 422 | `INSUFFICIENT_PRODUCT_STOCK` | Insufficient purchasable product stock. |

## 12. Recommended integration flow and runtime requirements

1. Create a Client in the admin panel; securely store Client ID and Client Secret.
2. List products; select `variants[].id` and record `upload_num`, dimensions, and price.
3. Upload every artwork and retain its `upload_id`.
4. Upload one external label for every shipment and retain its `upload_id`.
5. Create the order with a unique idempotency key and obey the artwork quantity rule.
6. Track factory orders through the create response, order query, or webhooks.

The server must run a queue worker to dispatch factory orders and send webhooks:

```bash
php artisan queue:work
```

Development test page: `https://{your-domain}/open-api-tester.html`. It is for testing only; the Client Secret is kept only in the current browser session.

中文版请见：[Open API v1 对接文档（中文）](open-api-v1.md)。
