API reference · v1
Build on Skannit, from factory floor to front door.
A REST API over HTTPS. It accepts JSON (and multipart for file uploads), answers in JSON and uses normal HTTP status codes. It is the same platform as the Skannit portals, without the screens — every call here is one the portals make.
http://62.169.23.239:8001/api/v1Who calls what
Each company in the supply chain has its own account and its own API keys. A key only sees and changes its own account's data.
| Account | Typical system | What it does |
|---|---|---|
| Brand | ERP, PIM | Products, production orders, QR codes, passports, analytics. Receives every item event as a webhook. |
| Warehouse | WMS | Receive deliveries, hold stock, answer shop orders, ship to shops, sell directly. |
| Shop (retail) | Web shop, WMS, till | Web orders: pick, ship, deliver, returns. Till sales. |
| Factory | MES, scanning stations | Declarations, QC variant assignment, label printing, packing. |
Words you will see
| Term | Meaning |
|---|---|
| Product / variant | A style (Trail Tee) and its colour × size versions, each with its own SKU. |
| Production order | An order to a factory for units of one product. Id: order_id (ord_…). |
| Item / unit_token | One physical item. Its QR code carries the unit_token — the id every item event uses. |
| Box | A sealed carton. Its label carries the box_id (BOX-…). Scanning a box covers every item inside. |
| Passport (DPP) | The item's Digital Product Passport: materials, origin, history. One per item, on-chain. |
| Owner vs holder | The owner has the passport; the holder has the parcel. A carrier holds goods the shop still owns. |
| Task | One unit of a web order waiting to be picked. An order with quantity 3 has 3 tasks. |
Skannit uses OAuth 2.0 client credentials. Once Skannit approves API access, the account owner creates an API client in the API console and picks its scopes. The secret is shown once. Exchange the client id and secret for a bearer token that lasts one hour, and send it on every call as Authorization: Bearer <token>. Cache the token — don't fetch one per call.
Live keys start skc_ / sks_; sandbox keys start skc_test_ / sks_test_ and only work in the sandbox. Never put a secret in a browser or mobile app.
Get an access token#
OAuth 2.0 client credentials (RFC 6749 §4.4). Credentials in the form body or as HTTP Basic auth. Errors use the RFC shape, not {"detail": ...}.
Request body
application/x-www-form-urlencoded- grant_typestring | null
Always client_credentials.
- client_idstring | null
Your API client id (skc_… live, skc_test_… sandbox).
- client_secretstring | null
Your API client secret (sks_…). Shown once when you create the key.
Returns
HTTP 200- access_tokenstringRequired
Bearer token. Send it as Authorization: Bearer <token>.
- token_typestring
Defaults to
Bearer - expires_inintegerRequired
Seconds until the token expires (3600). Cache it; fetch a new one shortly before expiry.
- scopestringRequired
Space-separated scopes this token carries.
curl -X POST http://62.169.23.239:8001/api/v1/oauth/token \
-d grant_type=client_credentials \
-d client_id=$SKANNIT_CLIENT_ID \
-d client_secret=$SKANNIT_CLIENT_SECRET{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "products:read orders:write qr:write"
}Scopes
| Account | Scopes |
|---|---|
| Brand | products:read · products:write · orders:read · orders:write · qr:write · custody:read · dpp:read · analytics:read · shipments:write |
| Warehouse | warehouse_stock:read/write · warehouse_orders:read · warehouse_transport:read/write · warehouse_sales:read/write |
| Shop | web_orders:read · web_orders:write · fulfillment:write · pos:read · pos:write |
| Factory | factory_orders:read/write · declarations:write · factory_qr:read/write · packing:read/write · factory_shipments:write |
Each endpoint in this reference shows the scope it needs. A key can only hold its own account type's scopes.
2xx means success, 4xx means the request can't be done as sent, 5xx means a problem on our side (safe to retry with the same Idempotency-Key). Errors carry a readable detail and the request_id to quote to support.
{
"detail": "API client lacks the 'orders:write' scope",
"request_id": "7f3c9a1e2b4d4e6f8a0b1c2d3e4f5a6b"
}{
"detail": [
{
"type": "missing",
"loc": [
"body",
"buyer_email"
],
"msg": "Field required",
"input": {}
}
]
}| Code | Meaning |
|---|---|
| 200 / 201 | Done. 201 when something was created. |
| 202 | Accepted, still working (minting). Poll the status_url in the response. |
| 204 | Done, no body (deletes). |
| 400 | The request breaks a business rule — read detail. |
| 401 | Missing, expired or wrong token. Get a new token. |
| 402 | The account is paused for non-payment. Every call is refused until the balance is paid. |
| 403 | The key lacks the scope, the endpoint is not open to API keys, the IP is not allowed, or access is suspended. |
| 404 | Not found — or it belongs to another account. |
| 409 | Wrong state for this action (e.g. box plan locked after printing), or the same Idempotency-Key is still running. |
| 422 | The body or parameters are invalid (detail lists each field), or an Idempotency-Key was reused with a different body. |
| 429 | Rate limit. Wait the Retry-After seconds. |
| 500 / 503 | Our problem, or the API is temporarily disabled. Retry with backoff. |
Not an error: a pick or return scan that doesn't match answers 200 with matched: false and a reason. That is a normal outcome — scan another item.
Send an Idempotency-Key header (any unique string, e.g. your purchase-order number) on every POST, PUT, PATCH and DELETE. Then a retry after a timeout can never create a second order or mint twice.
| Situation | Result |
|---|---|
| Same key, same body, within 24 h | The stored response is returned with Idempotent-Replayed: true. Nothing runs again. |
| Same key while the first call still runs | 409 idempotency_in_progress — retry shortly. |
| Same key, different body | 422 idempotency_key_reused. |
| First call ended 5xx, 401, 403 or 429 | The key is released — retry with the same key. |
Lists that can grow take page and limit and return a pagination object. A few use limit and offset instead. Smaller lists return everything with a total. Each endpoint shows which parameters it takes.
{
"items": [
"…"
],
"pagination": {
"current_page": 2,
"limit": 20,
"showing_from": 21,
"showing_to": 40,
"total_pages": 7,
"total_results": 128
}
}Each API client has a per-minute budget. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds). Going over returns 429 with Retry-After. Every response also carries X-Request-Id; you may send your own (8–64 letters, digits, . _ -) to match our logs with yours.
This is /api/v1. Within v1 we only add things — endpoints, optional fields, response fields, event types — so ignore fields you don't know. Breaking changes ship as /api/v2 next to v1, and v1 keeps working for at least 12 months after. Responses carry Skannit-Api-Version and Skannit-Environment (live or sandbox).
The sandbox is a separate copy of the platform on a blockchain testnet with Stripe test mode. Same endpoints, same rules, test keys — nothing you do there touches real goods or money.
The full journey a brand's ERP drives. Every step links to its reference.
- 1
Create the product
Send the product as JSON in the multipart fieldproduct. Send every colour × size row invariantsyourself — Skannit does not build the grid fromsizesandcolors. Each variant needsvolume_l(used to size boxes). Send"status": "active"; a draft is a single slot and is overwritten by the next draft. - 2
Raise a production order
Name the product, the factory (factory.factory_id) and the units per colour and size. Every colour/size must be a variant of the product.Webhooksorder.status_changed - 3
Plan the boxes
The factory packs exactly to your plan. Ask Skannit to propose one, add destinations, and save it with"complete": true— or skip the plan and let the factory pack freely. - 4
Finalize: one record and one QR per item
Finalize locks the order and creates an on-chain record per item. It answers202with astatus_urlwhile minting runs — poll it untilmint_statusiscompleted, then send the print job to the label factory. - 5
Follow production and shipping
The factory scans items at QC and seals boxes. Every hand-over (factory → carrier → warehouse → shop) arrives as an event. - 6
Read the passport and life after sale
Sales, returns, repairs, resale and recycling all write to the same item record. You get the country and date, never the buyer.
For a warehouse's WMS, with a warehouse account key.
- 1
See what arrived
Delivered boxes are listed with their contents per variant and anything the carrier reported on the way. Poll this list — there is no arrival webhook. - 2
Receive or refuse
Receive the boxes you accept; sendexpected_unitsand Skannit reports any gap indiscrepanciesinstead of refusing goods already on the dock. Refuse the rest with a reason — they stay with the brand. - 3
Hold stock
Boxes stay sealed until you unpack them. Record where each box sits; unpacked units inherit the location. - 4
Answer retailer orders
Shops order stock from you. Each order reserves free stock when placed, oldest first, so two orders are never promised the same units. - 5
Ship to the shop
Always on a carrier. Create a dispatch with thebox_ids, the shop (retail_id) and thebackorder_idit answers. When the carrier delivers, custody moves to the shop and the order is fulfilled. - 6
Sell directly (optional)
Sell loose units or whole sealed boxes. Every unit sold moves to the account withbuyer_email.
For the shop's web shop and warehouse, with a shop (retail) account key. The brand doesn't call these — it receives the events. Rule to remember: picking links the passport, only delivered moves it.
| Stage | Passport linked | Owner | Holder |
|---|---|---|---|
| Bought online | No | Shop | Warehouse |
| Picked & scanned | Yes | Shop | Warehouse |
| With the carrier | Yes | Shop | Carrier |
| Delivered | Yes | Customer | Customer |
| Return on its way back | Yes | Customer | Carrier |
| Return accepted | Yes | Shop | Warehouse |
- 1
Customer buys online → create the order
Send your order id, the lines (SKU +"Colour / Size") and the customer's email. Skannit queues one task per unit. No item is linked yet and nothing moves. Sending the sameexternal_order_idagain does not duplicate it. - 2
Warehouse picks → scan links the passport
Scan any matching item, by order number or by task. Skannit checks product, colour/size, that it is not linked elsewhere and not sold.matched: falseis a normal200answer with areason— pick another item. A scan never moves ownership.POST/retail/integrations/fulfillment/orders/{external_order_id}/scanPOST/retail/integrations/fulfillment/tasks/{task_id}/scanWebhooksweb_order.item_linked - 3
Parcel leaves → report shipped
Carrier and tracking number. The shop still owns the passport; the carrier only holds the parcel. - 4
Carrier delivers → report delivered
This is the only call that moves the passport to the customer — to the Skannit account with the order's email (one is created if needed). The customer does not scan anything.Webhooksownership.transferred - 5
When things go wrong
Cancel before shipping (item back to stock) · unlink an item damaged at packing and pick another · lost parcel · return: start it, scan it in (a different item is flagged), then inspect —acceptwith anext_stepreturns the passport to the shop,rejectleaves it with the customer · settle a return lost on the way back · reverse a delivery the customer says never arrived.POST/retail/integrations/fulfillment/tasks/{task_id}/cancelPOST/retail/integrations/fulfillment/tasks/{task_id}/unlinkPOST/retail/integrations/fulfillment/tasks/{task_id}/lostPOST/retail/integrations/fulfillment/tasks/{task_id}/returnPOST/retail/integrations/fulfillment/tasks/{task_id}/receivePOST/retail/integrations/fulfillment/tasks/{task_id}/inspectionPOST/retail/integrations/fulfillment/tasks/{task_id}/return-lostPOST/retail/integrations/fulfillment/tasks/{task_id}/dispute-nondelivery - 6
In a physical shop → one call at the till
Scan the item and send the customer's email. The passport moves immediately.Webhooksownership.transferred
Add an https endpoint in the API console and pick the events you want. Each event is a POST with this envelope; data differs per type. Respond with any 2xx within 10 seconds. Failed deliveries retry after 1 min, 5 min, 30 min, 2 h and 8 h; an endpoint that fails for 3 days is disabled. The same event can arrive twice — de-duplicate on id. Current envelope version: 2026-10-01.
| Header | Meaning |
|---|---|
X-Skannit-Signature | t=<unix seconds>,v1=<HMAC-SHA256 of "t.raw_body" with your endpoint secret>. Two v1= values during a secret rotation. |
X-Skannit-Event-Id | Same as the envelope id — use it to de-duplicate. |
X-Skannit-Event-Type | The event type. |
X-Skannit-Delivery-Id | This delivery attempt's id. |
Verify the signature
Always use the raw request body, not re-serialised JSON.
import hashlib, hmac, time
def verify_skannit(raw_body: bytes, header: str, secret: str) -> bool:
parts = header.split(",")
t = next((p[2:] for p in parts if p.startswith("t=")), None)
signatures = [p[3:] for p in parts if p.startswith("v1=")]
if t is None or abs(time.time() - int(t)) > 300: # reject replays
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, expected) for s in signatures)import crypto from "node:crypto";
export function verifySkannit(rawBody, header, secret) {
const parts = header.split(",");
const t = parts.find((p) => p.startsWith("t="))?.slice(2);
const sigs = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return sigs.some((s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}Brand events
Sale and return events carry the country and date, never the buyer's identity. Click an event for a sample delivery.
+order.status_changedA production order moves to its next stage
{
"id": "evt_f41d1a273c785e7c8565322bc5fcbe05",
"type": "order.status_changed",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"order_id": "po_test_8842",
"from_status": "IN_PRODUCTION",
"to_status": "QUALITY_CONTROL"
}
}+order.mintedItem records for an order are minted and ready to print
{
"id": "evt_400ab2171b555a69add1cb822db07926",
"type": "order.minted",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"order_id": "po_test_8842",
"qr_batch_id": "qrb_test_01",
"units_minted": 800
}
}+order.transmittedThe QR print job reached the factory
{
"id": "evt_febae71f18d7582299432a449a7070f6",
"type": "order.transmitted",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"order_id": "po_test_8842",
"qr_batch_id": "qrb_test_01",
"factory_id": "fac_test_01"
}
}+item.activatedAn item passed factory QC and its variant was confirmed
{
"id": "evt_a8672029955e57d782682444437fdce7",
"type": "item.activated",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"order_id": "po_test_8842",
"unit_token": "unit_test_000417",
"variant": {
"color": "Navy",
"size": "M"
},
"factory_id": "fac_test_01"
}
}+box.sealedA box was sealed at the factory, with its serial range
{
"id": "evt_6d52d9b0436b5691a920d51eae06f45a",
"type": "box.sealed",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"order_id": "po_test_8842",
"box_id": "box_test_00417",
"item_count": 24,
"weight_kg": 11.6
}
}+custody.transferredGoods changed hands in the supply chain
{
"id": "evt_7a4f9f40aed053a4b138f076eac3d7e3",
"type": "custody.transferred",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"order_id": "po_test_8842",
"box_id": "box_test_00417",
"unit_count": 24,
"from_type": "factory",
"to_type": "transporter",
"hop": "picked_up"
}
}+custody.exceptionDamage, tampering or loss — with damage_type and severity
{
"id": "evt_b65d38c66af751f7810b9a0520733b94",
"type": "custody.exception",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"dispatch_id": "dsp_test_01",
"damage_type": "crushed",
"severity": "high",
"note": "Corner of box crushed in transit"
}
}+web_order.item_linkedThe warehouse picked an item for a web order and linked its passport
{
"id": "evt_30a178302861567196587327739a6be0",
"type": "web_order.item_linked",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"task_id": "wot_test_01",
"external_order_id": "1001",
"order_reference": "#1001",
"unit_token": "unit_test_000417",
"shop_id": "shop_test_01",
"provenance_confidence": "high"
}
}+web_order.item_unlinkedA link was undone — order cancelled, item damaged at packing, or parcel refused
{
"id": "evt_ff6dfec6d0df532480d6b6304ea63305",
"type": "web_order.item_unlinked",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"task_id": "wot_test_01",
"external_order_id": "1001",
"order_reference": "#1001",
"unit_token": "unit_test_000417",
"shop_id": "shop_test_01",
"reason": "damaged"
}
}+ownership.transferredA passport moved to a customer — at the till, or on delivery (country and date, never identity)
{
"id": "evt_e45acf4d2deb5f279c1b3aa933e3f137",
"type": "ownership.transferred",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"unit_token": "unit_test_000417",
"channel": "retail_pos",
"sale_country": "NL"
}
}+return.receivedA returned item was scanned in — flagged if it isn't the item that was sent
{
"id": "evt_7f33b5d32feb57158e8a943f93b058c3",
"type": "return.received",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"task_id": "wot_test_01",
"external_order_id": "1001",
"order_reference": "#1001",
"unit_token": "unit_test_000417",
"shop_id": "shop_test_01",
"flagged": false
}
}+ownership.returnedA return passed inspection and the passport is back with the shop
{
"id": "evt_39a759c47b315451ae744d5e150e8360",
"type": "ownership.returned",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"unit_token": "unit_test_000417",
"channel": "web_shop"
}
}+return.rejectedA return failed inspection; the passport stays with the customer
{
"id": "evt_11ae1387bf245afd8b2875a507415676",
"type": "return.rejected",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"task_id": "wot_test_01",
"external_order_id": "1001",
"order_reference": "#1001",
"unit_token": "unit_test_000417",
"shop_id": "shop_test_01",
"reason": "Worn"
}
}+dispute.nondelivery_resolvedA buyer's non-delivery dispute was resolved — the passport moved back to the shop
{
"id": "evt_3f1200c1d91b5081b69e5e0d8c65d2db",
"type": "dispute.nondelivery_resolved",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"task_id": "wot_test_01",
"external_order_id": "1001",
"order_reference": "#1001",
"unit_token": "unit_test_000417",
"shop_id": "shop_test_01",
"reason": "Buyer reports parcel never arrived"
}
}+item.resoldA verified resale completed (coming soon: subscribe now, events start when marketplace checkout opens)
{
"id": "evt_f84327647c9a5b47b06ab0f50cef5112",
"type": "item.resold",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"unit_token": "unit_test_000417",
"marketplace_order_ref": "MKO-TEST-01",
"channel": "skannit_marketplace"
}
}+item.repair.completedA repair was confirmed
{
"id": "evt_0b2cfaa7228558fd9a746d6fe7e93913",
"type": "item.repair.completed",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"unit_token": "unit_test_000417",
"repair_job_id": "rj_test_01"
}
}+item.recycledRecycling confirmed, with facility, processing state and circularity score
{
"id": "evt_45ab9ae51e0b5a2e81cb857e3a278dd6",
"type": "item.recycled",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"unit_token": "unit_test_000417",
"recycling_request_id": "rr_test_01"
}
}+item.recalledAn item was recalled on-chain — its passport now shows the recall
{
"id": "evt_a84fb910c5cd5f4dbe3c47de5450a2fd",
"type": "item.recalled",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"unit_token": "unit_test_000417",
"token_id": 417,
"tx_hash": "0xtest"
}
}Warehouse events
+warehouse.order_placedA shop ordered stock from you
{
"id": "evt_c1c5e50dba9750e2a739825f507080a2",
"type": "warehouse.order_placed",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"backorder_id": "bord_test_01",
"retail_id": "shop_test_01",
"brand_id": "brand_test_01",
"product_id": "prod_test_01",
"sku": "TEE-NVY-M",
"color": "Navy",
"size": "M",
"quantity": 6,
"shortage_kind": "ready",
"eta_date": null,
"cancel_reason": null
}
}+warehouse.order_cancelledThe shop withdrew its order
{
"id": "evt_21f5258343c2572592255319eed5d54f",
"type": "warehouse.order_cancelled",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"backorder_id": "bord_test_01",
"retail_id": "shop_test_01",
"brand_id": "brand_test_01",
"product_id": "prod_test_01",
"sku": "TEE-NVY-M",
"color": "Navy",
"size": "M",
"quantity": 6,
"shortage_kind": null,
"eta_date": null,
"cancel_reason": "No longer needed"
}
}+warehouse.shipment_deliveredYour dispatch reached the shop
{
"id": "evt_73ca1cf838ce5f2fb0036efaceec8052",
"type": "warehouse.shipment_delivered",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"dispatch_id": "dsp_test_01",
"retail_id": "shop_test_01",
"backorder_id": "bord_test_01",
"box_ids": [
"box_test_00417"
],
"unit_count": 6
}
}+warehouse.shipment_exceptionThe carrier reported a problem on your dispatch
{
"id": "evt_599c0505818f54d89d1aeae154c29b3b",
"type": "warehouse.shipment_exception",
"api_version": "2026-10-01",
"created_at": "2026-10-09T09:30:00Z",
"data": {
"dispatch_id": "dsp_test_01",
"retail_id": "shop_test_01",
"damage_type": "crushed",
"severity": "high",
"note": "Corner of box crushed in transit"
}
}