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.

Base URLhttp://62.169.23.239:8001/api/v1

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

AccountTypical systemWhat it does
BrandERP, PIMProducts, production orders, QR codes, passports, analytics. Receives every item event as a webhook.
WarehouseWMSReceive deliveries, hold stock, answer shop orders, ship to shops, sell directly.
Shop (retail)Web shop, WMS, tillWeb orders: pick, ship, deliver, returns. Till sales.
FactoryMES, scanning stationsDeclarations, QC variant assignment, label printing, packing.

Words you will see

TermMeaning
Product / variantA style (Trail Tee) and its colour × size versions, each with its own SKU.
Production orderAn order to a factory for units of one product. Id: order_id (ord_…).
Item / unit_tokenOne physical item. Its QR code carries the unit_token — the id every item event uses.
BoxA 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 holderThe owner has the passport; the holder has the parcel. A carrier holds goods the shop still owns.
TaskOne 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#

POST/oauth/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.

POST /oauth/token
curl
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
200Response
json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "products:read orders:write qr:write"
}

Scopes

AccountScopes
Brandproducts:read · products:write · orders:read · orders:write · qr:write · custody:read · dpp:read · analytics:read · shipments:write
Warehousewarehouse_stock:read/write · warehouse_orders:read · warehouse_transport:read/write · warehouse_sales:read/write
Shopweb_orders:read · web_orders:write · fulfillment:write · pos:read · pos:write
Factoryfactory_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.

Error 403
json
{
  "detail": "API client lacks the 'orders:write' scope",
  "request_id": "7f3c9a1e2b4d4e6f8a0b1c2d3e4f5a6b"
}
Error 422 — invalid body
json
{
  "detail": [
    {
      "type": "missing",
      "loc": [
        "body",
        "buyer_email"
      ],
      "msg": "Field required",
      "input": {}
    }
  ]
}
CodeMeaning
200 / 201Done. 201 when something was created.
202Accepted, still working (minting). Poll the status_url in the response.
204Done, no body (deletes).
400The request breaks a business rule — read detail.
401Missing, expired or wrong token. Get a new token.
402The account is paused for non-payment. Every call is refused until the balance is paid.
403The key lacks the scope, the endpoint is not open to API keys, the IP is not allowed, or access is suspended.
404Not found — or it belongs to another account.
409Wrong state for this action (e.g. box plan locked after printing), or the same Idempotency-Key is still running.
422The body or parameters are invalid (detail lists each field), or an Idempotency-Key was reused with a different body.
429Rate limit. Wait the Retry-After seconds.
500 / 503Our 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.

SituationResult
Same key, same body, within 24 hThe stored response is returned with Idempotent-Replayed: true. Nothing runs again.
Same key while the first call still runs409 idempotency_in_progress — retry shortly.
Same key, different body422 idempotency_key_reused.
First call ended 5xx, 401, 403 or 429The 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.

GET /products?page=2&limit=20
json
{
  "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. 1

    Create the product

    Send the product as JSON in the multipart field product. Send every colour × size row in variants yourself — Skannit does not build the grid from sizes and colors. Each variant needs volume_l (used to size boxes). Send "status": "active"; a draft is a single slot and is overwritten by the next draft.
  2. 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.
  3. 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. 4

    Finalize: one record and one QR per item

    Finalize locks the order and creates an on-chain record per item. It answers 202 with a status_url while minting runs — poll it until mint_status is completed, then send the print job to the label factory.
  5. 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. 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. 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. 2

    Receive or refuse

    Receive the boxes you accept; send expected_units and Skannit reports any gap in discrepancies instead of refusing goods already on the dock. Refuse the rest with a reason — they stay with the brand.
  3. 3

    Hold stock

    Boxes stay sealed until you unpack them. Record where each box sits; unpacked units inherit the location.
  4. 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. 5

    Ship to the shop

    Always on a carrier. Create a dispatch with the box_ids, the shop (retail_id) and the backorder_id it answers. When the carrier delivers, custody moves to the shop and the order is fulfilled.
  6. 6

    Sell directly (optional)

    Sell loose units or whole sealed boxes. Every unit sold moves to the account with buyer_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.

StagePassport linkedOwnerHolder
Bought onlineNoShopWarehouse
Picked & scannedYesShopWarehouse
With the carrierYesShopCarrier
DeliveredYesCustomerCustomer
Return on its way backYesCustomerCarrier
Return acceptedYesShopWarehouse
  1. 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 same external_order_id again does not duplicate it.
  2. 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: false is a normal 200 answer with a reason — pick another item. A scan never moves ownership.
  3. 3

    Parcel leaves → report shipped

    Carrier and tracking number. The shop still owns the passport; the carrier only holds the parcel.
  4. 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.
  5. 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 — accept with a next_step returns the passport to the shop, reject leaves it with the customer · settle a return lost on the way back · reverse a delivery the customer says never arrived.
  6. 6

    In a physical shop → one call at the till

    Scan the item and send the customer's email. The passport moves immediately.

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.

HeaderMeaning
X-Skannit-Signaturet=<unix seconds>,v1=<HMAC-SHA256 of "t.raw_body" with your endpoint secret>. Two v1= values during a secret rotation.
X-Skannit-Event-IdSame as the envelope id — use it to de-duplicate.
X-Skannit-Event-TypeThe event type.
X-Skannit-Delivery-IdThis delivery attempt's id.

Verify the signature

Always use the raw request body, not re-serialised JSON.

Python
py
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)
Node.js
js
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
POST your-endpoint · order.status_changed
json
{
  "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
POST your-endpoint · order.minted
json
{
  "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
POST your-endpoint · order.transmitted
json
{
  "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
POST your-endpoint · item.activated
json
{
  "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
POST your-endpoint · box.sealed
json
{
  "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
POST your-endpoint · custody.transferred
json
{
  "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
POST your-endpoint · custody.exception
json
{
  "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
POST your-endpoint · web_order.item_linked
json
{
  "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
POST your-endpoint · web_order.item_unlinked
json
{
  "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)
POST your-endpoint · ownership.transferred
json
{
  "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
POST your-endpoint · return.received
json
{
  "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
POST your-endpoint · ownership.returned
json
{
  "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
POST your-endpoint · return.rejected
json
{
  "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
POST your-endpoint · dispute.nondelivery_resolved
json
{
  "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)
POST your-endpoint · item.resold
json
{
  "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
POST your-endpoint · item.repair.completed
json
{
  "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
POST your-endpoint · item.recycled
json
{
  "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
POST your-endpoint · item.recalled
json
{
  "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
POST your-endpoint · warehouse.order_placed
json
{
  "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
POST your-endpoint · warehouse.order_cancelled
json
{
  "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
POST your-endpoint · warehouse.shipment_delivered
json
{
  "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
POST your-endpoint · warehouse.shipment_exception
json
{
  "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"
  }
}