This is the full developer documentation for Ship Jay # Ship Jay developer docs > Buy shipping labels, track packages and manage returns from your own code or an AI agent with the Ship Jay API, webhooks and MCP server. Ship Jay lets you buy shipping labels, track packages and handle returns from your own systems. The API, the webhooks and the MCP server all act on the same account as the Ship Jay app: the same wallet, the same shipping rules and the same audit trail. Anything an API key does shows up in your audit trail. The base URL is `https://shipjay.com/api/v1`. Requests use JSON, amounts are integer cents, and every request carries a bearer key. ## Choose your path [Section titled “Choose your path”](#choose-your-path) [Quick start](/docs/quickstart)Create a key, quote a package, buy a label and read tracking in about ten minutes. [Webhooks](/docs/webhooks)Get signed events for labels, tracking and returns instead of polling. [MCP and AI agents](/docs/mcp)Let an assistant quote shipments and, with your approval, buy labels. [API reference](/docs/api)Every endpoint, generated from the same code that serves the API. ## Path to production [Section titled “Path to production”](#path-to-production) 1. **Create a key with only what it needs.** Pick the smallest set of permissions, set a per label and a per day spending limit if the key can buy, and set an expiry date. See [Authentication and API keys](/docs/authentication). 2. **Build the quote then buy flow.** Quote a shipment, choose a rate, then buy it with an `Idempotency-Key` so a retry can never buy twice. See [Buying labels safely](/docs/buying-labels). 3. **Listen for events.** Add a webhook endpoint, verify the `ShipJay-Signature` header and answer with a 2xx status quickly. See [Webhooks](/docs/webhooks). 4. **Handle failures.** Read the error code, respect `Retry-After` on a 429 and log the `request_id` of every failed call. See [Errors](/docs/errors) and [Rate limits and abuse protection](/docs/rate-limits). ## Developer tools [Section titled “Developer tools”](#developer-tools) [OpenAPI file](/api/v1/openapi.json)Generate a client in any language from the machine readable description of the API. [llms.txt](/docs/llms.txt)These docs as plain text for language models, with a full version in one file. [MCP server](/docs/mcp)Endpoint, client configuration and the list of tools. [Agent skills](/docs/agent-skills)A skill file that teaches AI coding assistants how to use the Ship Jay API safely. ## Conventions [Section titled “Conventions”](#conventions) * Amounts are integers in cents. `1250` means 12.50 in the account currency. * Timestamps are ISO 8601 strings in UTC. * Lists return `{ "data": [...], "next_cursor": "..." }`. Pass `next_cursor` back as `cursor` to get the next page. A `null` cursor means there are no more rows. * Every response has an `X-Request-Id` header. Quote it when you contact support. # Quick start > Create an API key, quote a package, buy a label with an idempotency key, download it and read tracking. This guide takes you from nothing to a purchased label. You need a Ship Jay account with money in the wallet and at least one saved ship-from address. Buying spends real money Buying a label takes money from your wallet. Use a key with a low per label limit while you test, and refund labels you do not need (see [Buying labels safely](/docs/buying-labels)). 1. **Create an API key** In the app, open Settings, then Developers, and create a key. For this guide tick `shipments:read`, `shipments:write` and `shipments:buy`. Give it a per label limit such as 20.00 while you test. The key looks like `sj_...` and is shown once, so copy it now. Keep it out of source control and out of browsers. The API sends no CORS headers on purpose: call it from a server. * curl ```bash export SHIPJAY_KEY="sj_..." ``` * Node ```js // Node 18 or newer. Run with: SHIPJAY_KEY=sj_... node quickstart.mjs const BASE = "https://shipjay.com/api/v1"; async function qp(path, { method = "GET", body, headers = {} } = {}) { const res = await fetch(BASE + path, { method, headers: { Authorization: `Bearer ${process.env.SHIPJAY_KEY}`, "Content-Type": "application/json", ...headers, }, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res; } ``` * Python ```python # pip install requests. Run with: SHIPJAY_KEY=sj_... python quickstart.py import os import requests BASE = "https://shipjay.com/api/v1" session = requests.Session() session.headers["Authorization"] = f"Bearer {os.environ['SHIPJAY_KEY']}" def qp(method, path, **kwargs): res = session.request(method, BASE + path, timeout=30, **kwargs) if not res.ok: raise RuntimeError(f"{res.status_code} {res.text}") return res ``` 2. **Check the key** `GET /account` works with any valid key. It shows the organization, its currency and what the key may do. * curl ```bash curl https://shipjay.com/api/v1/account \ -H "Authorization: Bearer $SHIPJAY_KEY" ``` * Node ```js console.log(await (await qp("/account")).json()); ``` * Python ```python print(qp("GET", "/account").json()) ``` ```json { "organization": { "id": "5c2d0f4e-8a31-4b6e-9d57-1f0a2c9b7e44", "name": "Northfield Candle Co", "currency": "USD" }, "key": { "id": "e19b7a62-3c05-4d8f-a6b1-90d4f27c5a13", "name": "Quick start", "scopes": ["shipments:read", "shipments:write", "shipments:buy"] } } ``` 3. **Find your ship-from address** A quote needs the id of one of your saved ship-from addresses. * curl ```bash curl https://shipjay.com/api/v1/ship-from \ -H "Authorization: Bearer $SHIPJAY_KEY" ``` * Node ```js const { data: addresses } = await (await qp("/ship-from")).json(); const from = addresses.find((a) => a.is_default) ?? addresses[0]; ``` * Python ```python addresses = qp("GET", "/ship-from").json()["data"] origin = next((a for a in addresses if a["is_default"]), addresses[0]) ``` ```json { "data": [ { "id": "0b8e6d1a-47c2-4f93-b5a8-6d2e91f3c0a7", "nickname": "Workshop", "name": "Northfield Candle Co", "company": "Northfield Candle Co", "street1": "410 Mill Street", "street2": null, "city": "Northfield", "state": "MN", "zip": "55057", "country": "US", "phone": "5075550142", "is_default": true, "use_as_return": true } ], "next_cursor": null } ``` 4. **Get rates** Quoting does not spend money. It saves the shipment, rates every enabled carrier and applies your shipping rules. The request is limited to 30 per minute per key. * curl ```bash curl https://shipjay.com/api/v1/shipments/quote \ -H "Authorization: Bearer $SHIPJAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "from_address_id": "0b8e6d1a-47c2-4f93-b5a8-6d2e91f3c0a7", "to": { "name": "Avery Quinn", "street1": "88 Harbor Lane", "city": "Portland", "state": "ME", "zip": "04101", "country": "US" }, "parcel": { "packaging_type": "box", "length_in": 10, "width_in": 8, "height_in": 4, "weight_oz": 24 }, "options": { "reference1": "order-1001" } }' ``` * Node ```js const quote = await ( await qp("/shipments/quote", { method: "POST", body: { from_address_id: from.id, to: { name: "Avery Quinn", street1: "88 Harbor Lane", city: "Portland", state: "ME", zip: "04101", country: "US", }, parcel: { packaging_type: "box", length_in: 10, width_in: 8, height_in: 4, weight_oz: 24 }, options: { reference1: "order-1001" }, }, }) ).json(); ``` * Python ```python quote = qp("POST", "/shipments/quote", json={ "from_address_id": origin["id"], "to": { "name": "Avery Quinn", "street1": "88 Harbor Lane", "city": "Portland", "state": "ME", "zip": "04101", "country": "US", }, "parcel": {"packaging_type": "box", "length_in": 10, "width_in": 8, "height_in": 4, "weight_oz": 24}, "options": {"reference1": "order-1001"}, }).json() ``` The response is `201`. The rate ids are only valid for this shipment, and only until `expires_in_seconds` runs out. ```json { "status": "quoted", "shipment_id": "2f6c3a9e-5d1b-4c7e-9a41-8b0d6e7f1c20", "zone": 5, "rates": [ { "id": "r_5f3a91", "carrier": "USPS", "service": "Ground Advantage", "currency": "USD", "price_cents": 864, "retail_cents": 1130, "delivery_days": 4, "delivery_date": null, "guaranteed": false, "badges": ["cheapest"] }, { "id": "r_c8d204", "carrier": "UPS", "service": "Ground", "currency": "USD", "price_cents": 1212, "retail_cents": 1590, "delivery_days": 3, "delivery_date": null, "guaranteed": false, "badges": ["best"] } ], "suggested_rate_id": null, "suggested_by_rule": null, "applied_rules": [], "partial": false, "expires_in_seconds": 600 } ``` If `partial` is `true`, some carriers did not answer and the list can be shorter than usual. If the response has `"status": "needs_address_confirmation"` instead, the carrier proposed a corrected address; see the API reference for that shape. 5. **Buy the label** Buying needs an `Idempotency-Key` header of 8 to 200 characters. Generate one per purchase and keep it with your order. If a request times out, send it again with the same key: you get the first result back and are never charged twice. * curl ```bash curl https://shipjay.com/api/v1/shipments/2f6c3a9e-5d1b-4c7e-9a41-8b0d6e7f1c20/buy \ -H "Authorization: Bearer $SHIPJAY_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1001-label-1" \ -d '{ "rate_id": "r_5f3a91" }' ``` * Node ```js import { randomUUID } from "node:crypto"; const bought = await ( await qp(`/shipments/${quote.shipment_id}/buy`, { method: "POST", headers: { "Idempotency-Key": `order-1001-${randomUUID()}` }, body: { rate_id: quote.rates[0].id }, }) ).json(); ``` * Python ```python import uuid bought = qp( "POST", f"/shipments/{quote['shipment_id']}/buy", headers={"Idempotency-Key": f"order-1001-{uuid.uuid4()}"}, json={"rate_id": quote["rates"][0]["id"]}, ).json() ``` ```json { "shipment_id": "2f6c3a9e-5d1b-4c7e-9a41-8b0d6e7f1c20", "status": "purchased", "tracking_code": "9400100000000000000000", "balance_after_cents": 18636, "duplicate": false } ``` A `status` of `purchasing` means the purchase is still finishing. Repeat the same request later, or read the shipment, until it says `purchased`. 6. **Download the label** The label is returned as a file in the format chosen for the shipment (PDF by default). * curl ```bash curl https://shipjay.com/api/v1/shipments/2f6c3a9e-5d1b-4c7e-9a41-8b0d6e7f1c20/label \ -H "Authorization: Bearer $SHIPJAY_KEY" \ -o label.pdf ``` * Node ```js import { writeFile } from "node:fs/promises"; const label = await qp(`/shipments/${bought.shipment_id}/label`); await writeFile("label.pdf", Buffer.from(await label.arrayBuffer())); ``` * Python ```python label = qp("GET", f"/shipments/{bought['shipment_id']}/label") with open("label.pdf", "wb") as f: f.write(label.content) ``` 7. **Read tracking** * curl ```bash curl https://shipjay.com/api/v1/shipments/2f6c3a9e-5d1b-4c7e-9a41-8b0d6e7f1c20/tracking \ -H "Authorization: Bearer $SHIPJAY_KEY" ``` * Node ```js const tracking = await (await qp(`/shipments/${bought.shipment_id}/tracking`)).json(); console.log(tracking.status, tracking.tracking_url); ``` * Python ```python tracking = qp("GET", f"/shipments/{bought['shipment_id']}/tracking").json() print(tracking["status"], tracking["tracking_url"]) ``` ```json { "tracking_code": "9400100000000000000000", "status": "pre_transit", "tracking_url": "https://shipjay.com/t/k3v9...", "events": [] } ``` ## Next steps [Section titled “Next steps”](#next-steps) * Move from polling to [webhooks](/docs/webhooks). * Read [Buying labels safely](/docs/buying-labels) before you put the buy call in production code. * Let an assistant do this for you with the [MCP server](/docs/mcp). # Agent skills > Install the Ship Jay API skill for Claude Code and other AI coding assistants, and use llms.txt and the MCP server. An agent skill is a short markdown file that tells an AI coding assistant how to do a job. The Ship Jay skill teaches an assistant the authentication, the quote then buy flow, idempotency, error handling and the safety rules for spending, so the code it writes against the API is correct the first time. The skill is a single file: ```text https://shipjay.com/docs/skills/shipjay-api/SKILL.md ``` [Open SKILL.md](/docs/skills/shipjay-api/SKILL.md)Read the skill as plain markdown. [llms.txt](/docs/llms.txt)An index of these docs for language models. [llms-full.txt](/docs/llms-full.txt)All pages in one text file. [MCP server](/docs/mcp)Give an agent live tools instead of code to write. ## Install it [Section titled “Install it”](#install-it) * Claude Code Claude Code reads skills from folders in `~/.claude/skills/` (for all projects) or `.claude/skills/` in a project. ```bash mkdir -p ~/.claude/skills/shipjay-api curl -o ~/.claude/skills/shipjay-api/SKILL.md \ https://shipjay.com/docs/skills/shipjay-api/SKILL.md ``` Start a new session and ask for something that involves shipping labels. Claude Code loads the skill when the task matches its description. * Other assistants Most coding assistants can read a markdown file of instructions. Download the file and add it where your tool keeps rules or context, for example a project rules file, an `AGENTS.md`, or a custom instructions field. ```bash curl -o SHIPJAY.md https://shipjay.com/docs/skills/shipjay-api/SKILL.md ``` Then point your assistant at it, or paste its contents into the instructions. ## What the skill covers [Section titled “What the skill covers”](#what-the-skill-covers) * Base URL, bearer auth and where keys come from. * The quote then buy flow, and why a rate id cannot be reused after it expires. * The `Idempotency-Key` header on purchases and how to build a good key. * Error codes, `Retry-After` and request ids. * Rules for spending money: never buy without approval, never log keys, prefer limited keys. The skill does not contain a key and never needs one. Your own code reads the key from an environment variable. ## Language models and the docs [Section titled “Language models and the docs”](#language-models-and-the-docs) * `/docs/llms.txt` lists the pages with a short description of the product. * `/docs/llms-full.txt` has the whole documentation in one file. * Every page has a **Copy Markdown** button, and a **View in Markdown** link, for pasting a page into an assistant. * The [OpenAPI file](https://shipjay.com/api/v1/openapi.json) describes every endpoint for code generators. Tip If your assistant supports MCP, the [MCP server](/docs/mcp) is often better than a skill for operational work such as checking tracking or quoting a shipment. Use the skill when the assistant is writing integration code. # Authentication and API keys > How API keys work, which permissions exist, how to limit a key and how to rotate one. Every request to `https://shipjay.com/api/v1` carries an API key as a bearer token: ```http Authorization: Bearer sj_... ``` A key looks like `sj_` followed by a 12 character prefix, an underscore and a long secret. The prefix identifies the key in the app; the secret is shown once when you create the key. Only a hash of the secret is stored, so Ship Jay cannot show you a key again. If you lose one, create a new key and revoke the old one. A missing, malformed, unknown, revoked or expired key all return the same `401` with the code `unauthenticated`, so a caller cannot learn which part was wrong. ## Create a key [Section titled “Create a key”](#create-a-key) Open Settings, then Developers in the app and choose to create a key. You set: * **Name.** Anything that helps you recognize the key later, such as the system that uses it. * **Permissions.** At least one. See the table below. * **Per label limit.** The most a single purchase made with this key may cost. * **Per day limit.** The most this key may spend in a day. The day follows your account time zone. * **Expiry date.** Optional. After this moment the key stops working. * **IP allowlist.** Optional. Up to 20 entries, each an IP address or an IPv4 range such as `203.0.113.0/24`. An account can have at most 50 active keys. ## Permissions [Section titled “Permissions”](#permissions) Each endpoint needs one permission. A key without it gets `403` with the code `forbidden`. `GET /account` needs none and works with any valid key. | Permission | What it allows | | ----------------- | ----------------------------------- | | `shipments:read` | Read shipments, labels and tracking | | `shipments:write` | Get rates and create shipments | | `shipments:buy` | Buy labels (spends wallet money) | | `refunds:write` | Request label refunds | | `wallet:read` | Read the wallet balance and ledger | | `returns:read` | Read returns | | `returns:write` | Approve, reject and receive returns | | `rules:read` | Read shipping rules | | `rules:write` | Create and change shipping rules | | `events:read` | Read the event feed | | `webhooks:manage` | Manage webhook endpoints | Caution `shipments:buy` spends wallet money and is separate from `shipments:write` on purpose. Give it only to keys that must buy labels, and always set spending limits on those keys. ## Check what a key can do [Section titled “Check what a key can do”](#check-what-a-key-can-do) * curl ```bash curl https://shipjay.com/api/v1/account \ -H "Authorization: Bearer $SHIPJAY_KEY" ``` * Node ```js const res = await fetch("https://shipjay.com/api/v1/account", { headers: { Authorization: `Bearer ${process.env.SHIPJAY_KEY}` }, }); console.log(await res.json()); ``` * Python ```python import os, requests res = requests.get( "https://shipjay.com/api/v1/account", headers={"Authorization": f"Bearer {os.environ['SHIPJAY_KEY']}"}, timeout=30, ) print(res.json()) ``` The response lists the key's `scopes`. The MCP server uses the same list to decide which tools to offer. ## Spending limits [Section titled “Spending limits”](#spending-limits) Limits are checked inside the purchase, before any money moves. A purchase over the per label limit, or one that would take the key over its day total, fails with `402` and the code `spend_limit`. The `details` field has the limit in cents, and for the day limit also what the key has spent so far. Your account has its own daily limit and wallet balance, which apply in addition to the key limits. ## Expiry and IP allowlist [Section titled “Expiry and IP allowlist”](#expiry-and-ip-allowlist) * A key past its expiry date returns `401 unauthenticated`. * If a key has an allowlist, a request from any other address returns `403 forbidden`. The address is the one reported by the proxy in front of Ship Jay. If the deployment does not report one, a key with an allowlist cannot be used. ## Rotate a key [Section titled “Rotate a key”](#rotate-a-key) 1. Create a new key with the same permissions and limits. 2. Deploy the new key to the systems that use it. 3. Check in the app that the old key's last used time stops moving. 4. Revoke the old key. Revoked keys stop working immediately. ## Keep keys safe [Section titled “Keep keys safe”](#keep-keys-safe) * Call the API from a server. It sends no CORS headers, so browsers cannot call it, and a key in front end code is a key anyone can copy. * Store keys in a secrets manager or environment variable, never in source control. * Use one key per system so you can revoke one without breaking the others. * Repeated requests with bad keys from one IP address are blocked for 15 minutes. See [Rate limits and abuse protection](/docs/rate-limits). # Buying labels safely > The quote then buy flow, idempotency keys, spending limits, rate expiry, partial results and refunds. Buying a label spends money from your wallet. The API splits the work in two calls so you can look before you spend. 1. `POST /shipments/quote` rates the package and saves it as a shipment. It costs nothing. 2. `POST /shipments/{id}/buy` buys one of the returned rates. ## Quote [Section titled “Quote”](#quote) A quote needs the ids of a saved ship-from address (`GET /ship-from`), a destination, and a parcel. See the [API reference](/docs/api) for every field. The response includes: | Field | Meaning | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `shipment_id` | The saved shipment. You pass it to the buy call. | | `rates` | The prices your organization pays. Each has `id`, `carrier`, `service`, `price_cents`, `delivery_days`, `guaranteed` and `badges`. | | `suggested_rate_id` and `suggested_by_rule` | Set when one of your [shipping rules](/docs/shipping-rules) chose a rate. | | `applied_rules` | Which rules ran and what they did. | | `partial` | `true` when some carriers did not answer. | | `expires_in_seconds` | How long the rates can be bought. | Rules that remove rates act on the quote itself, so a rate a rule removed is not in the list and cannot be bought. Note `rate.id` is an opaque string. It is only valid for the shipment it came from and only until `expires_in_seconds` runs out. Do not store it long term and do not try to build one yourself. If the quote request has `"verify_address": true` (the default) and your account is set to confirm address changes, the response is `200` with `"status": "needs_address_confirmation"`, the address you entered and a `suggestion`. Show the suggestion to a person, then quote again with the address you want. Pass `"verify_address": false` to skip the check. ### Expired rates and partial results [Section titled “Expired rates and partial results”](#expired-rates-and-partial-results) If you buy after the rates expired, the call fails with `409` and the code `conflict`, with `details.expired` set to `true`. Quote again and use the new rate. When `partial` is `true` the cheapest carrier may be missing. For an unattended system it is usually right to quote again after a short wait. For a person, show the rates you have and say that the list may be incomplete. ## Buy with an idempotency key [Section titled “Buy with an idempotency key”](#buy-with-an-idempotency-key) The buy call requires an `Idempotency-Key` header of 8 to 200 characters. Without it the call fails with `422 validation`. * curl ```bash curl https://shipjay.com/api/v1/shipments/$SHIPMENT_ID/buy \ -H "Authorization: Bearer $SHIPJAY_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1001-label-1" \ -d '{ "rate_id": "r_5f3a91" }' ``` * Node ```js const res = await fetch(`https://shipjay.com/api/v1/shipments/${shipmentId}/buy`, { method: "POST", headers: { Authorization: `Bearer ${process.env.SHIPJAY_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `order-${orderId}-label-1`, }, body: JSON.stringify({ rate_id: rateId }), }); ``` * Python ```python res = requests.post( f"https://shipjay.com/api/v1/shipments/{shipment_id}/buy", headers={ "Authorization": f"Bearer {os.environ['SHIPJAY_KEY']}", "Idempotency-Key": f"order-{order_id}-label-1", }, json={"rate_id": rate_id}, timeout=30, ) ``` How the key behaves: * The same key with the same request returns the first result and adds the header `Idempotent-Replayed: true`. Nothing is bought a second time. * The same key with a different request fails with `422 validation`. * The same key while the first request is still running fails with `409 conflict`. Wait and send it again. * A server error (`5xx`) forgets the key, so you can retry with it. * Keys are scoped to the API key that sent them. Derive the key from something stable in your system, such as an order id plus a counter, not from a random value generated on each retry. A random key per attempt defeats the protection. Buying a shipment that is already purchased also fails with `409`, and the response body has `"duplicate": true` when it repeats a purchase made with the same key. If a buy times out and you do not know the outcome, repeat the call with the same key. If you lost the key, read the shipment (`GET /shipments/{id}`) and check its `status` before you do anything else. ## Spending limits [Section titled “Spending limits”](#spending-limits) Three limits can stop a purchase, all with `402`: * The key's **per label** limit (`spend_limit`). * The key's **per day** limit (`spend_limit`). * Your account's daily limit (`spend_limit`) and your wallet balance (`insufficient_funds`). Set a per label and per day limit on every key that can buy. Then a bug or a leaked key can lose at most one day of the limit. ## After the purchase [Section titled “After the purchase”](#after-the-purchase) * `GET /shipments/{id}/label` downloads the label file. * `GET /shipments/{id}/tracking` returns the status and scan history. * Subscribe to `shipment.purchased` and `tracking.updated` to avoid polling. See [Webhooks](/docs/webhooks). ## Refunds [Section titled “Refunds”](#refunds) `POST /shipments/{id}/refund` asks the carrier to void an unused label. It needs the `refunds:write` permission. The response has a `refund_status`: | Status | Meaning | | ---------------- | ------------------------------------------------------------------------------------------ | | `submitted` | The carrier has the request. The wallet is credited when it approves, which can take days. | | `refunded` | Approved and credited already. | | `rejected` | The carrier refused. | | `not_applicable` | The label cannot be refunded. | Only purchased labels can be refunded, and asking twice fails with `409`. You get `shipment.refund_requested`, then either `shipment.refunded` or `shipment.refund_rejected` events. # Changelog > Changes to the Ship Jay API, webhooks and MCP server. ## 1.0.0 [Section titled “1.0.0”](#100) The first public version of the API. * REST API under `/api/v1` with scoped bearer keys, per key spending limits, expiry dates and IP allowlists. * Quote then buy flow with required idempotency keys for purchases. * Shipments, labels, tracking, refunds, wallet, returns, shipping rules and the event feed. * Signed webhooks with retries. * MCP server at `/api/mcp` with tools filtered by key permissions. * OpenAPI file at `/api/v1/openapi.json`. # Errors > The error format, every error code with its HTTP status, and how to use request ids. Failed requests return a JSON body with one `error` object: ```json { "error": { "code": "spend_limit", "message": "This purchase is above the per purchase limit of this API key.", "details": { "limitCents": 2000 }, "request_id": "c3a7e1f0-52d8-4b69-8e14-7f0a9d6b2c35" } } ``` * `code` is stable. Branch on it in code. * `message` is for people. Do not parse it, and expect the wording to change. * `details` is optional and depends on the code. It can hold the field that failed, a limit, or a retry time. * `request_id` matches the `X-Request-Id` header on the same response. Log it, and quote it when you contact support. ## Codes [Section titled “Codes”](#codes) | Code | HTTP status | Meaning | What to do | | -------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `validation` | 422 | The request is malformed or breaks a rule. `details.issues` lists each field path and message. Also used when a shipping rule blocks a shipment (`details.blockedByRule`). | Fix the request. Do not retry unchanged. | | `unauthenticated` | 401 | The key is missing, malformed, unknown, revoked or expired. | Check the key. | | `forbidden` | 403 | The key lacks the permission, or the request came from an address outside its allowlist. | Use a key with the right permission or from an allowed address. | | `not_found` | 404 | No such endpoint or object in your account. | Check the id. | | `conflict` | 409 | The request clashes with the current state: an already purchased shipment, expired rates (`details.expired`), a refund already requested, or an idempotency key still in use. | Read the current state, then decide. | | `insufficient_funds` | 402 | The wallet cannot cover the purchase. | Add funds, then try again. | | `spend_limit` | 402 | The purchase would break a per label or per day limit on the key or the account. `details` holds the limit in cents. | Raise the limit in the app, or wait for the day to reset. | | `org_suspended` | 403 | The account is not active. | Contact support. | | `rate_limited` | 429 | Too many requests. The `Retry-After` header says how many seconds to wait. | Wait, then retry. See [Rate limits](/docs/rate-limits). | | `maintenance` | 503 | The platform is in maintenance and refuses changes. Reads still work. | Retry later. | | `not_configured` | 501 | A needed service is not set up, for example no carrier is available. | Retry later or contact support. | | `upstream` | 502 | A carrier failed or returned something unusable. | Retry with backoff. | | `internal` | 500 | Something went wrong on our side. The message is generic on purpose. | Retry with backoff. Quote the `request_id` if it persists. | An unsupported method on a real path returns `405` with an `Allow` header. ## Retrying [Section titled “Retrying”](#retrying) * Retry `429`, `502`, `503` and `500` with exponential backoff and some jitter. For `429`, wait at least `Retry-After` seconds. * Do not retry `422`, `401`, `403`, `404` unchanged. They will fail the same way. * For a purchase, always retry with the **same** `Idempotency-Key`. See [Buying labels safely](/docs/buying-labels). * A `5xx` response to a request with an idempotency key does not lock the key. You can send the same request again. Note The OpenAPI file lists the same error shape for every endpoint. A `request_id` is present on every error, including ones that never reach a route, such as a bad key. # MCP server > Connect Claude, Cursor or any MCP client to Ship Jay. Endpoint, setup, the tool list and safety advice for agents that can spend money. The Model Context Protocol (MCP) lets an AI assistant call tools on your behalf. Ship Jay runs an MCP server so an assistant can quote shipments, read tracking and, if you allow it, buy labels. * **Endpoint:** `https://shipjay.com/api/mcp` * **Transport:** JSON-RPC 2.0 over HTTP POST. Responses are plain JSON, with no streaming. * **Auth:** the same bearer API key as the REST API. * **Protocol versions:** `2025-06-18`, `2025-03-26` and `2024-11-05`. The server runs the same handlers as the REST API, with the same permissions, limits and audit trail. An agent can never do more than its key allows. ## Connect a client [Section titled “Connect a client”](#connect-a-client) Create a key first (see [Authentication and API keys](/docs/authentication)). Give an agent key only the permissions it needs. * Claude Desktop Claude Desktop starts local servers, so use the `mcp-remote` bridge to reach a remote one. Edit `claude_desktop_config.json`: ```json { "mcpServers": { "shipjay": { "command": "npx", "args": ["-y", "mcp-remote", "https://shipjay.com/api/mcp", "--header", "Authorization:${SHIPJAY_AUTH}"], "env": { "SHIPJAY_AUTH": "Bearer sj_..." } } } } ``` Restart Claude Desktop. The Ship Jay tools appear in the tools menu. * Claude Code ```bash claude mcp add --transport http shipjay https://shipjay.com/api/mcp \ --header "Authorization: Bearer sj_..." ``` * Cursor Add this to `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for every project: ```json { "mcpServers": { "shipjay": { "url": "https://shipjay.com/api/mcp", "headers": { "Authorization": "Bearer sj_..." } } } } ``` * Other clients Any client that speaks MCP over HTTP needs two things: the URL `https://shipjay.com/api/mcp` and an `Authorization: Bearer sj_...` header. If your client can only start local programs, put the `mcp-remote` bridge in front as shown for Claude Desktop. You can also talk to the server directly: ```bash # List the tools this key may use curl https://shipjay.com/api/mcp \ -H "Authorization: Bearer $SHIPJAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }' # Call a tool curl https://shipjay.com/api/mcp \ -H "Authorization: Bearer $SHIPJAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "get_tracking", "arguments": { "shipment_id": "2f6c3a9e-5d1b-4c7e-9a41-8b0d6e7f1c20" } } }' ``` Do not put the key in a shared repository. Use your client's way of keeping secrets, or an environment variable. ## Tools [Section titled “Tools”](#tools) `tools/list` returns only the tools the key may use. A key with `shipments:read` and nothing else sees the read tools and none of the buying tools. Tool names and permissions below come from the server itself. | Tool | Permission | What it does | | -------------------- | ----------------- | ---------------------------------------------------------------------------------------------- | | `get_account` | any valid key | Show the organization, its wallet currency and what this key may do. | | `list_ship_from` | `shipments:read` | List the saved ship-from addresses. Use one of the ids as from_address_id when quoting. | | `verify_address` | `shipments:write` | Check whether a US address is deliverable and get the corrected version. | | `quote_shipment` | `shipments:write` | Get rates for a package. Saves the shipment and does not spend money. | | `buy_label` | `shipments:buy` | Buy a label for a rate from a quote. Spends wallet money. This tool spends money. | | `list_shipments` | `shipments:read` | List recent shipments, newest first, optionally by status or tracking number. | | `get_shipment` | `shipments:read` | Get one shipment. | | `get_tracking` | `shipments:read` | Get the tracking status and scan history of a shipment. | | `request_refund` | `refunds:write` | Ask the carrier to refund an unused label. | | `get_wallet_balance` | `wallet:read` | Get the wallet balance in cents. | | `list_returns` | `returns:read` | List return requests, optionally by status. | | `approve_return` | `returns:write` | Approve a return. Buys a prepaid return label and spends wallet money. This tool spends money. | | `list_rules` | `rules:read` | List the shipping rules. | | `list_events` | `events:read` | Read recent account events, oldest first. | Tool arguments are the fields of the matching REST call. For path ids the argument is named after the object, such as `shipment_id` for `buy_label` and `get_tracking`. Amounts are integer cents. The label file download is not offered as a tool, because a file does not fit a text answer. A failed tool call comes back with `isError: true` and the same JSON error body as the REST API, with its `code` and `request_id`. See [Errors](/docs/errors). ## Buying through an agent [Section titled “Buying through an agent”](#buying-through-an-agent) `buy_label` spends wallet money. The server derives the idempotency key from the shipment and the rate, so an agent that retries cannot buy the same label twice. The server tells the client to quote first and buy only after the person approves, and a well behaved client will ask. You should not rely on that alone. Safety advice for agents that can spend money * **Do not give an agent `shipments:buy` unless it must buy.** A key with `shipments:read` and `shipments:write` can research and quote all day and cannot spend anything. * **Always set limits on the key.** A per label and a per day limit decide the worst case if the agent misbehaves, is tricked by text in a web page or an email, or is simply wrong. * **Keep a person in the loop.** Configure your client to ask for confirmation before it calls `buy_label` and `approve_return`. Show the carrier, service and price in the prompt. * **Use a separate key for each agent**, with an expiry date, and an IP allowlist if it runs from a fixed address. Revoke it when the job is done. * **Treat shipment data as untrusted input.** Names, notes and references may contain instructions meant for the model. Your policy should live in shipping rules and key limits, not in a prompt. * **Review the audit trail.** Everything the key does is recorded with the key's name. Shipping rules apply to agents too. A rule that blocks a destination or removes a carrier holds no matter who asks. See [Shipping rules](/docs/shipping-rules). # Rate limits and abuse protection > Request limits, rate limit headers, key protections and what to do when you hit a limit. ## Limits [Section titled “Limits”](#limits) | Scope | Limit | | ---------------------------------------------------- | --------------------------------------------------------- | | All requests with one key | 120 per minute | | `POST /shipments/quote` and `POST /addresses/verify` | 30 per minute per key, and 2,000 per day per organization | | `POST /shipments/{id}/buy` | 30 per minute per key | | Request body size | 256 KB | When you go over a limit the response is `429` with the code `rate_limited`, and a `Retry-After` header with the number of seconds to wait. Every response carries three headers so you can slow down before you hit a limit: | Header | Meaning | | --------------------- | ------------------------------------------------- | | `RateLimit-Limit` | The limit that applies to this request. | | `RateLimit-Remaining` | How many requests are left in the current window. | | `RateLimit-Reset` | Seconds until the window resets. | ## Handle a 429 [Section titled “Handle a 429”](#handle-a-429) * Node ```js async function withRetry(makeRequest, attempts = 5) { for (let i = 0; i < attempts; i++) { const res = await makeRequest(); if (res.status !== 429 && res.status < 500) return res; const wait = Number(res.headers.get("Retry-After")) || 2 ** i; await new Promise((r) => setTimeout(r, (wait + Math.random()) * 1000)); } throw new Error("Giving up after repeated rate limits or server errors"); } ``` * Python ```python import random import time def with_retry(make_request, attempts=5): for i in range(attempts): res = make_request() if res.status_code != 429 and res.status_code < 500: return res wait = int(res.headers.get("Retry-After", 2 ** i)) time.sleep(wait + random.random()) raise RuntimeError("Giving up after repeated rate limits or server errors") ``` Retrying a purchase is safe only with the same `Idempotency-Key` on every attempt. ## Abuse protection [Section titled “Abuse protection”](#abuse-protection) * **Bad keys are blocked.** Repeated requests with invalid keys from one IP address are blocked for 15 minutes. * **Keys are stored hashed.** Only a hash of the secret is kept, and the key is shown once when you create it. * **Keys can expire.** Set an expiry date when you create a key. An expired key stops working. * **Keys can be limited to addresses.** An IP allowlist, with exact addresses or IPv4 ranges, rejects requests from anywhere else. * **Spending is capped.** A per label and a per day limit on the key, plus your account's own limit, bound what a leaked key can spend. * **Permissions are narrow.** A key only does what you ticked, and the MCP server only lists the tools the key may use. * **No browser access.** The API sends no CORS headers. Call it from a server and never put a key in front end code. * **HTTPS only.** In production the API is served over HTTPS only. Never send a key over plain HTTP. Tip Cache what you can. Rates for the same package and destination are not needed twice in a minute, and the event feed and webhooks make polling shipment status unnecessary. # Returns API > List and read returns, approve or reject requests, and follow a return to the end. Customers ask for returns on your returns page in the app. The API lets your own systems see those requests and act on them. It does not create return requests: those always come from the customer, who proves an order with the order or tracking number and the email on it. ## Permissions [Section titled “Permissions”](#permissions) * `returns:read` lists and reads returns. * `returns:write` approves, rejects, receives and closes them. ## Lifecycle [Section titled “Lifecycle”](#lifecycle) A return has one of these statuses: `requested`, `approved`, `rejected`, `label_issued`, `in_transit`, `received`, `closed` or `cancelled`. Approval buys a prepaid return label and emails it to the customer. | Call | What it does | | ---------------------------- | ---------------------------------------------------------------------------------------------- | | `GET /returns` | List returns. Filter with `?status=requested`. | | `GET /returns/{id}` | Read one return. | | `POST /returns/{id}/approve` | Approve and buy the prepaid return label. **Spends wallet money.** Accepts an optional `note`. | | `POST /returns/{id}/reject` | Reject. A `note` is required and explains the decision. | | `POST /returns/{id}/receive` | Mark the parcel as received. | | `POST /returns/{id}/close` | Close a finished return. | Caution Approving a return spends wallet money for the prepaid label. It is safe to repeat: a return that already has its label is returned unchanged, and a return that was approved but could not get a label (for example the wallet was empty) tries to buy the label again. ## Example [Section titled “Example”](#example) * curl ```bash # Find requests waiting for a decision curl "https://shipjay.com/api/v1/returns?status=requested" \ -H "Authorization: Bearer $SHIPJAY_KEY" # Approve one curl -X POST https://shipjay.com/api/v1/returns/$RETURN_ID/approve \ -H "Authorization: Bearer $SHIPJAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "note": "Within the 30 day window" }' ``` * Node ```js const headers = { Authorization: `Bearer ${process.env.SHIPJAY_KEY}`, "Content-Type": "application/json", }; const list = await (await fetch("https://shipjay.com/api/v1/returns?status=requested", { headers })).json(); for (const ret of list.data) { // Apply your own policy here before approving. await fetch(`https://shipjay.com/api/v1/returns/${ret.id}/approve`, { method: "POST", headers, body: JSON.stringify({ note: "Within the 30 day window" }), }); } ``` * Python ```python headers = {"Authorization": f"Bearer {os.environ['SHIPJAY_KEY']}"} listing = requests.get( "https://shipjay.com/api/v1/returns", params={"status": "requested"}, headers=headers, timeout=30 ).json() for ret in listing["data"]: # Apply your own policy here before approving. requests.post( f"https://shipjay.com/api/v1/returns/{ret['id']}/approve", headers=headers, json={"note": "Within the 30 day window"}, timeout=30, ) ``` A return looks like this: ```json { "id": "a41d9e27-6b80-4c35-8f12-d3e05b7a9c61", "rma": 1042, "status": "requested", "shipment_id": "2f6c3a9e-5d1b-4c7e-9a41-8b0d6e7f1c20", "return_shipment_id": null, "reason": "too_small", "reason_note": null, "customer_email": "avery@example.com", "customer_name": "Avery Quinn", "label_error": null, "created_at": "2026-05-06T09:30:00Z", "received_at": null } ``` `reason` is one of `too_small`, `too_large`, `damaged`, `wrong_item`, `not_as_described`, `changed_mind` or `other`. `label_error` explains why the prepaid label could not be bought. The return stays approved, and approving it again retries the label. The list returns the 200 most recent returns that match the filter, newest first. It does not paginate, and `next_cursor` is always `null`. ## Events [Section titled “Events”](#events) Return changes produce `return.requested`, `return.approved`, `return.rejected` and `return.received` events. Use them to react to a new request without polling. See [Webhooks](/docs/webhooks). # Shipping rules > Create shipping rules through the API: conditions, operators, actions, priorities and a worked example. Shipping rules turn your shipping policy into something Ship Jay enforces. A rule says: when these conditions hold, do these actions. Rules run on every quote, whether it comes from the app, a spreadsheet, the API or an AI agent. A rate a rule removes is removed from the quote itself, so it can never be bought. The API manages the same rules you see in Settings, Shipping rules. Reading needs `rules:read`. Creating, replacing and deleting need `rules:write`. | Call | What it does | | -------------------- | --------------------------------------------------- | | `GET /rules` | List your rules. | | `POST /rules` | Create a rule. Returns `201` and `{ "id": "..." }`. | | `PUT /rules/{id}` | Replace a rule. Send the full rule. | | `DELETE /rules/{id}` | Delete a rule. | ## Anatomy of a rule [Section titled “Anatomy of a rule”](#anatomy-of-a-rule) ```json { "name": "Sign for high value parcels", "phase": "shipment", "priority": 100, "enabled": true, "stop_on_match": false, "conditions": { "all": [ { "field": "declared_value_cents", "op": "gte", "value": 20000 } ] }, "actions": [ { "type": "require_signature" }, { "type": "set_insurance", "amount_cents": 20000 } ] } ``` | Field | Meaning | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | 1 to 80 characters. | | `phase` | `shipment` runs before rating. `rate` runs on the rates that came back. | | `priority` | 1 to 10000, default 100. Lower numbers run first. | | `enabled` | Default `true`. | | `stop_on_match` | When `true`, a rule that matches ends its phase. Later rules in that phase are skipped. | | `conditions` | A group, `{ "all": [...] }` or `{ "any": [...] }`. Groups can nest. Default is `{ "all": [] }`, which always matches. An empty `any` never matches. | | `actions` | 1 to 10 actions. Which ones are allowed depends on the phase. | ## Fields [Section titled “Fields”](#fields) A condition compares a field to a value. Text comparisons ignore case and surrounding spaces. | Field | Type | Phases | | ---------------------- | ---------------------------------------------------------------- | -------------- | | `to.country` | text, two letter code | shipment, rate | | `to.state` | text | shipment, rate | | `to.zip` | text | shipment, rate | | `international` | true or false, true when the country is not US | shipment, rate | | `weight_oz` | number | shipment, rate | | `packaging_type` | text | shipment, rate | | `declared_value_cents` | number, the larger of the insurance amount and the customs value | shipment, rate | | `reference1` | text | shipment, rate | | `rate.carrier` | text | rate only | | `rate.service` | text | rate only | | `rate.price_cents` | number | rate only | | `rate.days` | number, estimated delivery days | rate only | A shipment rule that uses a `rate.` field is rejected with `422`. ## Operators [Section titled “Operators”](#operators) | Operator | Meaning | | ------------------------- | --------------------------------------------------------------------------------- | | `eq`, `neq` | equals, does not equal | | `gt`, `gte`, `lt`, `lte` | numeric comparisons | | `in`, `not_in` | the value is in, or not in, a list. Send `value` as an array of up to 50 entries. | | `starts_with`, `contains` | text comparisons | If a rate has no estimated delivery time, `rate.days` has no value and the condition only holds for `neq` and `not_in`. ## Actions [Section titled “Actions”](#actions) **Shipment phase** | Action | Effect | | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `{ "type": "block", "message": "..." }` | The quote fails with `422` and your message. `details.blockedByRule` is `true`. | | `{ "type": "require_signature" }` | Turns on signature on delivery. | | `{ "type": "set_insurance", "amount_cents": 20000 }` | Insures the parcel. Between 100 and 500000 cents. The highest amount among matching rules wins. | **Rate phase** | Action | Effect | | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `{ "type": "exclude_carrier", "carrier": "UPS" }` | Removes rates from that carrier. | | `{ "type": "exclude_service", "contains": "Express" }` | Removes rates whose service name contains the text. | | `{ "type": "max_price", "cents": 3000 }` | Removes rates above that price. | | `{ "type": "max_days", "days": 5 }` | Removes rates estimated to take longer. Rates with no estimate stay. | | `{ "type": "choose_rate", "strategy": "cheapest" }` | Picks a rate and returns it as `suggested_rate_id`. Strategies: `cheapest`, `fastest`, `best` (a balance of price and speed). Add `"carrier"` to pick only from one carrier. | In rate rules, the conditions are tested against each rate. `choose_rate` chooses among the rates that match the conditions and survived earlier removals. The first rule to choose wins, and the chosen rate gets the `rule` badge. A rule that chooses a rate does not buy it. Your code still has to call buy. ## Worked example [Section titled “Worked example”](#worked-example) Goal: sign for and insure expensive parcels, never use UPS for parcels over 50 lb, and suggest the cheapest option that arrives within five days. * curl ```bash curl https://shipjay.com/api/v1/rules \ -H "Authorization: Bearer $SHIPJAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Sign for high value parcels", "phase": "shipment", "priority": 10, "conditions": { "all": [ { "field": "declared_value_cents", "op": "gte", "value": 20000 } ] }, "actions": [ { "type": "require_signature" }, { "type": "set_insurance", "amount_cents": 20000 } ] }' curl https://shipjay.com/api/v1/rules \ -H "Authorization: Bearer $SHIPJAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "No UPS over 50 lb", "phase": "rate", "priority": 10, "conditions": { "all": [ { "field": "weight_oz", "op": "gt", "value": 800 } ] }, "actions": [ { "type": "exclude_carrier", "carrier": "UPS" } ] }' curl https://shipjay.com/api/v1/rules \ -H "Authorization: Bearer $SHIPJAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Cheapest within five days", "phase": "rate", "priority": 50, "conditions": { "all": [ { "field": "rate.days", "op": "lte", "value": 5 } ] }, "actions": [ { "type": "choose_rate", "strategy": "cheapest" } ] }' ``` * Node ```js const rules = [ { name: "Sign for high value parcels", phase: "shipment", priority: 10, conditions: { all: [{ field: "declared_value_cents", op: "gte", value: 20000 }] }, actions: [{ type: "require_signature" }, { type: "set_insurance", amount_cents: 20000 }], }, { name: "No UPS over 50 lb", phase: "rate", priority: 10, conditions: { all: [{ field: "weight_oz", op: "gt", value: 800 }] }, actions: [{ type: "exclude_carrier", carrier: "UPS" }], }, { name: "Cheapest within five days", phase: "rate", priority: 50, conditions: { all: [{ field: "rate.days", op: "lte", value: 5 }] }, actions: [{ type: "choose_rate", strategy: "cheapest" }], }, ]; for (const rule of rules) { const res = await fetch("https://shipjay.com/api/v1/rules", { method: "POST", headers: { Authorization: `Bearer ${process.env.SHIPJAY_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(rule), }); console.log(res.status, await res.json()); } ``` * Python ```python rules = [ { "name": "Sign for high value parcels", "phase": "shipment", "priority": 10, "conditions": {"all": [{"field": "declared_value_cents", "op": "gte", "value": 20000}]}, "actions": [{"type": "require_signature"}, {"type": "set_insurance", "amount_cents": 20000}], }, { "name": "No UPS over 50 lb", "phase": "rate", "priority": 10, "conditions": {"all": [{"field": "weight_oz", "op": "gt", "value": 800}]}, "actions": [{"type": "exclude_carrier", "carrier": "UPS"}], }, { "name": "Cheapest within five days", "phase": "rate", "priority": 50, "conditions": {"all": [{"field": "rate.days", "op": "lte", "value": 5}]}, "actions": [{"type": "choose_rate", "strategy": "cheapest"}], }, ] for rule in rules: res = requests.post( "https://shipjay.com/api/v1/rules", headers={"Authorization": f"Bearer {os.environ['SHIPJAY_KEY']}"}, json=rule, timeout=30, ) print(res.status_code, res.json()) ``` Quote a 60 lb parcel after this and the UPS rates are gone. `applied_rules` in the quote response lists each rule that did something, with its `effects`, and `suggested_by_rule` names the rule that chose a rate. Note Rules change what every quote on the account returns, including quotes made by people in the app. Create them with care, and prefer a key that can only read rules for systems that do not need to change them. # Tracking and branded pages > Read tracking status and scan history, and use the branded tracking page for recipients. Every purchased shipment has a tracking status that Ship Jay keeps up to date from the carrier. ## Read tracking [Section titled “Read tracking”](#read-tracking) `GET /shipments/{id}/tracking` needs the `shipments:read` permission. * curl ```bash curl https://shipjay.com/api/v1/shipments/$SHIPMENT_ID/tracking \ -H "Authorization: Bearer $SHIPJAY_KEY" ``` * Node ```js const res = await fetch(`https://shipjay.com/api/v1/shipments/${shipmentId}/tracking`, { headers: { Authorization: `Bearer ${process.env.SHIPJAY_KEY}` }, }); const tracking = await res.json(); ``` * Python ```python tracking = requests.get( f"https://shipjay.com/api/v1/shipments/{shipment_id}/tracking", headers={"Authorization": f"Bearer {os.environ['SHIPJAY_KEY']}"}, timeout=30, ).json() ``` ```json { "tracking_code": "9400100000000000000000", "status": "in_transit", "tracking_url": "https://shipjay.com/t/k3v9...", "events": [ { "status": "in_transit", "message": "Arrived at regional facility", "location": "Minneapolis, MN", "occurred_at": "2026-05-04T14:12:00Z" }, { "status": "pre_transit", "message": "Shipping label created", "location": null, "occurred_at": "2026-05-03T18:40:00Z" } ] } ``` `events` holds up to the 100 most recent scans, newest first. `location` is the city and state when the carrier gave them, otherwise `null`. ## Statuses [Section titled “Statuses”](#statuses) | Status | Meaning | | ---------------------- | ------------------------------------------------------------ | | `unknown` | The carrier has not reported anything yet. | | `pre_transit` | The label exists but the carrier has not scanned the parcel. | | `in_transit` | On its way. | | `out_for_delivery` | With the local carrier for delivery. | | `delivered` | Delivered. | | `available_for_pickup` | Waiting at a pickup location. | | `return_to_sender` | On its way back to you. | | `failure` | Delivery failed. | | `cancelled` | The shipment was cancelled. | | `error` | The carrier reported a problem with the tracking number. | The shipment object (`GET /shipments/{id}`) has the current value as `tracking_status`, next to its purchase `status`, which is a different field. ## Get notified instead of polling [Section titled “Get notified instead of polling”](#get-notified-instead-of-polling) Subscribe a webhook endpoint to `tracking.updated`. Each event carries the new `status` and the `previousStatus`. See [Webhooks](/docs/webhooks). If you cannot receive webhooks, poll `GET /events` with the `after` parameter, which returns events oldest first. ## The branded tracking page [Section titled “The branded tracking page”](#the-branded-tracking-page) Each shipment has a `tracking_url`. It opens a tracking page for the recipient that uses your brand settings. The link contains a private token, so anyone with the link can see that shipment's status. Share it with the recipient, not publicly. Tracking emails sent from the app link to the same page. # Webhooks > Subscribe to events, verify the signature, handle retries and protect against replays. Webhooks send an HTTP POST to your server when something happens, so you do not have to poll. You can manage endpoints in Settings, Developers, or through the API with the `webhooks:manage` permission. ## Create an endpoint [Section titled “Create an endpoint”](#create-an-endpoint) `POST /webhooks` takes the URL, an optional description and the events you want. Use `"*"` to receive every event. * curl ```bash curl https://shipjay.com/api/v1/webhooks \ -H "Authorization: Bearer $SHIPJAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/shipjay", "description": "Order system", "event_types": ["shipment.purchased", "tracking.updated"] }' ``` * Node ```js const res = await fetch("https://shipjay.com/api/v1/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.SHIPJAY_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://hooks.example.com/shipjay", description: "Order system", event_types: ["shipment.purchased", "tracking.updated"], }), }); const { id, secret } = await res.json(); ``` * Python ```python res = requests.post( "https://shipjay.com/api/v1/webhooks", headers={"Authorization": f"Bearer {os.environ['SHIPJAY_KEY']}"}, json={ "url": "https://hooks.example.com/shipjay", "description": "Order system", "event_types": ["shipment.purchased", "tracking.updated"], }, timeout=30, ) endpoint = res.json() # {"id": "...", "secret": "..."} ``` The response is `201` with the endpoint `id` and its signing `secret`. **The secret is shown once.** Store it where your server can read it. The URL must use `https`, must not contain a user name or password, and must resolve to a public address. Addresses on private networks are refused when you save the endpoint and again before every delivery. ## Events [Section titled “Events”](#events) | Event | When it fires | Fields in `data` | | --------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------- | | `shipment.purchased` | A label was bought. | `shipmentId, trackingCode, carrier, service, chargedCents, currency` | | `shipment.refund_requested` | A refund request was sent to the carrier. | `shipmentId, status` | | `shipment.refunded` | The carrier approved the refund and the wallet was credited. | `shipmentId, creditCents, currency` | | `shipment.refund_rejected` | The carrier rejected the refund. | `shipmentId, status` | | `tracking.updated` | The tracking status of a shipment changed. | `shipmentId, status, previousStatus, trackingCode` | | `return.requested` | A customer asked to return a shipment. | `returnId, rma, shipmentId, reason` | | `return.approved` | A return was approved and its prepaid label was bought. | `returnId, rma` | | `return.rejected` | A return was rejected. | `returnId, rma` | | `return.received` | A returned parcel was received. | `returnId, rma` | | `adjustment.applied` | A carrier adjustment was charged to the wallet. | `adjustmentId, shipmentId, amountCents, currency, reason` | | `batch.completed` | A batch of labels finished processing. | `batchId, bought, failed, state` | Every event is also available from `GET /events` for 30 days, oldest first, so you can catch up after downtime. Pass the id of the last event you processed as `after`. ## Payload [Section titled “Payload”](#payload) Each delivery is a JSON object. The `data` fields depend on the event type and use camelCase names. ```json { "id": "6d1f0a8c-92b4-4e57-b3a6-0c7e5d2f9a14", "type": "shipment.purchased", "created_at": "2026-05-03T18:40:02Z", "data": { "shipmentId": "2f6c3a9e-5d1b-4c7e-9a41-8b0d6e7f1c20", "trackingCode": "9400100000000000000000", "carrier": "USPS", "service": "Ground Advantage", "chargedCents": 864, "currency": "USD" } } ``` Request headers: | Header | Value | | --------------------- | ---------------------------------------------------------- | | `Content-Type` | `application/json` | | `User-Agent` | `ShipJay-Webhooks/1` | | `ShipJay-Event-Id` | The event id, the same as `id` in the body. | | `ShipJay-Delivery-Id` | The id of this delivery. It stays the same across retries. | | `ShipJay-Signature` | `t=,v1=` | ## Verify the signature [Section titled “Verify the signature”](#verify-the-signature) The signature is an HMAC-SHA256, in hex, of the string `.` computed with your endpoint secret. Check it on the raw bytes of the body, before you parse the JSON. Reject the request when the signature does not match, and when `t` is more than five minutes away from your clock. Compare in constant time. * Node ```js import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyShip Jay(secret, header, rawBody, toleranceSeconds = 300) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const t = Number(parts.t); if (!Number.isFinite(t) || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); const given = Buffer.from(parts.v1); const wanted = Buffer.from(expected); return given.length === wanted.length && timingSafeEqual(given, wanted); } // Express: use express.raw({ type: "application/json" }) so req.body is the raw Buffer. app.post("/shipjay", express.raw({ type: "application/json" }), (req, res) => { const ok = verifyShip Jay(process.env.SHIPJAY_WEBHOOK_SECRET, req.get("ShipJay-Signature") ?? "", req.body.toString("utf8")); if (!ok) return res.sendStatus(400); const event = JSON.parse(req.body.toString("utf8")); // Store event.id, then handle the event. res.sendStatus(200); }); ``` * Python ```python import hashlib import hmac import time def verify_shipjay(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) try: t = int(parts["t"]) given = parts["v1"] except (KeyError, ValueError): return False if abs(time.time() - t) > tolerance: return False signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(given, expected) # Flask: # @app.post("/shipjay") # def shipjay(): # raw = request.get_data() # if not verify_shipjay(SECRET, request.headers.get("ShipJay-Signature", ""), raw): # return "", 400 # event = json.loads(raw) # return "", 200 ``` * curl and openssl ```bash # Check a saved delivery by hand. BODY is the exact request body, T the t= value from the header. SECRET="your endpoint secret" T=1777834802 BODY='{"id":"6d1f0a8c-92b4-4e57-b3a6-0c7e5d2f9a14","type":"shipment.purchased","created_at":"2026-05-03T18:40:02Z","data":{}}' printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex # Compare the printed value with v1= in the ShipJay-Signature header. ``` Caution Do not parse and re-serialize the JSON before you verify. Any change to whitespace or key order changes the signature. Use the bytes exactly as they arrived. ## Respond quickly [Section titled “Respond quickly”](#respond-quickly) Answer with any `2xx` status within 10 seconds. Do the real work after you reply, for example by putting the event on a queue. Anything else counts as a failed attempt: a non 2xx status, a timeout, a connection error, and also a redirect, because Ship Jay does not follow redirects. ## Retries [Section titled “Retries”](#retries) A failed delivery is tried again with a growing delay: 30 seconds after the first failure, then 1 minute, 2, 4, 8, 16, 32 and 64 minutes, and 128 minutes before the tenth and last attempt. The delay is capped at 6 hours. That is ten attempts over about four hours. A delivery that fails all ten attempts is marked failed, and you can retry it from Settings, Developers. If 20 deliveries in a row fail completely, Ship Jay disables the endpoint and shows the reason in the app. Fix the problem and enable the endpoint again. Events that happened while it was disabled are not delivered, but you can still read them from `GET /events` for 30 days. ## Replay protection and duplicates [Section titled “Replay protection and duplicates”](#replay-protection-and-duplicates) * The five minute window on `t` stops someone from replaying an old capture later. Ship Jay signs each attempt with the time of that attempt, so a retry is not rejected for being old. * Delivery is at least once. You may receive the same event twice, for example when your server answered after the timeout. Store the event `id` and ignore ones you have already handled. * Events can arrive out of order. Use `created_at`, or the `previousStatus` field on `tracking.updated`, when order matters. * Keep the endpoint secret private. If it leaks, delete the endpoint and create a new one.