{"openapi":"3.1.0","info":{"title":"Shipyu Shipping API","version":"1.0.0","description":"Shipyu is the shipment tracking API. Create a tracker from a tracking number. Shipyu detects the carrier, normalizes every scan into one status model, and delivers real-time `tracker.updated` webhooks until the package arrives.\n\nThree concepts cover the whole API:\n\n- **Trackers** — one object per tracked package. Create it, then read it by id. Updates arrive by webhook.\n- **Webhooks** — signed HTTPS deliveries to your endpoint on every tracker change.\n- **Usage** — your request counts, bucketed for dashboards and alerting.\n\n## Base URL\n\nAll API endpoints are relative to: `https://shipyu.com/api/v1`\n\n## Authentication\n\nAll API requests require authentication using a Bearer token. Include your API key in the Authorization header:\n\n```\nAuthorization: Bearer sk_shipyu_live_your_api_key_here\n```\n\n### API Key Types\n\n- **Live keys** (`sk_shipyu_live_*`) — production. Real carrier tracking. Real money.\n- **Test keys** (`sk_shipyu_test_*`) — development and testing. Same API surface; billable events are not recorded; labels are marked test-mode and don't ship.\n\nKeys you create in the dashboard are a prefix (`sk_shipyu_live_` or `sk_shipyu_test_`) followed by exactly 32 lowercase hex characters.\n\nConnecting the MCP server over OAuth issues a different, shorter credential prefixed `mcpoauth_`. You never see or paste it — the browser sign-in provisions and stores it — but it is what `get_usage` reports as `key_prefix`, so do not validate a key against the `sk_shipyu_` shape and conclude something is wrong.\n\n### Error responses\n\nEvery error response has this shape — parse it, don't just check status codes:\n\n```json\n{\n  \"error\": {\n    \"type\": \"authentication_error\",\n    \"message\": \"Invalid API key provided\",\n    \"code\": \"api_key.invalid\"\n  }\n}\n```\n\nCommon `type` values: `authentication_error`, `invalid_request_error`, `forbidden_error`, `rate_limit_error`, `billing_error`, `not_found_error`, `api_error`, `validation_error`.\n\n## Rate Limits\n\nRate limits are enforced per API **client** (not per key — multiple keys for the same client share the same bucket). Defaults: **100 requests/minute** and **1,000 requests/hour**.\n\nEvery response carries:\n\n- `X-RateLimit-Limit` — the limit currently being applied\n- `X-RateLimit-Remaining` — requests remaining in the window\n- `X-RateLimit-Reset` — Unix timestamp when the window resets\n\nOn a `429`, back off until the reset timestamp and retry. If you need higher limits, contact support.\n\n## Request IDs\n\nEvery response includes `X-Shipyu-Request-Id`. Include this header value when contacting support — it lets us trace the exact request through our logs.\n\n---\n\n## Webhooks\n\nWebhooks push events to an HTTPS endpoint you control so you don't have to poll. Register one via `POST /webhooks`, and we'll POST JSON events to your URL as they happen.\n\n### Event types reference\n\n| Event | When it fires |\n|---|---|\n| `tracker.created` | A tracker is created (e.g. via `POST /trackers`) |\n| `tracker.updated` | A tracker's status or tracking_details changes |\n\nWhen you register a webhook, set `event_types` to the events you care about, or omit it (`null`) to receive everything. The event payload's `result` is the full Tracker object.\n\n### Verifying webhook signatures\n\nEvery webhook delivery (including test deliveries) includes an `X-Shipyu-Signature` header. Verifying it proves the payload came from Shipyu and hasn't been tampered with or replayed.\n\n**Header format**\n\n```\nX-Shipyu-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>\n```\n\n**Algorithm**\n\n`v1` is `HMAC-SHA256(secret, \"<t>.<raw_request_body>\")` — the timestamp is part of the signed payload, so a replayed request with a stale `t` can be detected.\n\n**Use the secret exactly as issued, including the `whsec_` prefix.** It is the literal ASCII string that is the HMAC key — do not strip the prefix, do not hex-decode the part after it, and do not base64-decode it. The secret looks like `whsec_` followed by 64 hex characters, and that shape invites decoding it to 32 raw bytes; doing so produces a signature that never matches, and the only symptom is that every delivery fails verification with nothing to indicate why. The code samples below pass the whole string, which is correct.\n\n**Steps to verify**\n\n1. Parse `t` and `v1` out of the header.\n2. Reject if `|now_in_seconds - t| > 300` (5-minute freshness window).\n3. Compute `expected = HMAC-SHA256(secret, t + \".\" + raw_body)`.\n4. Compare `expected` to `v1` using a **constant-time** equality function (e.g. `crypto.timingSafeEqual` in Node, `hmac.compare_digest` in Python).\n\n**Additional delivery headers**\n\nReal deliveries send:\n\n- `X-Shipyu-Delivery-Id` — UUID of this delivery ATTEMPT. It changes on every retry, so it is not an idempotency key. Deduplicate on the event's `result.id` plus `result.updated_at`, which stay stable across retries of the same event.\n- `X-Shipyu-Event-Type` — e.g. `tracker.updated`.\n\nTest deliveries from `POST /webhooks/{id}/test` send a different set:\n\n- `X-Shipyu-Webhook-Id` — UUID of the webhook endpoint being tested.\n- `X-Shipyu-Webhook-Test` — `\"true\"`. Absent on real traffic.\n\nThe `X-Shipyu-Signature` header is present on both.\n\n**Where the event type lives in the body**\n\nThe delivery body is the carrier event, passed through unchanged. The event type is in its `description` field — NOT in a field called `event` or `type`, which is what most people reach for first:\n\n```json\n{\n  \"id\": \"evt_...\",\n  \"description\": \"tracker.updated\",\n  \"mode\": \"production\",\n  \"result\": { \"id\": \"trk_...\", \"status\": \"in_transit\", \"...\": \"the full Tracker\" }\n}\n```\n\nOn a real delivery the `X-Shipyu-Event-Type` header carries the same value and is the easier thing to branch on. Note that a TEST delivery reports `\"mode\": \"test\"` in the body even when the webhook belongs to a live key — the payload is synthetic, so it describes itself as test data rather than describing your account.\n\n**Timeouts, retries and auto-disable**\n\n- Your endpoint has **10 seconds** to respond to a real delivery, and **5 seconds** for a test delivery. Return 2xx as soon as you have durably accepted the event and do the work afterwards.\n- A failed delivery is retried up to **5 times**, at roughly **1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours**, each with ±20% jitter so simultaneous failures do not retry in lockstep.\n- Every failed **attempt** increments the endpoint's consecutive-failure count, and the endpoint is **disabled automatically at 10** — so an endpoint that is fully down is disabled after roughly two exhausted deliveries, not ten. Any successful delivery resets the count to zero.\n- Deliveries to a **paused** endpoint are held rather than attempted: they are deferred about 15 minutes at a time, count no failures, and resume when you set the status back to active.\n- Test deliveries never count toward the auto-disable counter.\n\n**Node.js (Express)**\n\n```javascript\nimport crypto from \"node:crypto\";\nimport express from \"express\";\n\nconst WEBHOOK_TOLERANCE_SECONDS = 300;\nconst SECRET = process.env.SHIPYU_WEBHOOK_SECRET; // starts with \"whsec_\"\n\nfunction verifyShipyuSignature(rawBody, signatureHeader, secret) {\n  const parts = Object.fromEntries(\n    signatureHeader.split(\",\").map((p) => p.split(\"=\"))\n  );\n  const timestamp = Number(parts.t);\n  const signature = parts.v1;\n  if (!timestamp || !signature) return { valid: false, reason: \"bad header\" };\n\n  const now = Math.floor(Date.now() / 1000);\n  if (Math.abs(now - timestamp) > WEBHOOK_TOLERANCE_SECONDS) {\n    return { valid: false, reason: \"stale signature\" };\n  }\n\n  const expected = crypto\n    .createHmac(\"sha256\", secret)\n    .update(`${timestamp}.${rawBody}`, \"utf8\")\n    .digest(\"hex\");\n\n  const a = Buffer.from(signature, \"hex\");\n  const b = Buffer.from(expected, \"hex\");\n  if (a.length !== b.length) return { valid: false, reason: \"length mismatch\" };\n  return { valid: crypto.timingSafeEqual(a, b) };\n}\n\nconst app = express();\napp.post(\n  \"/webhooks/shipyu\",\n  express.raw({ type: \"application/json\" }),\n  (req, res) => {\n    const sig = req.header(\"X-Shipyu-Signature\");\n    const { valid, reason } = verifyShipyuSignature(req.body.toString(), sig, SECRET);\n    if (!valid) return res.status(401).send(reason);\n\n    const event = JSON.parse(req.body.toString());\n    if (event._test) {\n      // Synthetic test event — safe to log and 200.\n      return res.status(200).send(\"ok\");\n    }\n    // Handle real event...\n    res.status(200).send(\"ok\");\n  }\n);\n```\n\n**Python (Flask)**\n\n```python\nimport hmac, hashlib, time, os\nfrom flask import Flask, request, abort\n\nWEBHOOK_TOLERANCE_SECONDS = 300\nSECRET = os.environ[\"SHIPYU_WEBHOOK_SECRET\"]  # \"whsec_...\"\napp = Flask(__name__)\n\ndef verify_shipyu_signature(raw_body: bytes, signature_header: str, secret: str):\n    parts = dict(kv.split(\"=\", 1) for kv in signature_header.split(\",\"))\n    try:\n        t = int(parts[\"t\"])\n        v1 = parts[\"v1\"]\n    except (KeyError, ValueError):\n        return False, \"bad header\"\n\n    if abs(int(time.time()) - t) > WEBHOOK_TOLERANCE_SECONDS:\n        return False, \"stale signature\"\n\n    signed = f\"{t}.{raw_body.decode('utf-8')}\".encode()\n    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()\n    return hmac.compare_digest(expected, v1), \"ok\"\n\n@app.post(\"/webhooks/shipyu\")\ndef shipyu_webhook():\n    raw = request.get_data()  # raw bytes, do not parse first\n    sig = request.headers.get(\"X-Shipyu-Signature\", \"\")\n    ok, reason = verify_shipyu_signature(raw, sig, SECRET)\n    if not ok:\n        abort(401, reason)\n    # Handle event...\n    return \"\", 200\n```\n\n> **Tip** — always verify against the **raw request body**, not the parsed JSON. Re-serializing changes whitespace and breaks the signature.\n\n### Testing a webhook\n\nUse `POST /webhooks/{id}/test` to fire a synthetic delivery at your endpoint at any time. The test payload has `_test: true` and uses `tracking_code: \"TEST123456789\"` — it doesn't reference any real shipment. The delivery includes `X-Shipyu-Webhook-Test: true` so your receiver can branch.\n\n### Retries and auto-disable\n\nIf your endpoint doesn't return a 2xx within 10 seconds, we retry with exponential backoff. Each delivery is attempted up to **5 times** total (1 initial attempt + 4 retries):\n\n| Attempt | Delay after previous attempt |\n|---|---|\n| 1 | immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n\nAll delays include ±20% jitter. After the 5th attempt fails, the delivery's `status` becomes `failed` and it is not retried further.\n\nSeparately, after **10 consecutive failed deliveries** across all events, the webhook itself is **auto-disabled** (`status` = `disabled`, `failure_count` = 10). Re-enable it with `PATCH /webhooks/{id}` setting `status: \"active\"` — this also resets `failure_count` to 0.\n\nYou can also **pause** a webhook (`status` = `paused`). Paused webhooks hold new deliveries as pending instead of failing them. Set `status: \"active\"` to resume — held deliveries are then sent, and `failure_count` resets to 0.\n\nTest deliveries (via `/test`) do **not** count toward the auto-disable counter.\n\n### Webhook URL requirements (SSRF protection)\n\nTo prevent SSRF attacks that could expose internal services, webhook URLs must:\n\n- Use the `https://` scheme (`http://` is rejected).\n- Resolve to a **public** IP address.\n\nWe reject (at registration **and** at every delivery, to defend against DNS rebinding):\n\n- Loopback: `127.0.0.0/8`, `localhost`\n- Private RFC 1918: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`\n- Link-local: `169.254.0.0/16` (includes AWS/GCP metadata endpoints like `169.254.169.254`)\n- Unspecified: `0.0.0.0`\n- Cloud metadata hostnames: `metadata.google.internal`, etc.\n\n### Secret rotation\n\n`secret` is returned **once** in the `POST /webhooks` response and cannot be retrieved afterward. To rotate a leaked or lost secret, send a `POST` request to `/webhooks/{id}/rotate-secret`. The response contains the new `secret`. The old secret stops working immediately. The webhook keeps its URL, its event types, and its delivery history.\n\n---\n\n## Test mode\n\n- Use `sk_shipyu_test_*` keys for development. Same endpoints, same request shapes, same response shapes.\n- Test-mode requests do not contact carriers and do not record billable events.\n- Webhooks registered against a test key receive events triggered by test-mode objects.\n- There is no separate staging URL — point at `https://shipyu.com/api/v1` with a test key.\n\n## Billable events\n\nCertain endpoints generate billable events in live mode. Test-mode calls are free.\n\n| Endpoint | Billable | Notes |\n|---|---|---|\n| `POST /trackers` | $0.020 – $0.015 per call | Each successful POST is billed at your current monthly volume tier (see below). Enterprise contracts may use a flat rate instead. |\n| All read endpoints (`GET`) | — | Free |\n| Webhook deliveries | — | Free |\n\nUsage is billed monthly. Billing cycles are UTC calendar months: the usage counter resets on the 1st, and the invoice for a month generates just after it ends.\n\nTracker pricing is graduated by monthly volume. Each bracket bills at its own rate — crossing a tier discounts only the trackers above the threshold:\n\n| Monthly tracking volume | Price per tracker |\n|---|---|\n| 0 – 25,000 | $0.020 |\n| 25,001 – 100,000 | $0.019 |\n| 100,001 – 250,000 | $0.018 |\n| 250,001 – 500,000 | $0.017 |\n| 500,001 – 1,000,000 | $0.016 |\n| 1,000,001+ | Starting at $0.015 (contact sales) |\n\nExample: 120,000 trackers in one month bill as 25,000 × $0.020 + 75,000 × $0.019 + 20,000 × $0.018 = $2,285.00.\n\nInvoices below $1.00 carry forward to the next month. If a payment fails, the client's status moves to `suspended_payment_failed` and all API calls will return `402 Payment Required` until resolved in the dashboard.\n","contact":{"name":"Shipyu Support","url":"https://shipyu.com/support","email":"support@shipyu.com"},"license":{"name":"Proprietary","url":"https://shipyu.com/terms"}},"servers":[{"url":"https://shipyu.com/api/v1","description":"Production API"},{"url":"http://localhost:3000/api/v1","description":"Local development"}],"security":[{"BearerAuth":[]}],"tags":[{"name":"Trackers","description":"Track packages across carriers"},{"name":"Webhooks","description":"Register, list, update, test, and delete webhook endpoints. For signing, retry, and security details see the **Webhooks** section in the introduction."},{"name":"Usage","description":"View API usage statistics for your organization"}],"paths":{"/trackers":{"post":{"tags":["Trackers"],"summary":"Create a tracker","description":"Create a tracker to track a package. Provide a tracking_code and, optionally, a carrier id (use the ids from the carriers reference, such as \"USPS\", \"UPS\", \"FedEx\", \"DHL\"; common aliases are accepted, and the response echoes the carrier gateway's own id, which may differ from what you send; omit carrier to auto-detect from the tracking number). Some carriers must be enabled on your account before a tracker returns events — if a tracker stays in 'unknown' with no tracking_details, contact support@shipyu.com.","operationId":"createTracker","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrackerCreateRequest"},"example":{"tracker":{"tracking_code":"9400111899223033005436","carrier":"USPS"}}}}},"responses":{"200":{"description":"Tracker created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Tracker"},"example":{"id":"trk_abc123","object":"Tracker","mode":"test","tracking_code":"9400111899223033005436","status":"in_transit","carrier":"USPS","tracking_details":[{"object":"TrackingDetail","message":"Arrived at USPS Regional Facility","status":"in_transit","datetime":"2024-01-16T14:30:00Z","tracking_location":{"city":"Denver","state":"CO","country":"US","zip":"80266"}},{"object":"TrackingDetail","message":"Accepted at USPS Origin Facility","status":"in_transit","datetime":"2024-01-15T10:30:00Z","tracking_location":{"city":"San Francisco","state":"CA","country":"US","zip":"94104"}}],"est_delivery_date":"2024-01-18","created_at":"2024-01-15T10:35:00Z","updated_at":"2024-01-16T14:35:00Z"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/trackers/{id}":{"get":{"tags":["Trackers"],"summary":"Retrieve a tracker","description":"Retrieve an existing tracker by its ID.","operationId":"retrieveTracker","parameters":[{"name":"id","in":"path","required":true,"description":"The tracker ID (starts with trk_)","schema":{"type":"string"}}],"responses":{"200":{"description":"Tracker retrieved successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Tracker"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}},"delete":{"tags":["Trackers"],"summary":"Delete a tracker","description":"Delete an existing tracker.","operationId":"deleteTracker","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Tracker deleted"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/webhooks":{"post":{"tags":["Webhooks"],"summary":"Create a webhook","description":"Register a new webhook endpoint to receive event notifications.\n\n**Important — the signing secret is returned once.** The `secret` field on the response (format `whsec_<64 hex>`) is how you verify that incoming webhook POSTs actually came from Shipyu. Store it immediately; it cannot be retrieved later. See **Verifying webhook signatures** in the introduction for code samples.\n\n**URL validation.** The URL must be HTTPS and publicly reachable. We reject private IPs (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.1`, `localhost`), link-local (`169.254.0.0/16` — blocks AWS / GCP metadata endpoints), and `0.0.0.0`. DNS rebinding is also blocked at delivery time by re-resolving the IP and checking it against the same ranges.\n\n**Limits.** Max 100 webhooks per API client. Max 50 event types per webhook. Description max 1000 characters.","operationId":"createWebhook","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"HTTPS endpoint that will receive POSTs. Must be publicly reachable."},"description":{"type":"string","maxLength":1000,"description":"Optional free-text label for this webhook."},"event_types":{"type":"array","items":{"type":"string"},"maxItems":50,"description":"Subset of events this webhook should receive. Omit or set to `null` to receive ALL events. See the *Event Types Reference* in the introduction for valid values."}}},"example":{"url":"https://example.com/webhooks/shipyu","description":"Production tracker updates","event_types":["tracker.created","tracker.updated"]}}}},"responses":{"201":{"description":"Webhook created. The `secret` field is returned in this response only and cannot be retrieved later.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"},"example":{"id":"d3a4b5c6-1234-5678-90ab-cdef12345678","url":"https://example.com/webhooks/shipyu","description":"Production tracker updates","status":"active","event_types":["tracker.created","tracker.updated"],"failure_count":0,"last_success_at":null,"last_failure_at":null,"created_at":"2026-04-23T14:00:00Z","updated_at":"2026-04-23T14:00:00Z","secret":"whsec_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}},"get":{"tags":["Webhooks"],"summary":"List webhooks","description":"List all webhooks registered under the authenticated API client. Signing secrets are NOT returned by this endpoint.","operationId":"listWebhooks","responses":{"200":{"description":"List of webhooks","content":{"application/json":{"schema":{"type":"object","properties":{"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/webhooks/{id}":{"get":{"tags":["Webhooks"],"summary":"Retrieve a webhook","description":"Retrieve a webhook by ID, including its 10 most recent delivery attempts (useful for debugging).","operationId":"retrieveWebhook","parameters":[{"name":"id","in":"path","required":true,"description":"The webhook's UUID.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Webhook details with recent deliveries.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Webhook"},{"type":"object","properties":{"recent_deliveries":{"type":"array","maxItems":10,"items":{"$ref":"#/components/schemas/WebhookDelivery"}}}}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}},"patch":{"tags":["Webhooks"],"summary":"Update a webhook","description":"Update one or more fields on a webhook. All fields are optional — omitted fields are left unchanged.\n\nSetting `status: \"paused\"` holds deliveries. Held deliveries stay pending and are sent after you set `status: \"active\"` again.\n\nSetting `status: \"active\"` on a previously `paused` or `disabled` webhook re-enables delivery and resets `failure_count` to 0.\n\nThe signing secret **cannot be rotated** via this endpoint. If you need to rotate it, delete the webhook and create a new one.","operationId":"updateWebhook","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"description":{"type":["string","null"],"maxLength":1000},"event_types":{"type":["array","null"],"items":{"type":"string"},"maxItems":50},"status":{"type":"string","enum":["active","paused","disabled"]}}},"example":{"event_types":["tracker.updated"],"status":"active"}}}},"responses":{"200":{"description":"Webhook updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}},"delete":{"tags":["Webhooks"],"summary":"Delete a webhook","description":"Permanently delete a webhook. All pending and historical delivery records for this webhook are also removed (cascade). This cannot be undone.","operationId":"deleteWebhook","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Webhook deleted. No response body."},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/webhooks/{id}/test":{"post":{"tags":["Webhooks"],"summary":"Send a test delivery","description":"Fire a synthetic test event at the webhook's URL to verify your receiver is working and your signature verification is correct.\n\n**The payload is synthetic.** The test event always carries a `_test: true` flag, a `tracker.updated` `description`, and a result containing `tracking_code: \"TEST123456789\"` with a hard-coded timeline. It does NOT reference any real shipment — safe to fire at any time.\n\n**Test-specific headers.** Test deliveries include `X-Shipyu-Webhook-Test: true` in addition to the normal `X-Shipyu-Signature` / `X-Shipyu-Webhook-Id` headers, so your receiver can branch on test vs. real traffic.\n\n**Timeout.** Your receiver has 5 seconds to return a 2xx. Anything else (non-2xx, timeout, TLS failure, DNS failure) is reported as `success: false` with diagnostic fields populated. Test deliveries do **not** count against the auto-disable failure counter.","operationId":"testWebhook","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Test delivery completed. `success: true` means the receiver returned 2xx within 5 seconds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookTestResult"},"example":{"webhook_id":"d3a4b5c6-1234-5678-90ab-cdef12345678","url":"https://example.com/webhooks/shipyu","success":true,"http_status":200,"response_body":"{\"received\":true}","response_time_ms":187}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/me/usage":{"get":{"tags":["Usage"],"summary":"Get API usage","description":"Get usage statistics for your organization. The endpoint has two modes.\n\n**Summary mode (default).** Call the endpoint without bucket parameters. The response contains the current month, monthly history, and the 20 most recent requests. Use `months` to set the history length.\n\n**Bucketed mode.** Set `start_time`, `end_time`, `bucket_width`, or `group_by` to activate bucketed mode. Each bucket covers one UTC day. Set `group_by` to split each bucket by one dimension: `endpoint`, `key`, `carrier`, or `mode`. Dimension fields on results are `null` unless you group by them. The response contains the first 31 UTC days from `start_time`. `has_more` is `true` when the window holds more days. To read the next page, set `start_time` to the day after the last bucket.\n\n`bucket_width` accepts `1d` only. The widths `1h` and `1w` are reserved.\n\nUsage data can be up to 15 minutes old.","operationId":"getUsage","parameters":[{"name":"months","in":"query","description":"Summary mode only. Months of history to return.","schema":{"type":"integer","minimum":1,"maximum":12,"default":3}},{"name":"start_time","in":"query","description":"Bucketed mode. Start of the window. Accepts a Unix timestamp in seconds or an ISO-8601 date. The window snaps to whole UTC days. Default: 30 days ago.","schema":{"type":"string","example":"1785283200"}},{"name":"end_time","in":"query","description":"Bucketed mode. End of the window. The named day is included. Accepts a Unix timestamp in seconds or an ISO-8601 date. Default: today.","schema":{"type":"string","example":"2026-07-28"}},{"name":"bucket_width","in":"query","description":"Bucketed mode. Bucket size. Only `1d` is accepted.","schema":{"type":"string","enum":["1d"],"default":"1d"}},{"name":"group_by","in":"query","description":"Bucketed mode. Split each bucket by one dimension. `key` results include `api_key_id` and the human `api_key_name`.","schema":{"type":"string","enum":["endpoint","key","carrier","mode"]}}],"responses":{"200":{"description":"Usage statistics. The shape depends on the mode: a summary object by default, or a bucketed list when a bucket parameter is set.","content":{"application/json":{"schema":{"oneOf":[{"title":"UsageSummary","type":"object","description":"Summary mode (default).","properties":{"current_month":{"type":"object","nullable":true,"properties":{"request_count":{"type":"integer"},"successful_requests":{"type":"integer"},"failed_requests":{"type":"integer"},"avg_response_time_ms":{"type":"integer"},"success_rate":{"type":"number"}}},"history":{"type":"array","items":{"type":"object","properties":{"period_start":{"type":"string","format":"date-time"},"period_end":{"type":"string","format":"date-time"},"request_count":{"type":"integer"},"successful_requests":{"type":"integer"},"failed_requests":{"type":"integer"},"avg_response_time_ms":{"type":"integer"},"success_rate":{"type":"number"}}}},"recent_requests":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Internal record id. NOT a value you can look up — use request_id."},"request_id":{"type":["string","null"],"description":"The X-Shipyu-Request-Id for this request. This is the value to quote to support and the one the Logs console resolves. Null for records that predate the field."},"method":{"type":"string"},"path":{"type":"string"},"status_code":{"type":"integer"},"response_time_ms":{"type":"integer"},"created_at":{"type":"string","format":"date-time"}}}}}},{"title":"UsageBucketList","type":"object","description":"Bucketed mode.","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"type":"object","properties":{"object":{"type":"string","enum":["bucket"]},"start_time":{"type":"integer","description":"Unix epoch seconds. Inclusive UTC-day start."},"end_time":{"type":"integer","description":"Unix epoch seconds. Exclusive UTC-day end."},"results":{"type":"array","description":"One result per group value. Empty when the day has no requests.","items":{"type":"object","properties":{"object":{"type":"string","enum":["usage_result"]},"request_count":{"type":"integer"},"success_count":{"type":"integer"},"error_count":{"type":"integer"},"avg_latency_ms":{"type":"integer"},"endpoint":{"type":"string","nullable":true,"description":"Endpoint template. Set when group_by=endpoint."},"api_key_id":{"type":"string","nullable":true,"description":"Set when group_by=key. Null for requests whose key was deleted."},"api_key_name":{"type":"string","nullable":true,"description":"Human key name (label or key prefix). Set when group_by=key."},"carrier":{"type":"string","nullable":true,"description":"Set when group_by=carrier. Empty string when no carrier applies."},"mode":{"type":"string","nullable":true,"description":"Set when group_by=mode."}}}}}}},"has_more":{"type":"boolean","description":"True when the window holds more days than the 31-bucket maximum."}}}]},"examples":{"summary":{"summary":"Summary mode (default)","value":{"current_month":{"request_count":1250,"successful_requests":1238,"failed_requests":12,"avg_response_time_ms":187,"success_rate":0.9904},"history":[{"period_start":"2026-07-01T00:00:00.000Z","period_end":"2026-07-31T23:59:59.000Z","request_count":1250,"successful_requests":1238,"failed_requests":12,"avg_response_time_ms":187,"success_rate":0.9904}],"recent_requests":[{"id":"0d9f8a3e-1111-4222-8333-444455556666","method":"GET","path":"/trackers/trk_123","status_code":200,"response_time_ms":142,"created_at":"2026-07-28T10:30:00.000Z"}]}},"bucketed":{"summary":"Bucketed mode (group_by=endpoint)","value":{"object":"list","data":[{"object":"bucket","start_time":1785283200,"end_time":1785369600,"results":[{"object":"usage_result","request_count":142,"success_count":139,"error_count":3,"avg_latency_ms":187,"endpoint":"/trackers/{id}","api_key_id":null,"api_key_name":null,"carrier":null,"mode":null}]}],"has_more":false}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}}},"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","description":"API key authentication using Bearer tokens.\n\n**Key formats:**\n- Live API keys: `sk_shipyu_live_*`\n- Test API keys: `sk_shipyu_test_*`\n\nInclude your key in the Authorization header:\n```\nAuthorization: Bearer sk_shipyu_live_your_api_key_here\n```\n\nTest keys work identically to live keys but create test-mode objects that never contact carriers and never record charges."}},"parameters":{"PageSize":{"name":"page_size","in":"query","description":"Number of records per page (max 100)","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},"BeforeId":{"name":"before_id","in":"query","description":"Return records before this ID (for pagination)","schema":{"type":"string"}},"AfterId":{"name":"after_id","in":"query","description":"Return records after this ID (for pagination)","schema":{"type":"string"}}},"responses":{"BadRequest":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"type":"invalid_request_error","message":"Missing required field: street1","code":"parameter.required"}}}}},"Unauthorized":{"description":"Invalid or missing API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"type":"authentication_error","message":"Invalid API key provided","code":"api_key.invalid"}}}}},"Forbidden":{"description":"Endpoint not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"type":"forbidden_error","message":"This endpoint is not available through the Shipyu API","code":"endpoint_not_available"}}}}},"PaymentRequired":{"description":"API access is suspended due to a payment failure on the account. Resolve the payment in the Shipyu dashboard to restore access.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"type":"billing_error","message":"API access suspended due to payment failure","code":"billing_suspended"}}}}},"NotFound":{"description":"Resource not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"type":"invalid_request_error","message":"The requested resource could not be found","code":"not_found"}}}}},"RateLimited":{"description":"Rate limit exceeded","headers":{"X-RateLimit-Limit":{"description":"Maximum requests per window","schema":{"type":"integer"}},"X-RateLimit-Remaining":{"description":"Remaining requests in window","schema":{"type":"integer"}},"X-RateLimit-Reset":{"description":"Unix timestamp when window resets","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":{"type":"rate_limit_error","message":"Rate limit exceeded. Please retry after the reset time.","code":"rate_limit_exceeded"}}}}}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["type","message","code"],"properties":{"type":{"type":"string","description":"Error category","enum":["invalid_request_error","authentication_error","rate_limit_error","api_error","forbidden_error"]},"message":{"type":"string","description":"Human-readable error message"},"code":{"type":"string","description":"Machine-readable error code"}}}}},"Tracker":{"type":"object","properties":{"id":{"type":"string","description":"Unique identifier (trk_...)"},"object":{"type":"string","enum":["Tracker"]},"mode":{"type":"string","enum":["test","production"]},"tracking_code":{"type":"string"},"status":{"type":"string","enum":["pre_transit","in_transit","out_for_delivery","delivered","available_for_pickup","return_to_sender","failure","cancelled","error","unknown"]},"status_detail":{"type":"string"},"carrier":{"type":"string"},"tracking_details":{"type":"array","items":{"type":"object","properties":{"object":{"type":"string","enum":["TrackingDetail"]},"message":{"type":"string"},"description":{"type":"string"},"status":{"type":"string"},"status_detail":{"type":"string"},"datetime":{"type":"string","format":"date-time"},"source":{"type":"string"},"tracking_location":{"type":"object","properties":{"city":{"type":"string"},"state":{"type":"string"},"country":{"type":"string"},"zip":{"type":"string"}}}}}},"weight":{"type":"number"},"est_delivery_date":{"type":"string"},"shipment_id":{"type":"string"},"carrier_detail":{"type":"object"},"public_url":{"type":"string","format":"uri"},"fees":{"type":"array","items":{"type":"object"},"description":"Carrier-gateway fees for this tracker, passed through as reported. This is NOT what Shipyu bills you and is commonly 0.00 — see the billing docs for Shipyu's own per-tracker price, and `GET /v1/me/usage` for what you have actually been charged."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"TrackerCreateRequest":{"type":"object","properties":{"tracker":{"type":"object","required":["tracking_code"],"properties":{"tracking_code":{"type":"string","description":"The carrier tracking number"},"carrier":{"type":"string","description":"Carrier id (optional; auto-detected if omitted), e.g. \"USPS\", \"UPS\", \"FedEx\", \"DHL\". Common aliases are accepted. The response echoes the carrier gateway id, which may differ from the value you send."}}}}},"Webhook":{"type":"object","description":"A registered webhook endpoint. The `secret` field is only returned once, at creation time, and cannot be retrieved later — store it immediately.","required":["id","url","description","status","event_types","failure_count","last_success_at","last_failure_at","created_at","updated_at"],"properties":{"id":{"type":"string","format":"uuid","description":"Unique webhook identifier (UUID)."},"url":{"type":"string","format":"uri","description":"The HTTPS endpoint Shipyu POSTs events to. Must be publicly reachable; private / localhost / cloud-metadata URLs are rejected."},"description":{"type":["string","null"],"description":"Optional free-text description. Max 1000 characters."},"status":{"type":"string","enum":["active","paused","disabled"],"description":"`active`: deliveries are sent. `paused`: deliveries are held. Resume the webhook to send them. `disabled`: deliveries fail. A webhook is disabled automatically after 10 failed deliveries in a row, or manually via PATCH."},"event_types":{"type":["array","null"],"items":{"type":"string"},"description":"Event types this webhook subscribes to. `null` means subscribed to *all* events. Max 50 entries.","example":["tracker.updated"]},"failure_count":{"type":"integer","description":"Consecutive failed deliveries since the last success. Resets to 0 on a successful delivery. Webhook is auto-disabled when this reaches 10."},"last_success_at":{"type":["string","null"],"format":"date-time","description":"Timestamp of the last successful (2xx) delivery."},"last_failure_at":{"type":["string","null"],"format":"date-time","description":"Timestamp of the last failed delivery."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"secret":{"type":"string","description":"Signing secret (format `whsec_<64 hex>`). **Returned only on creation.** Use this to verify webhook signatures — see the Webhooks guide in the introduction.","example":"whsec_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"}}},"WebhookDelivery":{"type":"object","description":"A single attempted delivery of a webhook event.","required":["id","event_type","status","attempts","max_attempts","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"event_type":{"type":"string","description":"The event type that triggered this delivery (e.g. `tracker.updated`)."},"status":{"type":"string","enum":["pending","delivered","failed"]},"http_status":{"type":["integer","null"],"description":"HTTP status returned by the receiver, or null if delivery hasn't completed."},"attempts":{"type":"integer"},"max_attempts":{"type":"integer","description":"Total attempt budget for this delivery. Defaults to 5 (initial attempt + 4 retries)."},"delivered_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"WebhookTestResult":{"type":"object","description":"Result of firing a synthetic test delivery at a webhook's URL. The receiver has ~5 seconds to return a 2xx or the attempt is recorded as a failure.","required":["webhook_id","url","success","response_time_ms"],"properties":{"webhook_id":{"type":"string","format":"uuid"},"url":{"type":"string","format":"uri"},"success":{"type":"boolean","description":"True iff the receiver returned 2xx within 5s."},"http_status":{"type":["integer","null"]},"response_body":{"type":["string","null"],"description":"First 1024 bytes of the receiver's response body, for debugging. Longer bodies are truncated."},"response_time_ms":{"type":"integer"},"error":{"type":"string","description":"Populated when delivery could not complete (timeout, DNS failure, TLS error, etc.)."}}}}}}