# Omnishelf Seller API > Developer documentation for the Omnishelf Seller API — fulfilment orders, labels, tracking, inventory, and webhooks. The Omnishelf Seller API lets sellers and fulfilment partners list the fulfilment orders they fulfil, download carrier labels, submit and read tracking, report inventory, and receive signed webhooks. Base URL: https://api.omnishelf.com. Authentication: Authorization: Bearer omni_sk_... # Getting started # Omnishelf Seller API **Version 1 — Developer Documentation** Fulfilment orders, carrier labels, tracking submission, inventory reporting, and signed webhooks. Everything documented here is live in production today. ______________________________________________________________________ ## What the API does The Omnishelf Seller API lets you run fulfilment programmatically for the orders you fulfil. It works in two directions: - **Pull** — you call Omnishelf over HTTPS to list actionable fulfilment orders, download carrier labels, and read tracking. - **Push** — Omnishelf calls a webhook URL you host the moment an order becomes actionable, is put on hold, or is cancelled. ## Getting access API keys and webhook registrations are created and managed by Omnishelf staff on request — there is no self-service portal for either. Email your Omnishelf contact to get set up. ## Base URL ``` https://api.omnishelf.com ``` Set this once in your environment and use it for every request: ``` export BASE_URL="https://api.omnishelf.com" export OMNI_KEY="omni_sk_YOUR_KEY" ``` ## What's in this guide | Section | What you'll find | | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | | [Quickstart](https://docs.omnishelf.com/quickstart/index.md) | From zero to a verified key and webhook in five steps | | [Fulfilment Lifecycle](https://docs.omnishelf.com/concepts/lifecycle/index.md) | How orders move through the system — read before the endpoint reference | | [Authentication](https://docs.omnishelf.com/concepts/authentication/index.md) | Bearer tokens, key format, revocation | | [Conventions](https://docs.omnishelf.com/concepts/conventions/index.md) | Base URL, identifier formats, timestamps, pagination | | [API Reference](https://docs.omnishelf.com/api/fulfilment-orders/index.md) | All 14 endpoints with request/response examples | | [Webhooks](https://docs.omnishelf.com/webhooks/overview/index.md) | Event types, payload shape, signature verification, retry schedule | | [Errors & Rate Limits](https://docs.omnishelf.com/reference/errors/index.md) | Every error code and what to do about it | | [Status Reference](https://docs.omnishelf.com/reference/status/index.md) | Order, shipment, and parcel status vocabularies | ## Using an AI assistant The whole guide is available as plain text for AI coding tools such as Claude, ChatGPT or Cursor. Give your assistant one of these URLs: - [`https://docs.omnishelf.com/llms.txt`](https://docs.omnishelf.com/llms.txt) — an index of every page, with links to a Markdown copy of each - [`https://docs.omnishelf.com/llms-full.txt`](https://docs.omnishelf.com/llms-full.txt) — the full text of every page in one file # Quickstart Five steps from nothing to a verified key and a verified webhook. Steps 1 to 3 take about two minutes once you have a key in hand. ``` export BASE_URL="https://api.omnishelf.com" export OMNI_KEY="omni_sk_YOUR_KEY" ``` ______________________________________________________________________ ## Step 1 — Request an API key Ask your Omnishelf contact for a Seller API key. You will receive a single string beginning with `omni_sk_`. It is shown to you **exactly once** — Omnishelf stores only a SHA-256 hash of it and cannot recover or re-display the original. Put it straight into your secret store. ``` # Keep the key in an environment variable, never in source control. export BASE_URL="https://api.omnishelf.com" export OMNI_KEY="omni_sk_YOUR_KEY" ``` ______________________________________________________________________ ## Step 2 — Call GET /v1/ping `/v1/ping` is the health and identity probe. It touches no order data, so it is the safe way to confirm a key is live before you wire anything else up. ``` curl -s "$BASE_URL/v1/ping" \ -H "Authorization: Bearer $OMNI_KEY" ``` ______________________________________________________________________ ## Step 3 — Check the response A working key returns 200 with the API version and the webhook event types you can register. Read `event_types` from here rather than hard-coding it — the list grows without a version bump. ``` { "ok": true, "version": "v1", "event_types": [ "fulfilment_order.created", "fulfilment_order.cancelled", "fulfilment_order.on_hold", "fulfilment_order.hold_released", "fulfilment_order.in_transit", "fulfilment_order.delivered", "fulfilment_order.undelivered", "fulfilment_order.status_changed" ] } ``` The last five, from `hold_released` to `status_changed`, are order lifecycle events. You can register for them, but they are sent only once the order lifecycle is switched on for your account. Every authenticated response also carries rate limit headers. Check them once here so you know what your client will be reading later: ``` curl -s -D - -o /dev/null "$BASE_URL/v1/ping" \ -H "Authorization: Bearer $OMNI_KEY" # HTTP/2 200 # x-ratelimit-limit: 100 # x-ratelimit-remaining: 99 # x-ratelimit-reset: 1786531260 ``` ______________________________________________________________________ ## Step 4 — Register a webhook callback URL Send Omnishelf staff the HTTPS URL that should receive events and which event types you want. They register one webhook per event type and issue its signing secret, again shown once. Each webhook gets its own secret, and its deliveries verify only with that one. Your endpoint must accept a POST with a JSON body and answer within 10 seconds. ``` # What to send to your Omnishelf contact: # callback_url: https://api.your-company.com/omnishelf/webhooks # event_types: fulfilment_order.created, fulfilment_order.cancelled, fulfilment_order.on_hold # What you get back: # one signing secret per webhook — store each with the URL it signs for, # e.g. OMNISHELF_WEBHOOK_SECRET for a single webhook ``` ______________________________________________________________________ ## Step 5 — Receive and verify your first event Ask Omnishelf staff to fire a test event at your endpoint. Verify the signature over the raw request body, acknowledge with 200, then process asynchronously. ``` import hmac, hashlib, os from flask import Flask, request app = Flask(__name__) # The signing secret of the webhook registered for this URL (each webhook has its own). SECRET = os.environ["OMNISHELF_WEBHOOK_SECRET"].encode() @app.post("/omnishelf/webhooks") def receive(): raw = request.get_data() # raw bytes, NOT request.json sent = request.headers.get("X-Omnishelf-Signature", "") expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(sent, expected): return "", 401 # Acknowledge first, process afterwards. enqueue(request.get_json()) return "", 200 ``` ______________________________________________________________________ ## Next steps - Read the [Fulfilment Lifecycle](https://docs.omnishelf.com/concepts/lifecycle/index.md) before the endpoint reference — it explains why the endpoints are shaped the way they are. - See [Webhooks — Testing](https://docs.omnishelf.com/webhooks/testing/index.md) for a minimal local receiver you can expose with a tunnel. # Concepts # Fulfilment Lifecycle Everything the API does exists to serve one loop. Read this section before the endpoint reference — it explains why the endpoints are shaped the way they are. ______________________________________________________________________ ## The flow, step by step **1. An order becomes actionable.** As soon as you can see an order, the `fulfilment_order.created` webhook fires with the full order payload: shipping address, line items, and the shipment and parcels Omnishelf has created for it. Every order always carries at least one shipment with one parcel, even before a label or tracking number exists. If you are not using webhooks, poll `GET /v1/fulfilment-orders` instead — it returns `awaiting_shipment` and `processing` orders by default. **2. Fetch the labels for its parcels.** Call `GET /v1/fulfilment-orders/{order_id}/labels` for everything on the order in one request, or drop to shipment or parcel level. Labels come back as base64-encoded PDFs alongside the `parcel_id` and `tracking_number` they belong to, so the right label always lands on the right box. **3. Pack and hand the boxes to the carrier.** No API involvement. Omnishelf learns nothing until a scan happens or you tell it. **4. Submit tracking numbers back to Omnishelf.** If Omnishelf did not supply the labels — you bought your own postage, for instance — post the tracking numbers to `POST /v1/fulfilment-orders/{order_id}/tracking`. Omnishelf creates the shipment and parcel records, writes through to the fulfilment system, and recomputes order status itself. **5. Poll tracking status until delivered.** `GET /v1/orders/{order_id}/tracking` returns the order rollup plus every shipment and parcel with its carrier status. Poll on a sensible interval — the 100 requests/minute limit is per key, not per order. ______________________________________________________________________ ## What Omnishelf does and does not do for you The single most common integration mistake is assuming you control order status. **You do not, and this is deliberate.** Order status is derived from the tracking status of the parcels on the order — Omnishelf recomputes it automatically each time a parcel's carrier tracking status changes, and rolls the parcel statuses up into a single order status (see [How statuses roll up](https://docs.omnishelf.com/reference/status/#how-statuses-roll-up)). | Omnishelf does | Omnishelf does not | | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Compute order status from the tracking status of each parcel, every time a carrier update is received. | Accept a status value from you. There is no endpoint that sets order status directly. | | Create shipment and parcel records when you submit tracking at order level. | Move an order to `shipped` just because you submitted a tracking number. The order stays in `processing` until carrier tracking confirms movement. | | Move an order to `shipped` only when the parcel tracking status shows a real carrier scan (`in_transit` or later). | Treat `pending` or `info_received` as a carrier scan. Those are label-created placeholders, not actual carrier movement. | | Push your tracking submission through to the fulfilment system before writing anything locally. | Save a partial result. If the push fails, nothing is written and you can safely retry. | ______________________________________________________________________ ## Webhooks start the work; polling finishes it The dependable pattern is: react to `fulfilment_order.created` to begin fulfilment, then poll tracking for the orders you have in flight until each reaches `delivered`, `exception`, or `cancelled`. **Do not wait for a completion event — there isn't one.** See [Webhooks — What's not sent](https://docs.omnishelf.com/webhooks/overview/#when-nothing-is-sent) for the full list of transitions that emit no event. # Authentication Every request carries a bearer token in the `Authorization` header. There is no other authentication mechanism — no query-string keys, no HMAC on requests, no OAuth. ``` Authorization: Bearer omni_sk_YOUR_KEY ``` ______________________________________________________________________ ## Key format Keys always begin with `omni_sk_`. A token without that prefix is rejected before any database lookup. - Keys are shown **once** at creation and stored as a SHA-256 hash. Lose it and staff issue a new one — nobody can read the original back. - One active key per seller is the norm. - The key resolves to exactly one seller account. Every endpoint is scoped to the orders that account fulfils: an order is visible once it is routed to you. If you are a fulfilment partner, that includes orders routed to you from other sellers, and your own orders that are routed to another warehouse partner are not visible to your key. ______________________________________________________________________ ## Revocation Revocation is immediate. A revoked key fails on its very next request, with no grace period or cache. ______________________________________________________________________ ## Server-side storage only The API is not CORS-restricted, but a key in browser or mobile code is a key you have published. Store keys server-side only. ______________________________________________________________________ ## Error responses **401 — No key, or a token without the prefix** ``` { "error": "missing_api_key", "message": "An Authorization: Bearer API key is required." } ``` **401 — Key not recognised, or revoked** ``` { "error": "invalid_api_key", "message": "The provided API key is invalid or has been revoked." } ``` ### A wrong key looks like an empty account Authentication failures return 401, but authorisation failures do not return 403. If a key belongs to a different seller than the order you are asking for, you get `404 not_found` — identical to an order that does not exist. If a known-good order ID is 404ing, check which seller the key belongs to before you assume the order is missing. ### Orders waiting on an Omnishelf label are not visible yet Some orders ship on a label that Omnishelf supplies. Until that label is attached, the order is left out of `GET /v1/fulfilment-orders`, and the order, its shipments, parcels, labels and tracking all return `404 not_found`. The order appears as soon as the label is ready. No action is needed on your side. # Conventions ## Base URL ``` https://api.omnishelf.com ``` Every path in this document is appended to that base — `$BASE_URL/v1/ping`, `$BASE_URL/v1/fulfilment-orders`, and so on. The version lives in the path. Within v1 changes are **additive only**: new fields and new endpoints may appear, new webhook event types may be added to `event_types`, but existing fields will not be removed or change meaning. Parse defensively — ignore fields you do not recognise rather than rejecting the payload. ______________________________________________________________________ ## Identifier formats Every identifier the API exposes is a human-readable display ID, never an internal database UUID. The formats nest: a parcel ID contains its shipment ID, which contains its order ID. That means you can always tell what a parcel belongs to from the string alone. | Identifier | Format | Example | Notes | | ------------- | ------------------------------- | -------------------- | ---------------------------------------------------------------------------------- | | `order_id` | `OMN-{SELLER}-{NNNNN}` | `OMN-KONW-00042` | The Omnishelf order ID. The only order identifier on this API. | | `shipment_id` | `{order_id}-{sequence}` | `OMN-KONW-00042-1` | A new order's first shipment is `-1`. A second shipment on the same order is `-2`. | | `parcel_id` | `{shipment_id}-{parcel_number}` | `OMN-KONW-00042-1-2` | The second box of the first shipment of order `OMN-KONW-00042`. | Treat `sequence` as an ordering key, not a count A voided or replaced shipment keeps its number, and the next shipment continues from there, so numbers are never reused. Older orders can have `-2` as their first visible shipment with no `-1`, because a placeholder that was later replaced used up the number. Those IDs stay as they are; they are not renumbered. Path parameters must be URL-encoded if they contain anything unusual, though in practice these formats are already URL-safe. ______________________________________________________________________ ## Timestamps and dates - **Timestamps** are ISO 8601 in UTC with a `Z` suffix and no sub-second precision: `2026-07-28T14:22:00Z`. Fields ending in `_at` are timestamps. - **Dates** are plain `YYYY-MM-DD` with no time component: `order_date` is `2026-07-28`. The date filters on the list endpoint take the same format. - `X-RateLimit-Reset` is the one exception: it is a Unix epoch second count, not ISO 8601. ______________________________________________________________________ ## Pagination List endpoints are cursor-paginated. Every list response carries a `pagination` object with `next_cursor` and `has_more`. When `has_more` is false, `next_cursor` is null and you are done. Cursors are opaque base64 — never construct or modify one; pass back exactly what you received. ``` # First page — default limit is 50, maximum is 200. curl -s "$BASE_URL/v1/fulfilment-orders?limit=100" \ -H "Authorization: Bearer $OMNI_KEY" # Response tail: # "pagination": { "next_cursor": "eyJkIjoiMjAyNi0wNy0yOCIsIm8iOiJPTU4tS09OVy0wMDA0MiJ9", "has_more": true } # Next page — feed next_cursor straight back in. curl -s "$BASE_URL/v1/fulfilment-orders?limit=100&cursor=eyJkIjoiMjAyNi0wNy0yOCIsIm8iOiJPTU4tS09OVy0wMDA0MiJ9" \ -H "Authorization: Bearer $OMNI_KEY" ``` **Full drain loop:** ``` cursor="" while :; do url="$BASE_URL/v1/fulfilment-orders?limit=200" [ -n "$cursor" ] && url="$url&cursor=$cursor" page=$(curl -s "$url" -H "Authorization: Bearer $OMNI_KEY") echo "$page" | jq -c '.data[]' more=$(echo "$page" | jq -r '.pagination.has_more') [ "$more" = "true" ] || break cursor=$(echo "$page" | jq -r '.pagination.next_cursor') done ``` Results are ordered by `order_date` ascending, then by `order_id` ascending, and the cursor encodes that pair. An order whose date changes mid-walk can therefore be missed or repeated — make your ingestion idempotent on `order_id`. # API reference # Fulfilment Orders Base URL All examples assume `BASE_URL=https://api.omnishelf.com` and `OMNI_KEY=omni_sk_YOUR_KEY`. ______________________________________________________________________ ## GET /v1/fulfilment-orders List the orders that are actionable for you, newest date last, with cursor pagination. ### Query parameters | Parameter | Type | Default | Notes | | ----------------- | -------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | `status` | Comma-separated list | `awaiting_shipment,processing` | Allowed: `awaiting_shipment`, `processing`, `on_hold`, `cancelled`. Any other value returns a `validation_error`. | | `order_date_from` | `YYYY-MM-DD` | — | Inclusive lower bound on `order_date`. Must be a real calendar date. | | `order_date_to` | `YYYY-MM-DD` | — | Inclusive upper bound on `order_date`. Must not be earlier than `order_date_from`. | | `warehouse` | String | — | Warehouse code. Matches orders with at least one line item routed to that warehouse. An unknown code returns an empty list, not an error. | | `limit` | Integer | 50 | Maximum 200: a larger value is treated as 200. A value below 1 or not a whole number falls back to 50. | | `cursor` | Opaque string | — | `next_cursor` from the previous page. An unparseable cursor is a `validation_error`. | Unknown query parameters are rejected Only the six parameters above are accepted. Any other parameter, including a misspelt one, returns `400 validation_error` with a hint instead of being silently ignored: ``` { "error": "validation_error", "message": "Unknown query parameter \"order_date_start\" (did you mean \"order_date_from\"?). Supported parameters: status, order_date_from, order_date_to, warehouse, limit, cursor." } ``` Before this change, unknown parameters were silently ignored, so a misspelt filter had no effect. If your integration sends extra parameters, remove them. ### Request ``` curl -s "$BASE_URL/v1/fulfilment-orders?status=processing&order_date_from=2026-07-01&limit=50" \ -H "Authorization: Bearer $OMNI_KEY" ``` ### Response — 200 ``` { "data": [ { "order_id": "OMN-KONW-00042", "status": "processing", "order_date": "2026-07-28", "created_at": "2026-07-28T14:22:00Z", "updated_at": "2026-07-29T09:10:00Z", "parcel_count": 3, "warehouse": { "code": "TETRIS", "name": "Tetris Prep" }, "shipping_address": { "name": "Jordan Ellis", "line1": "1420 Sycamore Ave", "line2": null, "city": "Baltimore", "state": "MD", "postal_code": "21201", "country": "US" }, "items": [ { "sku": "YT-FL-RV-A-028-YE-1", "product_name": "31000 Lumen LED Work Lights", "quantity": 5 }, { "sku": "YT-FL-RV-A-031-BK-2", "product_name": "Heavy Duty Tripod Stand", "quantity": 2 } ], "shipments": [ { "shipment_id": "OMN-KONW-00042-1", "sequence": 1, "carrier": "ups", "parcels": [ { "parcel_id": "OMN-KONW-00042-1-1", "parcel_number": 1, "tracking_number": "1Z0K49Y40312446341", "carrier": "ups", "has_label": true, "weight": 11.0, "weight_unit": "lb", "dimensions": { "length": 17.0, "width": 13.0, "height": 5.0, "unit": "in" } } ] } ] } ], "pagination": { "next_cursor": "eyJkIjoiMjAyNi0wNy0yOCIsIm8iOiJPTU4tS09OVy0wMDA0MiJ9", "has_more": true } } ``` ### Errors - `400 validation_error` — any of: - an unknown query parameter - an unsupported status value - a date that is not `YYYY-MM-DD` or is not a real calendar date (e.g. `2026-02-30`) - `order_date_from` later than `order_date_to` - an invalid cursor - `200` with empty `data` array — no matching orders, including an unknown warehouse code. Not an error. ### Note on `warehouse` `warehouse` reflects the warehouse of the first line item that has been routed. If an order draws stock from more than one, the object gains `"mixed": true` and you should look at the shipments to see how it was split. If the order has not been routed to a warehouse yet, `warehouse` falls back to your main warehouse instead of being empty. The object always has a `code` and `name`, but on an unrouted order it shows where the order is expected to ship from, not a routing decision. It is `null` only if you have no active warehouse, or several. ### Note on `items` `items[].sku` is your own SKU, the same one `GET /v1/inventory` and the seller portal show. A line not yet mapped to one of your products keeps the SKU it arrived with. `product_name` carries no marketplace catalogue identifiers. Webhook payloads use the same values. ______________________________________________________________________ ## GET /v1/fulfilment-orders/{order_id} Fetch one fulfilment order by its Omnishelf order ID. ### Path parameters | Parameter | Example | Notes | | ---------- | ---------------- | ------------------- | | `order_id` | `OMN-KONW-00042` | Omnishelf order ID. | ### Request ``` curl -s "$BASE_URL/v1/fulfilment-orders/OMN-KONW-00042" \ -H "Authorization: Bearer $OMNI_KEY" ``` ### Response — 200 Identical object shape to a single entry in the list response, wrapped in `data` with no pagination. ``` { "data": { "order_id": "OMN-KONW-00042", "status": "processing", "order_date": "2026-07-28", "created_at": "2026-07-28T14:22:00Z", "updated_at": "2026-07-29T09:10:00Z", "parcel_count": 3, "warehouse": { "code": "TETRIS", "name": "Tetris Prep" }, "shipping_address": { "name": "Jordan Ellis", "line1": "1420 Sycamore Ave", "line2": null, "city": "Baltimore", "state": "MD", "postal_code": "21201", "country": "US" }, "items": [ { "sku": "YT-FL-RV-A-028-YE-1", "product_name": "31000 Lumen LED Work Lights", "quantity": 5 } ], "shipments": [ { "shipment_id": "OMN-KONW-00042-1", "sequence": 1, "carrier": "ups", "parcels": [ { "parcel_id": "OMN-KONW-00042-1-1", "parcel_number": 1, "tracking_number": "1Z0K49Y40312446341", "carrier": "ups", "has_label": true, "weight": 11.0, "weight_unit": "lb", "dimensions": { "length": 17.0, "width": 13.0, "height": 5.0, "unit": "in" } } ] } ] } } ``` ### Errors - `404 not_found` — the order does not exist, or belongs to another seller. The two are deliberately indistinguishable. # Labels Base URL All examples assume `BASE_URL=https://api.omnishelf.com` and `OMNI_KEY=omni_sk_YOUR_KEY`. Labels are returned as **base64-encoded PDF inside JSON** — no signed URLs, no binary streaming — with the `parcel_id` and `tracking_number` travelling alongside each one. Decode `label_base64` with a standard base64 decoder (not base64url) and write it to a `.pdf` file. Labels are fetched live on every call Each label request fetches the PDF from the carrier system at call time. Expect about 2 seconds per call that returns a label, against about 0.5 seconds for a `label_not_ready` response. Set your client timeouts accordingly. The PDF bytes differ on every call even when the label has not changed: only the PDF's `/CreationDate` and file `/ID` differ, and the printed label is the same. Don't dedupe labels or detect changes by hashing the file. Key them by `parcel_id` and `tracking_number` instead. ______________________________________________________________________ ## GET /v1/fulfilment-orders/{order_id}/labels Every parcel label on the order, across all its shipments, in one request. ### Path parameters | Parameter | Example | Notes | | ---------- | ---------------- | ------------------- | | `order_id` | `OMN-KONW-00042` | Omnishelf order ID. | ### Request ``` curl -s "$BASE_URL/v1/fulfilment-orders/OMN-KONW-00042/labels" \ -H "Authorization: Bearer $OMNI_KEY" ``` ### Response — 200 ``` { "data": { "order_id": "OMN-KONW-00042", "shipment_count": 2, "parcel_count": 3, "labels_available": 2, "labels": [ { "parcel_id": "OMN-KONW-00042-1-1", "parcel_number": 1, "tracking_number": "1Z0K49Y40312446341", "carrier": "ups", "label_format": "pdf", "label_base64": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50...", "content_length": 24817, "error": null, "shipment_id": "OMN-KONW-00042-1" }, { "parcel_id": "OMN-KONW-00042-2-1", "parcel_number": 1, "tracking_number": "9405511899223197428431", "carrier": "stamps_com", "label_format": null, "label_base64": null, "content_length": null, "error": "label_not_ready", "shipment_id": "OMN-KONW-00042-2" } ] } } ``` A 200 is returned as long as at least one label is available. Per-parcel failures appear on the entry itself as `error` (`label_not_ready` or `fetch_failed`) with null label fields, so one missing label never blocks the rest. Compare `labels_available` to `parcel_count` to know whether to come back later. **Decode every available label to disk:** ``` curl -s "$BASE_URL/v1/fulfilment-orders/OMN-KONW-00042/labels" \ -H "Authorization: Bearer $OMNI_KEY" \ | jq -r '.data.labels[] | select(.label_base64 != null) | .parcel_id + " " + .label_base64' \ | while read -r id b64; do echo "$b64" | base64 -d > "$id.pdf"; done ``` ### Errors - `404 not_found` — unknown order, or another seller's order. - `409 label_not_ready` — the order has no shipments yet, or not a single parcel has a label. - `413 too_many_parcels` — more than 20 parcels on the order. Fetch per shipment instead. ______________________________________________________________________ ## GET /v1/shipments/{shipment_id}/labels Every parcel label for a single shipment. ### Path parameters | Parameter | Example | Notes | | ------------- | ------------------ | ---------------------- | | `shipment_id` | `OMN-KONW-00042-1` | Omnishelf shipment ID. | ### Request ``` curl -s "$BASE_URL/v1/shipments/OMN-KONW-00042-1/labels" \ -H "Authorization: Bearer $OMNI_KEY" ``` ### Response — 200 ``` { "data": { "shipment_id": "OMN-KONW-00042-1", "carrier": "ups", "parcel_count": 2, "labels_available": 2, "labels": [ { "parcel_id": "OMN-KONW-00042-1-1", "parcel_number": 1, "tracking_number": "1Z0K49Y40312446341", "carrier": "ups", "label_format": "pdf", "label_base64": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50...", "content_length": 24817, "error": null } ] } } ``` ### Errors - `404 not_found` — unknown shipment, or another seller's shipment. - `409 label_not_ready` — no parcel on the shipment has a label yet. - `413 too_many_parcels` — more than 20 parcels. Fetch them one at a time. ______________________________________________________________________ ## GET /v1/parcels/{parcel_id}/label One label for one box. The narrowest and cheapest label call. ### Path parameters | Parameter | Example | Notes | | ----------- | -------------------- | -------------------- | | `parcel_id` | `OMN-KONW-00042-1-2` | Omnishelf parcel ID. | ### Request ``` curl -s "$BASE_URL/v1/parcels/OMN-KONW-00042-1-2/label" \ -H "Authorization: Bearer $OMNI_KEY" ``` ### Response — 200 ``` { "data": { "parcel_id": "OMN-KONW-00042-1-2", "parcel_number": 2, "tracking_number": "1Z0K49Y40301020133", "carrier": "ups", "label_format": "pdf", "label_base64": "JVBERi0xLjQKJeLjz9MKNSAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50...", "content_length": 24790 } } ``` ### Errors - `404 not_found` — unknown parcel, or another seller's parcel. - `409 label_not_ready` — the parcel has no label yet. Retryable. - `502 label_fetch_failed` — the label exists but the carrier system did not return it. Retryable. # Tracking Base URL All examples assume `BASE_URL=https://api.omnishelf.com` and `OMNI_KEY=omni_sk_YOUR_KEY`. Two endpoints submit tracking and three read it back: | Endpoint | Use it to | | ------------------------------------------------ | ------------------------------------------------------------- | | `POST /v1/fulfilment-orders/{order_id}/tracking` | Submit tracking when Omnishelf has no shipment records yet. | | `POST /v1/shipments/{shipment_id}/tracking` | Attach tracking numbers to parcels Omnishelf already created. | | `GET /v1/orders/{order_id}/tracking` | Read the whole order: every shipment and every parcel. | | `GET /v1/shipments/{shipment_id}/tracking` | Read one shipment and its parcels. | | `GET /v1/parcels/{parcel_id}/tracking` | Read one parcel. | Both submission endpoints push to the fulfilment system before writing anything locally, so a failed push leaves Omnishelf untouched and you can retry the identical request. Both are **idempotent**: resubmitting a tracking number Omnishelf already holds is a no-op that returns `"result": "unchanged"`. Neither sets order status — Omnishelf recomputes it. ______________________________________________________________________ ## POST /v1/fulfilment-orders/{order_id}/tracking Submit tracking for an order when Omnishelf has **no shipment records yet** — Omnishelf creates the shipments and parcels for you, one parcel per tracking number, grouped by carrier. ### Path parameters | Parameter | Example | Notes | | ---------- | ---------------- | ------------------- | | `order_id` | `OMN-KONW-00042` | Omnishelf order ID. | ### Request body | Field | Type | Required | Notes | | --------------------------- | ------- | ----------- | --------------------------------------------------------------------------- | | `parcels` | Array | Required | Non-empty, at most 50 entries. | | `parcels[].tracking_number` | String | Required | Must be unique within the request (case-insensitive). | | `parcels[].carrier` | String | Required | Carrier name or code, e.g. `ups`, `fedex`, `stamps_com`. | | `parcels[].shipping_cost` | Decimal | Optional | Must be ≥ 0. Requires `currency` when supplied. | | `parcels[].currency` | String | Conditional | Three-letter uppercase ISO 4217, e.g. `USD`. Required with `shipping_cost`. | | `parcels[].weight` | Decimal | Optional | Must be > 0. Requires `weight_unit` when supplied. | | `parcels[].weight_unit` | String | Conditional | One of `lb`, `kg`. Required with `weight`. | | `parcels[].dimensions` | Object | Optional | All-or-nothing: `length`, `width`, `height` (all > 0) and `unit`. | | `parcels[].dimensions.unit` | String | Conditional | One of `in`, `cm`. | ### Request ``` curl -s -X POST "$BASE_URL/v1/fulfilment-orders/OMN-KONW-00042/tracking" \ -H "Authorization: Bearer $OMNI_KEY" \ -H "Content-Type: application/json" \ -d '{ "parcels": [ { "tracking_number": "1Z0K49Y40312446341", "carrier": "ups", "shipping_cost": 14.32, "currency": "USD", "weight": 11.0, "weight_unit": "lb", "dimensions": { "length": 17.0, "width": 13.0, "height": 5.0, "unit": "in" } }, { "tracking_number": "1Z0K49Y40301020133", "carrier": "ups" } ] }' ``` ### Response — 200 ``` { "data": { "order_id": "OMN-KONW-00042", "order_status": "processing", "previous_order_status": "awaiting_shipment", "omnishelf_oms_synced": true, "parcels": [ { "tracking_number": "1Z0K49Y40312446341", "carrier": "ups", "result": "created" }, { "tracking_number": "1Z0K49Y40301020133", "carrier": "ups", "result": "created" } ] } } ``` A fully idempotent resubmission — every tracking number already on the order — returns 200 with `omnishelf_oms_synced: false` and every parcel marked `"result": "unchanged"`. No writes happen at all. Only the first new tracking number reaches the fulfilment system The downstream fulfilment system accepts one tracking number per order on this path, so only the first new parcel's number is pushed through. Every parcel you submit is still recorded in Omnishelf and appears on the tracking endpoints. ### Errors - `400 validation_error` — empty or oversized parcels, malformed JSON, a missing tracking number or carrier, a duplicate tracking number, or a bad unit/currency. Includes an `errors` array keyed by `tracking_number` (see [When the key field is missing](#when-the-key-field-is-missing)). - `404 not_found` — unknown order, or another seller's order. - `409 omnishelf_oms_order_missing` — the order is not linked to the fulfilment system yet. - `409 request_pending` — a cancellation or SKU change request is open on the order. Nothing is saved or sent to the fulfilment system. Wait for Omnishelf to decide the request. - `503 service_unavailable` — Omnishelf couldn't check whether the order may take tracking. Nothing is saved or sent. Retry shortly. - `502 omnishelf_oms_push_failed` — nothing was saved. Retry the same request. ______________________________________________________________________ ## POST /v1/shipments/{shipment_id}/tracking Attach tracking numbers to parcels Omnishelf has **already created** — the case where you were given parcel IDs up front and are filling in the numbers you got at the counter. ### Path parameters | Parameter | Example | Notes | | ------------- | ------------------ | ---------------------- | | `shipment_id` | `OMN-KONW-00042-1` | Omnishelf shipment ID. | ### Request body Same shape as the order-level endpoint with one addition: each entry must name an existing `parcel_id` that belongs to this shipment. | Field | Type | Required | Notes | | --------------------------- | ------- | ----------- | --------------------------------------------------------------- | | `parcels` | Array | Required | Non-empty, at most 50 entries. | | `parcels[].parcel_id` | String | Required | Must already exist on this shipment. Unique within the request. | | `parcels[].tracking_number` | String | Required | — | | `parcels[].carrier` | String | Required | — | | `parcels[].shipping_cost` | Decimal | Optional | ≥ 0, requires `currency`. | | `parcels[].currency` | String | Conditional | ISO 4217 uppercase. | | `parcels[].weight` | Decimal | Optional | > 0, requires `weight_unit`. | | `parcels[].weight_unit` | String | Conditional | `lb` or `kg`. | | `parcels[].dimensions` | Object | Optional | `length`, `width`, `height` (> 0) and `unit` (`in` or `cm`). | ### Request ``` curl -s -X POST "$BASE_URL/v1/shipments/OMN-KONW-00042-1/tracking" \ -H "Authorization: Bearer $OMNI_KEY" \ -H "Content-Type: application/json" \ -d '{ "parcels": [ { "parcel_id": "OMN-KONW-00042-1-1", "tracking_number": "1Z0K49Y40312446341", "carrier": "ups", "weight": 11.0, "weight_unit": "lb" }, { "parcel_id": "OMN-KONW-00042-1-2", "tracking_number": "1Z0K49Y40301020133", "carrier": "ups" } ] }' ``` ### Response — 200 ``` { "data": { "shipment_id": "OMN-KONW-00042-1", "order_id": "OMN-KONW-00042", "order_status": "processing", "previous_order_status": "awaiting_shipment", "omnishelf_oms_synced": true, "parcels": [ { "parcel_id": "OMN-KONW-00042-1-1", "tracking_number": "1Z0K49Y40312446341", "carrier": "ups", "result": "updated" }, { "parcel_id": "OMN-KONW-00042-1-2", "tracking_number": "1Z0K49Y40301020133", "carrier": "ups", "result": "unchanged" } ] } } ``` ### Errors - `400 validation_error` — as above, plus `"parcel_id does not belong to this shipment"`. Entries are keyed by `parcel_id`. - `404 not_found` — unknown shipment, or another seller's shipment. - `409 omnishelf_oms_order_missing` — the parent order is not linked to the fulfilment system. - `409 request_pending` — a cancellation or SKU change request is open on the order. Nothing is saved or sent to the fulfilment system. Wait for Omnishelf to decide the request. - `503 service_unavailable` — Omnishelf couldn't check whether the order may take tracking. Nothing is saved or sent. Retry shortly. - `502 omnishelf_oms_push_failed` — nothing was saved. Retry. ### When the key field is missing Each entry in `errors` is keyed by `tracking_number` on the order-level endpoint and by `parcel_id` on the shipment-level endpoint. When the key field is the one that is missing, the key is `null` and the `message` names the entry by its index in `parcels`: ``` { "error": "validation_error", "message": "One or more parcel updates are invalid.", "errors": [ { "tracking_number": null, "field": "tracking_number", "message": "tracking_number is required (parcels[0])" } ] } ``` Map such an entry back to your request by the index in the message, not by the key. ______________________________________________________________________ ## What happens after you submit tracking Submitting a tracking number **never** sets the order to `shipped` by itself — status is always derived from carrier data. | Step | When | Effect on order status | | ----------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1. Tracking accepted | Synchronously, in your POST | Parcels store the number and carrier; the order is recomputed to `processing`. | | 2. Tracker registered | Synchronously, right after the write (best effort) | The carrier's current milestone is fetched and applied. An already in-transit parcel becomes `in_transit` and the order becomes `shipped` in the same response. | | 3. Live carrier updates | Whenever the carrier scans, via webhook | Parcel `tracking_status` updates and the order rolls up to `shipped`, `exception` or `delivered`. | | 4. Safety net | Every 30 minutes | A background sync registers any tracking number that step 2 could not register and reconciles statuses. | `order_status` in the response reflects the status after step 2, so a parcel already in the carrier network can come back as `shipped` immediately. If the provider is slow or unreachable, you get `processing` and the status corrects itself on the next carrier event or within 30 minutes. ______________________________________________________________________ ## GET /v1/orders/{order_id}/tracking Different path prefix Reading an order's tracking uses `/v1/orders/...`, while everything else order-related uses `/v1/fulfilment-orders/...`. Submitting tracking is `POST /v1/fulfilment-orders/{id}/tracking`; reading it back is `GET /v1/orders/{id}/tracking`. Same order ID, different prefix. The shipment and parcel reads have no such mismatch: `GET /v1/shipments/{id}/tracking` and `GET /v1/parcels/{id}/tracking` use the same prefix as their other endpoints. The whole tracking picture for one order: the stored order status, then every shipment with its computed rollup status, then every parcel with its raw carrier status. ### Path parameters | Parameter | Example | Notes | | ---------- | ---------------- | ------------------- | | `order_id` | `OMN-KONW-00042` | Omnishelf order ID. | ### Request ``` curl -s "$BASE_URL/v1/orders/OMN-KONW-00042/tracking" \ -H "Authorization: Bearer $OMNI_KEY" ``` ### Response — 200 ``` { "data": { "order_id": "OMN-KONW-00042", "status": "shipped", "parcel_count": 3, "shipments": [ { "shipment_id": "OMN-KONW-00042-1", "sequence": 1, "status": "shipped", "carrier": "ups", "parcel_count": 2, "parcels": [ { "parcel_id": "OMN-KONW-00042-1-1", "parcel_number": 1, "tracking_number": "1Z0K49Y40312446341", "carrier": "ups", "tracking_status": "out_for_delivery", "tracking_message": "Out for delivery", "tracking_updated_at": "2026-07-31T12:04:00Z" }, { "parcel_id": "OMN-KONW-00042-1-2", "parcel_number": 2, "tracking_number": "1Z0K49Y40301020133", "carrier": "ups", "tracking_status": "in_transit", "tracking_message": "Arrived at facility", "tracking_updated_at": "2026-07-31T04:41:00Z" } ] }, { "shipment_id": "OMN-KONW-00042-2", "sequence": 2, "status": "processing", "carrier": "stamps_com", "parcel_count": 1, "parcels": [ { "parcel_id": "OMN-KONW-00042-2-1", "parcel_number": 1, "tracking_number": "9405511899223197428431", "carrier": "stamps_com", "tracking_status": "pending", "tracking_message": null, "tracking_updated_at": null } ] } ] } } ``` Note shipment 2: its only parcel is `pending`, so the shipment rolls up to `processing`, not `shipped`. Nested parcels omit `shipment_id` and `order_id` because the surrounding structure already states them. ### Errors - `404 not_found` — unknown order, or another seller's order. ______________________________________________________________________ ## GET /v1/shipments/{shipment_id}/tracking One shipment: its computed rollup status, then every parcel with its raw carrier status. It is the same shipment object as in the order-level read, with `order_id` added so you can walk back up. ### Path parameters | Parameter | Example | Notes | | ------------- | ------------------ | ---------------------- | | `shipment_id` | `OMN-KONW-00042-1` | Omnishelf shipment ID. | ### Request ``` curl -s "$BASE_URL/v1/shipments/OMN-KONW-00042-1/tracking" \ -H "Authorization: Bearer $OMNI_KEY" ``` ### Response — 200 ``` { "data": { "shipment_id": "OMN-KONW-00042-1", "order_id": "OMN-KONW-00042", "sequence": 1, "status": "shipped", "carrier": "ups", "parcel_count": 2, "parcels": [ { "parcel_id": "OMN-KONW-00042-1-1", "parcel_number": 1, "tracking_number": "1Z0K49Y40312446341", "carrier": "ups", "tracking_status": "out_for_delivery", "tracking_message": "Out for delivery", "tracking_updated_at": "2026-07-31T12:04:00Z" }, { "parcel_id": "OMN-KONW-00042-1-2", "parcel_number": 2, "tracking_number": "1Z0K49Y40301020133", "carrier": "ups", "tracking_status": "in_transit", "tracking_message": "Arrived at facility", "tracking_updated_at": "2026-07-31T04:41:00Z" } ] } } ``` `status` is the shipment rollup, computed from this shipment's parcels only. The order may have a different status if it has other shipments. ### Errors - `404 not_found` — `"The requested shipment does not exist."` Unknown shipment, or another seller's shipment. ______________________________________________________________________ ## GET /v1/parcels/{parcel_id}/tracking One box, parcel facts only — with its shipment and order IDs so you can walk back up. ### Path parameters | Parameter | Example | Notes | | ----------- | -------------------- | -------------------- | | `parcel_id` | `OMN-KONW-00042-1-1` | Omnishelf parcel ID. | ### Request ``` curl -s "$BASE_URL/v1/parcels/OMN-KONW-00042-1-1/tracking" \ -H "Authorization: Bearer $OMNI_KEY" ``` ### Response — 200 ``` { "data": { "parcel_id": "OMN-KONW-00042-1-1", "parcel_number": 1, "shipment_id": "OMN-KONW-00042-1", "order_id": "OMN-KONW-00042", "tracking_number": "1Z0K49Y40312446341", "carrier": "ups", "tracking_status": "out_for_delivery", "tracking_message": "Out for delivery", "tracking_updated_at": "2026-07-31T12:04:00Z" } } ``` ### Errors - `404 not_found` — unknown parcel, or another seller's parcel. # Inventory Base URL All examples assume `BASE_URL=https://api.omnishelf.com` and `OMNI_KEY=omni_sk_YOUR_KEY`. Inventory is the other half of the integration: fulfilment tells Omnishelf what left your shelf, inventory tells Omnishelf what is still on it. ______________________________________________________________________ ## How Omnishelf models your stock Omnishelf keeps three figures per SKU per warehouse, and every response is built from them. | Figure | Who sets it | Meaning | | -------------------- | ----------------------------- | ------------------------------------------------------------------------------------------ | | `reported_quantity` | You, via `POST /v1/inventory` | The last absolute count you sent for that SKU and warehouse. | | `allocated_quantity` | Omnishelf, automatically | Units taken by orders received since your last count. Resets to zero on an absolute count. | | `available_quantity` | Derived | `reported` minus `allocated`. Can be negative — that is a signal, not an error. | ### Stock is a reference figure, never a constraint Omnishelf never blocks, clamps or rounds a figure you send, and never refuses an order because availability is low or negative. A negative `available_quantity` means more was ordered than your last count covered — treat it as a prompt to recount, not as a failure. ### Absolute counts only There is one write mode: an absolute count. Every entry carries `quantity` — the number of units physically on the shelf for that SKU at that warehouse right now. Omnishelf does not accept signed deltas; if you know a change rather than a total, add it to your own figure and send the resulting total. Absolute counts are safe to repeat — sending 40 twice still means 40, so a retry after a timeout can never double-apply. ______________________________________________________________________ ## The loop most integrations run 1. **Once, at setup:** call `GET /v1/warehouses` and store the `warehouse_code` values. 1. **On a schedule (nightly is typical):** push the full count for every SKU with `POST /v1/inventory` in batches of up to 200 entries per request. 1. **On each stock event** (a receipt, a damage write-off, a manual correction): recalculate the shelf total for that SKU and push it as a single absolute-count entry. 1. **Whenever you want to reconcile:** call `GET /v1/inventory` with `updated_since` and compare `available_quantity` against your own system. ______________________________________________________________________ ## GET /v1/warehouses Your locations, and the only warehouse identifier the API exposes. Use `warehouse_code` verbatim when reading or writing inventory. ### Query parameters | Parameter | Type | Default | Notes | | ----------- | ------------- | ------- | --------------------------------------------------------------------------------------------------------------------- | | `is_active` | Boolean | — | Exactly `true` or `false`. Anything else is a `validation_error`. Omit to receive both active and inactive locations. | | `limit` | Integer | 50 | Between 1 and 200. Out-of-range values are a `validation_error`, not a silent clamp. | | `cursor` | Opaque string | — | `next_cursor` from the previous page. Results are ordered by `warehouse_code` ascending. | ### Request ``` curl -s "$BASE_URL/v1/warehouses?is_active=true" \ -H "Authorization: Bearer $OMNI_KEY" ``` ### Response — 200 ``` { "data": [ { "warehouse_code": "MAIN-LA", "name": "Main Warehouse LA", "is_active": true, "address": { "line1": "1200 Alameda St", "city": "Los Angeles", "state": "CA", "postal_code": "90021", "country": "US" } } ], "pagination": { "next_cursor": null, "has_more": false } } ``` ### Errors - `400 validation_error` — `is_active` is not `true` or `false`, `limit` is outside 1–200, or the cursor cannot be decoded. - `200` with empty `data` array — no warehouses matching the filter. Warning Inactive locations are rejected on write, so filter with `is_active=true` before caching codes. ______________________________________________________________________ ## GET /v1/inventory One entry per SKU per active warehouse, as Omnishelf currently holds it. This is the read side of reconciliation: it tells you what Omnishelf believes, so you can compare it with what you believe. ### Query parameters | Parameter | Type | Default | Notes | | ---------------- | ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `sku` | String | — | Your own seller SKU. Exact match, case-insensitive. | | `warehouse_code` | String | — | Restricts the result to one location. An unknown code returns an empty list, not an error. | | `updated_since` | ISO 8601 timestamp | — | Only entries reported at or after this moment. Entries never counted carry a null `reported_at` and are excluded whenever this filter is supplied. | | `limit` | Integer | 50 | Between 1 and 200. Out-of-range values are a `validation_error`. | | `cursor` | Opaque string | — | `next_cursor` from the previous page. Ordering is stable across pages. | ### Request ``` curl -s "$BASE_URL/v1/inventory?warehouse_code=MAIN-LA&limit=100" \ -H "Authorization: Bearer $OMNI_KEY" ``` ### Response — 200 ``` { "data": [ { "sku": "JJS021B01V1", "product_title": "Velvet Display Stand - Black", "warehouse_code": "MAIN-LA", "stock_available": 128, "reported_at": "2026-08-24T06:12:04Z", "last_sold_at": "2026-08-25T14:41:10Z" }, { "sku": "LMR12BV1", "product_title": "Stackable 6-Tier Metal Shoe Organizer", "warehouse_code": "MAIN-LA", "stock_available": -3, "reported_at": "2026-08-11T05:00:00Z", "last_sold_at": "2026-08-25T09:02:55Z" } ], "pagination": { "next_cursor": "eyJzIjoiTE1SMTJCVjEi...", "has_more": true } } ``` `stock_available` is the derived figure — your last report minus what orders have consumed since. For a fulfilment partner, entries held for another seller also carry that seller's `seller_code`. GET returns one figure, POST returns three `GET /v1/inventory` currently returns only `stock_available`, which is the same value as `available_quantity`. It does not return `reported_quantity` or `allocated_quantity`. `POST /v1/inventory` returns all three in its `results`. If you need the breakdown, keep the `results` from your last write. The two missing figures may be added to `GET` later as new fields. Existing fields will not change. The second entry above is negative: the shelf was last counted on 11 August and has been selling since, so a recount is overdue. `reported_at` going stale is the earliest warning you get; `last_sold_at` tells you how fast that stock is moving. ### Errors - `400 validation_error` — `limit` outside 1–200, an `updated_since` that is not a valid ISO 8601 timestamp, or an undecodable cursor. - `200` with empty `data` array — no SKU matches the filter, or the seller has no active warehouses. ______________________________________________________________________ ## POST /v1/inventory Report stock to Omnishelf. Up to 200 entries per request, applied as one transaction: either every entry lands or none does. ### Request body | Field | Type | Required | Notes | | -------------------------- | ------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | `entries` | Array | Required | Non-empty, at most 200 items. Each `(sku, warehouse_code)` pair may appear only once per request. | | `entries[].sku` | String | Required | Your own seller SKU, matched case-insensitively against the SKUs linked to your account. Must match exactly one of your products. | | `entries[].warehouse_code` | String | Required | A `warehouse_code` from `GET /v1/warehouses`. Must be an active location. | | `entries[].quantity` | Integer | Required | Absolute count of the shelf. Resets `allocated` to zero. Negatives are accepted and never clamped. | | `entries[].seller_code` | String | Fulfilment partners only | Picks the seller when a SKU you count is shared by more than one seller you fulfil for. Sellers don't send it. | ### Request ``` curl -s -X POST "$BASE_URL/v1/inventory" \ -H "Authorization: Bearer $OMNI_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: nightly-2026-08-26-main-la" \ -d '{ "entries": [ { "sku": "JJS021B01V1", "warehouse_code": "MAIN-LA", "quantity": 128 }, { "sku": "LMR12BV1", "warehouse_code": "MAIN-LA", "quantity": 22 } ] }' ``` ### Response — 200 ``` { "applied": 2, "unchanged": 0, "results": [ { "sku": "JJS021B01V1", "warehouse_code": "MAIN-LA", "status": "applied", "reported_quantity": 128, "allocated_quantity": 0, "available_quantity": 128 }, { "sku": "LMR12BV1", "warehouse_code": "MAIN-LA", "status": "applied", "reported_quantity": 22, "allocated_quantity": 0, "available_quantity": 22 } ] } ``` Read the `results` array rather than assuming your input was echoed back: it reports the post-write state of each line, including the allocation Omnishelf applied. An entry whose figure did not move comes back with `status: "unchanged"` and is counted in `unchanged`, which makes a repeated nightly push cheap to verify. ### Idempotency The `Idempotency-Key` header is optional. When you do send one, reuse within 24 hours replays the stored response instead of writing again. Reuse with a different body returns `409 idempotency_conflict`. ### Errors - `400 validation_error` — with an `errors` array carrying one entry per problem, each naming the `sku`, `warehouse_code` and `field`. Nothing is written when this is returned. ``` { "error": "validation_error", "message": "One or more stock updates are invalid.", "errors": [ { "sku": "UNKNOWN-1", "warehouse_code": "MAIN-LA", "field": "sku", "message": "sku is not linked to this seller" }, { "sku": "JJS021B01V1", "warehouse_code": "OLD-DC", "field": "warehouse_code", "message": "warehouse_code is not an active location for this seller" } ] } ``` - `409 idempotency_conflict` — the `Idempotency-Key` was already used within the last 24 hours with a different request body. Use a new key or resend the original body. - `429 rate_limit_exceeded` — the standard 100 requests per minute per key. Batch entries rather than sending one request per SKU. Unknown SKUs and inactive warehouses are reported identically whether or not the value exists elsewhere in Omnishelf, so the endpoint never discloses another seller's data. Resolve the named lines and resend the whole batch — with the same `Idempotency-Key` only if the body is unchanged. ### SKUs that match more than one product or seller Two `validation_error` messages need a different fix from the rest: - **A SKU set on more than one of your products** is refused, never guessed: `sku matches more than one product; contact Omnishelf support to fix the mapping`. Omnishelf has to fix the mapping. One bad line rejects the whole batch, so drop that line and resend the rest. - **Fulfilment partners** counting a SKU that two sellers share, without `seller_code`, get `sku belongs to more than one seller; add seller_code`. # Utility Base URL All examples assume `BASE_URL=https://api.omnishelf.com` and `OMNI_KEY=omni_sk_YOUR_KEY`. ______________________________________________________________________ ## GET /v1/ping Health and identity probe. Confirms a key is valid and reports the API version and the webhook event types you can register. Touches no order data. Use this to: - Confirm a new key is live before wiring anything else up. - Read the live `event_types` list rather than hard-coding it — the list grows without a version bump. ### Request ``` curl -s "$BASE_URL/v1/ping" \ -H "Authorization: Bearer $OMNI_KEY" ``` ### Response — 200 ``` { "ok": true, "version": "v1", "event_types": [ "fulfilment_order.created", "fulfilment_order.cancelled", "fulfilment_order.on_hold", "fulfilment_order.hold_released", "fulfilment_order.in_transit", "fulfilment_order.delivered", "fulfilment_order.undelivered", "fulfilment_order.status_changed" ] } ``` Five of these (`hold_released`, `in_transit`, `delivered`, `undelivered`, `status_changed`) belong to the order lifecycle. You can register for them, but they are sent only once the order lifecycle is switched on for your account. See [Webhooks](https://docs.omnishelf.com/webhooks/overview/#event-catalogue). ### Errors `401 missing_api_key` / `401 invalid_api_key` — the only failures possible here. Ping is still authenticated and still counts against your rate limit. # Webhooks # Webhooks — Overview & Events Webhooks are the push half of the API. Omnishelf POSTs a signed JSON envelope to a URL you host when an order changes state. Registration is done by Omnishelf staff: give them the callback URL and the event types you want. Each event type is registered as its own webhook, and every webhook gets its own signing secret: a delivery verifies only with the secret of the webhook it was sent for. ______________________________________________________________________ ## Event catalogue Three event types exist today, all driven by a single database trigger on the order. Read the "from" column carefully — `cancelled` and `on_hold` only fire for an order you were already told about. | Event | Fires when status changes to | From | What it means for you | | ---------------------------- | ---------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fulfilment_order.created` | its first visible status | Order creation | The order now exists and is readable on the API. Sent once per order, as soon as you can see it, usually before any label exists. Where orders are routed, that is when it is routed to you. | | `fulfilment_order.cancelled` | `cancelled` | any announced status | Stop. Do not ship. Destroy or void any label already printed. Includes `previous_status`. | | `fulfilment_order.on_hold` | `on_hold` | any announced status | Pause. Hold the boxes. No event is sent when the hold is released, so poll `GET /v1/fulfilment-orders/{order_id}` until its status moves on. Includes `previous_status`. | You are only sent event types you registered for, and only for the orders you fulfil, once they are routed to you. Read the live list from `event_types` on `GET /v1/ping` rather than hard-coding it. `GET /v1/ping` lists every event type you can register, eight in all. Besides the three above, five belong to the order lifecycle: `fulfilment_order.hold_released`, `.in_transit`, `.delivered`, `.undelivered` and `.status_changed`. They are sent only once the order lifecycle is switched on for your account. Until then, the three events above are the only ones you receive. ______________________________________________________________________ ## When nothing is sent The single largest source of integration surprises. Every case below is a moment where an integrator reasonably expects an event and does not get one: | Situation | Why no event | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | The order ships (`processing` → `shipped`) | There is no `fulfilment_order.shipped` event. Poll `GET /v1/orders/{order_id}/tracking` instead. | | The order is delivered (`shipped` → `delivered`) | No event emitted. Same workaround. | | A parcel hits an exception | Carrier problems move status to `exception` silently. Only tracking reads reveal it. | | The order becomes actionable (`awaiting_shipment` → `processing`) | You were already told about the order when it was created. Poll the order or its labels to see when a label appears. | | The order has no `order_id` yet | Nothing is sent until the order is assigned its Omnishelf order identifier; the `created` event follows immediately afterwards. | | Anything other than status changes | A changed address, an added or removed line item, a new tracking number, a regenerated label — none of these emit an event. | | The status is re-set to the same value | A write of `processing` over `processing` is not a change and is ignored. | | The order has no seller assigned | Nobody to notify, so nothing is sent. | | You have no active registration for that event type | A registration that is inactive, or missing for that specific event type, is a silent no-op. | ### Webhooks start the work; polling finishes it The dependable pattern is: react to `fulfilment_order.created` to begin fulfilment, then poll tracking for the orders you have in flight until each reaches `delivered`, `exception`, or `cancelled`. **Do not wait for a completion event — there isn't one.** # Webhooks — Payload Shape & Headers ______________________________________________________________________ ## Payload shape Every event is the same four-field envelope. The `data` object is byte-for-byte the same shape as the object returned by `GET /v1/fulfilment-orders/{order_id}` — it is built by the same code path and passed through the same serializer, so the two cannot drift apart. Write one parser and use it for both. ``` { "event": "fulfilment_order.created", "event_id": "evt_9f2c1b4e-6d31-4f7a-9c0e-8b2a5d41e770", "occurred_at": "2026-07-29T09:10:04Z", "data": { "order_id": "OMN-KONW-00042", "status": "processing", "order_date": "2026-07-28", "created_at": "2026-07-28T14:22:00Z", "updated_at": "2026-07-29T09:10:00Z", "parcel_count": 3, "warehouse": { "code": "TETRIS", "name": "Tetris Prep" }, "shipping_address": { "name": "Jordan Ellis", "line1": "1420 Sycamore Ave", "line2": null, "city": "Baltimore", "state": "MD", "postal_code": "21201", "country": "US" }, "items": [ { "sku": "YT-FL-RV-A-028-YE-1", "product_name": "31000 Lumen LED Work Lights", "quantity": 5 } ], "shipments": [ { "shipment_id": "OMN-KONW-00042-1", "sequence": 1, "carrier": "ups", "parcels": [ { "parcel_id": "OMN-KONW-00042-1-1", "parcel_number": 1, "tracking_number": "1Z0K49Y40312446341", "carrier": "ups", "has_label": true, "weight": 11.0, "weight_unit": "lb", "dimensions": { "length": 17.0, "width": 13.0, "height": 5.0, "unit": "in" } } ] } ] } } ``` | Field | Type | Notes | | ------------- | --------- | ------------------------------------------------------------------ | | `event` | String | One of the event types in the catalogue. | | `event_id` | String | Unique per event, prefixed `evt_`. Use it as your idempotency key. | | `occurred_at` | Timestamp | ISO 8601 UTC with a `Z` suffix. | | `data` | Object | The full fulfilment order. Same shape as the `GET` response. | On `fulfilment_order.cancelled` and `fulfilment_order.on_hold`, `data` carries one extra field, `previous_status`, holding the status the order left: ``` { "event": "fulfilment_order.cancelled", "event_id": "evt_31c8a0d5-77bd-4a2e-8f19-1cf3f0b6a942", "occurred_at": "2026-07-30T16:48:11Z", "data": { "order_id": "OMN-KONW-00042", "status": "cancelled", "previous_status": "processing", ... } } ``` ______________________________________________________________________ ## Delivery headers Every delivery carries `Content-Type: application/json` plus four Omnishelf headers: | Header | Example | What it is for | | ------------------------ | -------------------------- | -------------------------------------------------------------------------------------- | | `X-Omnishelf-Event-Id` | `evt_9f2c1b4e-...` | Same value as `event_id` in the body. Deduplicate on this without parsing the payload. | | `X-Omnishelf-Event-Type` | `fulfilment_order.created` | Route to the right handler before parsing. | | `X-Omnishelf-Signature` | `sha256=8f14e45f...` | HMAC-SHA256 of the raw body, hex-encoded, with a `sha256=` prefix. | | `X-Omnishelf-Timestamp` | `1785488503` | Unix epoch seconds at send time. Useful for rejecting very stale deliveries. | ``` POST /omnishelf/webhooks HTTP/1.1 Host: api.your-company.com Content-Type: application/json X-Omnishelf-Event-Id: evt_9f2c1b4e-6d31-4f7a-9c0e-8b2a5d41e770 X-Omnishelf-Event-Type: fulfilment_order.created X-Omnishelf-Signature: sha256=8f14e45fceea167a5a36dedd4bea2543a1b4e2c9d0f7361b8e4c2a9d13f5e074 X-Omnishelf-Timestamp: 1785488503 ``` The signature is not timestamped into the signed string: it is a plain HMAC over the body alone. The timestamp header is informational, so use it for staleness checks but do not include it in the HMAC input. # Webhooks — Signature Verification & Responding ______________________________________________________________________ ## Verifying the signature Compute `HMAC-SHA256(secret, raw_request_body)`, hex-encode it, prefix it with `sha256=`, and compare against `X-Omnishelf-Signature` using a **constant-time comparison**. Reject with 401 on mismatch. `secret` is the signing secret of the webhook the delivery was sent for. Each webhook has its own, so a delivery verifies only with that one. With several webhooks, give each its own callback URL and keep a secret per URL; if two webhooks share a URL, accept a delivery that verifies with either of their secrets. Sign the raw bytes, never a re-serialized body This is the mistake nearly every integrator makes once. If your framework parses the JSON for you and you then re-serialize it to compute the HMAC, key order, whitespace and number formatting will differ from what Omnishelf signed — and **every single signature will fail**, with no clue as to why. Capture the raw bytes **before** any parsing: `request.get_data()` in Flask, `express.raw()` in Express, `php://input` in PHP. Parse the JSON only after the signature has checked out. ### Python (Flask) ``` import hmac, hashlib, os # The signing secret of the webhook registered for this URL. Each webhook # has its own secret; with several webhooks, keep one secret per URL. SECRET = os.environ["OMNISHELF_WEBHOOK_SECRET"].encode() def verify(raw_body: bytes, signature_header: str) -> bool: expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature_header or "") from flask import Flask, request app = Flask(__name__) @app.post("/omnishelf/webhooks") def receive(): raw = request.get_data() # raw bytes — do NOT use request.json here if not verify(raw, request.headers.get("X-Omnishelf-Signature", "")): return "", 401 event = request.get_json() enqueue(event["event_id"], event) # process asynchronously return "", 200 ``` ______________________________________________________________________ ## Responding **Answer 2xx within 10 seconds.** Omnishelf aborts the request at 10 seconds and treats the timeout as a failed attempt. Any status outside 200–299 is a failure. **Acknowledge first, process afterwards.** Verify the signature, put the event on a queue, return 200. Never do label fetching, database work or third-party calls inside the request. **Be idempotent.** The same `event_id` can legitimately arrive more than once — a retry after your 200 was lost in transit, for example. Store processed `event_id` values and discard duplicates. **Do not rely on ordering.** Events are dispatched independently. A `cancelled` event can reach you before you finished processing the `created` one. Response bodies are captured for debugging (first 2000 characters), so a short plain-text reason on a failure helps Omnishelf staff help you. # Webhooks — Delivery & Retries A non-2xx response, a connection failure, or a timeout schedules a retry. Six attempts in total, spanning just under 15 hours, after which the delivery is abandoned. | Attempt | Delay after previous | Cumulative time after the event | | ------- | -------------------- | ------------------------------- | | 1 | Immediate | 0 | | 2 | 1 minute | ~1 minute | | 3 | 5 minutes | ~6 minutes | | 4 | 30 minutes | ~36 minutes | | 5 | 2 hours | ~2 hours 36 minutes | | 6 | 12 hours | ~14 hours 36 minutes | | — | No further attempts | Delivery abandoned | Every attempt is recorded with its response status, response body, and error message. If you had an outage and missed events entirely, ask Omnishelf staff — the delivery history is queryable, and catching up on missed orders is also just: ``` curl -s "$BASE_URL/v1/fulfilment-orders?status=processing" \ -H "Authorization: Bearer $OMNI_KEY" ``` # Webhooks — Testing There is no separate sandbox environment and no self-service test button. Testing is done against the live API with a real key, plus test events fired by Omnishelf staff — which is why getting your receiver right locally first matters. ______________________________________________________________________ ## Requesting a test event 1. Register your callback URL with Omnishelf staff and get that webhook's signing secret. 1. Ask them to send a test event for the event type you are implementing. It is delivered to your registered URL with the same headers, the same signature scheme and the same nested `shipments → parcels` shape as a real event, using representative sample data. Confirm what you received: a valid signature, an `event_id` starting `evt_`, and a `data` object your parser handles without special-casing. ______________________________________________________________________ ## Verifying signature handling before going live Prove your verification rejects as well as accepts. A verifier that accepts everything passes the happy-path test just as convincingly as a correct one. ``` # Generate a correct signature locally and replay it at your own endpoint. BODY='{"event":"fulfilment_order.created","event_id":"evt_test_1","occurred_at":"2026-08-01T10:00:00Z","data":{"order_id":"OMN-KONW-00042","status":"processing"}}' SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$OMNISHELF_WEBHOOK_SECRET" -hex | awk '{print $2}') # 1. Correct signature — expect 200. curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8000/omnishelf/webhooks \ -H "Content-Type: application/json" \ -H "X-Omnishelf-Event-Id: evt_test_1" \ -H "X-Omnishelf-Event-Type: fulfilment_order.created" \ -H "X-Omnishelf-Signature: sha256=$SIG" \ -H "X-Omnishelf-Timestamp: $(date +%s)" \ -d "$BODY" # 2. Tampered body, unchanged signature — expect 401. curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8000/omnishelf/webhooks \ -H "Content-Type: application/json" \ -H "X-Omnishelf-Signature: sha256=$SIG" \ -d '{"event":"fulfilment_order.created","event_id":"evt_test_1","data":{"order_id":"OMN-EVIL-00001"}}' # 3. Same event twice — expect 200 both times, and exactly one order processed. ``` ______________________________________________________________________ ## Sending a full sample `fulfilment_order.created` To test order creation end to end, send your endpoint a complete event, the same as a real one. Set three values first: - `WEBHOOK_URL` — your registered callback URL. - `OMNISHELF_WEBHOOK_SECRET` — the `whsec_…` signing secret of the webhook registered for that URL. - In the body, set `warehouse.code` to one of your warehouse codes (see `GET /v1/warehouses`) and `items[].sku` to a SKU that exists in your system. ``` WEBHOOK_URL='https://your-system.example.com/omnishelf/webhooks' OMNISHELF_WEBHOOK_SECRET='whsec_...' EVENT_ID="evt_test_$(date +%s)" ORDER_ID="OMN-TEST-$(date +%s)" # Write the body to a file so the bytes you sign are exactly the bytes you send. cat > event.json <