# Shipyu Shipping API — full API documentation (plain text) Version 1.0.0. JSON spec: https://shipyu.com/api/openapi. Short index: https://shipyu.com/llms.txt. Servers: - https://shipyu.com/api/v1 — Production API - http://localhost:3000/api/v1 — Local development 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. Three concepts cover the whole API: - **Trackers** — one object per tracked package. Create it, then read it by id. Updates arrive by webhook. - **Webhooks** — signed HTTPS deliveries to your endpoint on every tracker change. - **Usage** — your request counts, bucketed for dashboards and alerting. ## Base URL All API endpoints are relative to: `https://shipyu.com/api/v1` ## Authentication All API requests require authentication using a Bearer token. Include your API key in the Authorization header: ``` Authorization: Bearer sk_shipyu_live_your_api_key_here ``` ### API Key Types - **Live keys** (`sk_shipyu_live_*`) — production. Real carrier tracking. Real money. - **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. Keys are a prefix (`sk_shipyu_live_` or `sk_shipyu_test_`) followed by exactly 32 lowercase hex characters. ### Error responses Every error response has this shape — parse it, don't just check status codes: ```json { "error": { "type": "authentication_error", "message": "Invalid API key provided", "code": "api_key.invalid" } } ``` Common `type` values: `authentication_error`, `invalid_request_error`, `forbidden_error`, `rate_limit_error`, `billing_error`, `not_found_error`, `api_error`, `validation_error`. ## Rate Limits Rate 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**. Every response carries: - `X-RateLimit-Limit` — the limit currently being applied - `X-RateLimit-Remaining` — requests remaining in the window - `X-RateLimit-Reset` — Unix timestamp when the window resets On a `429`, back off until the reset timestamp and retry. If you need higher limits, contact support. ## Request IDs Every response includes `X-Shipyu-Request-Id`. Include this header value when contacting support — it lets us trace the exact request through our logs. --- ## Webhooks Webhooks 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. ### Event types reference | Event | When it fires | |---|---| | `tracker.created` | A tracker is created (e.g. via `POST /trackers`) | | `tracker.updated` | A tracker's status or tracking_details changes | When 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. ### Verifying webhook signatures Every 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. **Header format** ``` X-Shipyu-Signature: t=,v1= ``` **Algorithm** `v1` is `HMAC-SHA256(secret, ".")` — the timestamp is part of the signed payload, so a replayed request with a stale `t` can be detected. **Steps to verify** 1. Parse `t` and `v1` out of the header. 2. Reject if `|now_in_seconds - t| > 300` (5-minute freshness window). 3. Compute `expected = HMAC-SHA256(secret, t + "." + raw_body)`. 4. Compare `expected` to `v1` using a **constant-time** equality function (e.g. `crypto.timingSafeEqual` in Node, `hmac.compare_digest` in Python). **Additional delivery headers** Real deliveries send: - `X-Shipyu-Delivery-Id` — UUID of the delivery attempt (unique per retry; use it as an idempotency key on your side). - `X-Shipyu-Event-Type` — e.g. `tracker.updated`. Test deliveries from `POST /webhooks/{id}/test` send a different set: - `X-Shipyu-Webhook-Id` — UUID of the webhook endpoint being tested. - `X-Shipyu-Webhook-Test` — `"true"`. Absent on real traffic. The `X-Shipyu-Signature` header is present on both. **Node.js (Express)** ```javascript import crypto from "node:crypto"; import express from "express"; const WEBHOOK_TOLERANCE_SECONDS = 300; const SECRET = process.env.SHIPYU_WEBHOOK_SECRET; // starts with "whsec_" function verifyShipyuSignature(rawBody, signatureHeader, secret) { const parts = Object.fromEntries( signatureHeader.split(",").map((p) => p.split("=")) ); const timestamp = Number(parts.t); const signature = parts.v1; if (!timestamp || !signature) return { valid: false, reason: "bad header" }; const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > WEBHOOK_TOLERANCE_SECONDS) { return { valid: false, reason: "stale signature" }; } const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`, "utf8") .digest("hex"); const a = Buffer.from(signature, "hex"); const b = Buffer.from(expected, "hex"); if (a.length !== b.length) return { valid: false, reason: "length mismatch" }; return { valid: crypto.timingSafeEqual(a, b) }; } const app = express(); app.post( "/webhooks/shipyu", express.raw({ type: "application/json" }), (req, res) => { const sig = req.header("X-Shipyu-Signature"); const { valid, reason } = verifyShipyuSignature(req.body.toString(), sig, SECRET); if (!valid) return res.status(401).send(reason); const event = JSON.parse(req.body.toString()); if (event._test) { // Synthetic test event — safe to log and 200. return res.status(200).send("ok"); } // Handle real event... res.status(200).send("ok"); } ); ``` **Python (Flask)** ```python import hmac, hashlib, time, os from flask import Flask, request, abort WEBHOOK_TOLERANCE_SECONDS = 300 SECRET = os.environ["SHIPYU_WEBHOOK_SECRET"] # "whsec_..." app = Flask(__name__) def verify_shipyu_signature(raw_body: bytes, signature_header: str, secret: str): parts = dict(kv.split("=", 1) for kv in signature_header.split(",")) try: t = int(parts["t"]) v1 = parts["v1"] except (KeyError, ValueError): return False, "bad header" if abs(int(time.time()) - t) > WEBHOOK_TOLERANCE_SECONDS: return False, "stale signature" signed = f"{t}.{raw_body.decode('utf-8')}".encode() expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1), "ok" @app.post("/webhooks/shipyu") def shipyu_webhook(): raw = request.get_data() # raw bytes, do not parse first sig = request.headers.get("X-Shipyu-Signature", "") ok, reason = verify_shipyu_signature(raw, sig, SECRET) if not ok: abort(401, reason) # Handle event... return "", 200 ``` > **Tip** — always verify against the **raw request body**, not the parsed JSON. Re-serializing changes whitespace and breaks the signature. ### Testing a webhook Use `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. ### Retries and auto-disable If 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): | Attempt | Delay after previous attempt | |---|---| | 1 | immediate | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | All delays include ±20% jitter. After the 5th attempt fails, the delivery's `status` becomes `failed` and it is not retried further. Separately, 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. You 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. Test deliveries (via `/test`) do **not** count toward the auto-disable counter. ### Webhook URL requirements (SSRF protection) To prevent SSRF attacks that could expose internal services, webhook URLs must: - Use the `https://` scheme (`http://` is rejected). - Resolve to a **public** IP address. We reject (at registration **and** at every delivery, to defend against DNS rebinding): - Loopback: `127.0.0.0/8`, `localhost` - Private RFC 1918: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` - Link-local: `169.254.0.0/16` (includes AWS/GCP metadata endpoints like `169.254.169.254`) - Unspecified: `0.0.0.0` - Cloud metadata hostnames: `metadata.google.internal`, etc. ### Secret rotation `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. --- ## Test mode - Use `sk_shipyu_test_*` keys for development. Same endpoints, same request shapes, same response shapes. - Test-mode requests do not contact carriers and do not record billable events. - Webhooks registered against a test key receive events triggered by test-mode objects. - There is no separate staging URL — point at `https://shipyu.com/api/v1` with a test key. ## Billable events Certain endpoints generate billable events in live mode. Test-mode calls are free. | Endpoint | Billable | Notes | |---|---|---| | `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. | | All read endpoints (`GET`) | — | Free | | Webhook deliveries | — | Free | Usage 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. Tracker pricing is graduated by monthly volume. Each bracket bills at its own rate — crossing a tier discounts only the trackers above the threshold: | Monthly tracking volume | Price per tracker | |---|---| | 0 – 25,000 | $0.020 | | 25,001 – 100,000 | $0.019 | | 100,001 – 250,000 | $0.018 | | 250,001 – 500,000 | $0.017 | | 500,001 – 1,000,000 | $0.016 | | 1,000,001+ | Starting at $0.015 (contact sales) | Example: 120,000 trackers in one month bill as 25,000 × $0.020 + 75,000 × $0.019 + 20,000 × $0.018 = $2,285.00. Invoices 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. ## Endpoints ### POST /trackers Create a tracker Create a tracker to track a package. Provide a tracking_code and, optionally, a carrier id (use canonical ids such as "USPS", "UPS", "FedEx", "DHLExpress"; 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. Request body (application/json): TrackerCreateRequest (required) Responses: - 200: Tracker created successfully → Tracker - 400 - 401 - 429 ### GET /trackers/{id} Retrieve a tracker Retrieve an existing tracker by its ID. Parameters: - id (path, string, required): The tracker ID (starts with trk_) Responses: - 200: Tracker retrieved successfully → Tracker - 401 - 404 - 429 ### DELETE /trackers/{id} Delete a tracker Delete an existing tracker. Parameters: - id (path, string, required) Responses: - 200: Tracker deleted - 401 - 404 - 429 ### POST /webhooks Create a webhook Register a new webhook endpoint to receive event notifications. **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. **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. **Limits.** Max 100 webhooks per API client. Max 50 event types per webhook. Description max 1000 characters. Request body (application/json): object (required) Responses: - 201: Webhook created. The `secret` field is returned in this response only and cannot be retrieved later. → Webhook - 400 - 401 - 402 - 403 - 429 ### GET /webhooks List webhooks List all webhooks registered under the authenticated API client. Signing secrets are NOT returned by this endpoint. Responses: - 200: List of webhooks → object - 401 - 402 - 429 ### GET /webhooks/{id} Retrieve a webhook Retrieve a webhook by ID, including its 10 most recent delivery attempts (useful for debugging). Parameters: - id (path, string, required): The webhook's UUID. Responses: - 200: Webhook details with recent deliveries. → object - 401 - 404 - 429 ### PATCH /webhooks/{id} Update a webhook Update one or more fields on a webhook. All fields are optional — omitted fields are left unchanged. Setting `status: "paused"` holds deliveries. Held deliveries stay pending and are sent after you set `status: "active"` again. Setting `status: "active"` on a previously `paused` or `disabled` webhook re-enables delivery and resets `failure_count` to 0. The signing secret **cannot be rotated** via this endpoint. If you need to rotate it, delete the webhook and create a new one. Parameters: - id (path, string, required) Request body (application/json): object Responses: - 200: Webhook updated → Webhook - 400 - 401 - 404 - 429 ### DELETE /webhooks/{id} Delete a webhook Permanently delete a webhook. All pending and historical delivery records for this webhook are also removed (cascade). This cannot be undone. Parameters: - id (path, string, required) Responses: - 204: Webhook deleted. No response body. - 401 - 404 - 429 ### POST /webhooks/{id}/test Send a test delivery Fire a synthetic test event at the webhook's URL to verify your receiver is working and your signature verification is correct. **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. **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. **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. Parameters: - id (path, string, required) Responses: - 200: Test delivery completed. `success: true` means the receiver returned 2xx within 5 seconds. → WebhookTestResult - 401 - 404 - 429 ### GET /me/usage Get API usage Get usage statistics for your organization. The endpoint has two modes. **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. **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. `bucket_width` accepts `1d` only. The widths `1h` and `1w` are reserved. Usage data can be up to 15 minutes old. Parameters: - months (query, integer, optional): Summary mode only. Months of history to return. - start_time (query, string, optional): 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. - end_time (query, string, optional): Bucketed mode. End of the window. The named day is included. Accepts a Unix timestamp in seconds or an ISO-8601 date. Default: today. - bucket_width (query, "1d", optional): Bucketed mode. Bucket size. Only `1d` is accepted. - group_by (query, "endpoint" | "key" | "carrier" | "mode", optional): Bucketed mode. Split each bucket by one dimension. `key` results include `api_key_id` and the human `api_key_name`. Responses: - 200: Usage statistics. The shape depends on the mode: a summary object by default, or a bucketed list when a bucket parameter is set. → object - 400 - 401 - 429 ## Schemas ### Error Properties: - error (object, required) ### Tracker Properties: - id (string, optional): Unique identifier (trk_...) - object ("Tracker", optional) - mode ("test" | "production", optional) - tracking_code (string, optional) - status ("pre_transit" | "in_transit" | "out_for_delivery" | "delivered" | "available_for_pickup" | "return_to_sender" | "failure" | "cancelled" | "error" | "unknown", optional) - status_detail (string, optional) - carrier (string, optional) - tracking_details (array of object, optional) - weight (number, optional) - est_delivery_date (string, optional) - shipment_id (string, optional) - carrier_detail (object, optional) - public_url (string, optional) - fees (array of object, optional) - created_at (string, optional) - updated_at (string, optional) ### TrackerCreateRequest Properties: - tracker (object, optional) ### Webhook A registered webhook endpoint. The `secret` field is only returned once, at creation time, and cannot be retrieved later — store it immediately. Properties: - id (string, required): Unique webhook identifier (UUID). - url (string, required): The HTTPS endpoint Shipyu POSTs events to. Must be publicly reachable; private / localhost / cloud-metadata URLs are rejected. - description (string,null, required): Optional free-text description. Max 1000 characters. - status ("active" | "paused" | "disabled", required): `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 (array,null, required): Event types this webhook subscribes to. `null` means subscribed to *all* events. Max 50 entries. - failure_count (integer, required): 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 (string,null, required): Timestamp of the last successful (2xx) delivery. - last_failure_at (string,null, required): Timestamp of the last failed delivery. - created_at (string, required) - updated_at (string, required) - secret (string, optional): Signing secret (format `whsec_<64 hex>`). **Returned only on creation.** Use this to verify webhook signatures — see the Webhooks guide in the introduction. ### WebhookDelivery A single attempted delivery of a webhook event. Properties: - id (string, required) - event_type (string, required): The event type that triggered this delivery (e.g. `tracker.updated`). - status ("pending" | "delivered" | "failed", required) - http_status (integer,null, optional): HTTP status returned by the receiver, or null if delivery hasn't completed. - attempts (integer, required) - max_attempts (integer, required): Total attempt budget for this delivery. Defaults to 5 (initial attempt + 4 retries). - delivered_at (string,null, optional) - created_at (string, required) ### WebhookTestResult 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. Properties: - webhook_id (string, required) - url (string, required) - success (boolean, required): True iff the receiver returned 2xx within 5s. - http_status (integer,null, optional) - response_body (string,null, optional): First 1024 bytes of the receiver's response body, for debugging. Longer bodies are truncated. - response_time_ms (integer, required) - error (string, optional): Populated when delivery could not complete (timeout, DNS failure, TLS error, etc.).