Toni Maxx

API reference · v1 · sample

Sample Orders API

A fictional orders API for a small bakery, documented the way I document real ones: clear contracts, predictable errors, safe retries and signed webhooks.

  • Demo with sample data
  • Illustrative, not a live service
  • OpenAPI 3.1
  • REST · JSON

Request

curl "https://api.example.invalid/v1/orders/ord_01JB8N7R" \
  -H "X-API-Key: $SAMPLE_API_KEY"

Response · 200 OK · trimmed

{
  "id": "ord_01JB8N7R",
  "status": "paid",
  "customer_id": "cus_01JB8K2P",
  "total_cents": 3024,
  "currency": "usd",
  "created_at": "2026-11-09T09:41:12Z"
}

Overview#

The Sample Orders API is a small REST API for a fictional bakery shop: products on the shelf, customers who buy them, and orders that tie the two together. It is JSON over HTTPS, uses predictable resource URLs, and reports errors in one consistent format.

Illustrative only. This is a fictional API written to show how I design and document REST APIs. There is no running service, no sign-up, no real data and no keys to request. Every host name uses a reserved placeholder domain, and every name, e-mail address and id is made up. Demo with sample data.

What you can see here: the cross-cutting rules every client needs (authentication, versioning, limits, pagination, errors, idempotency, signed webhooks), then 16 endpoints with parameter tables, request and response examples and code in four languages, and a machine-readable openapi.json.

Base URL#

All requests go to one versioned base URL. The host below is a reserved placeholder (illustrative): it will not resolve.

https://api.example.invalid/v1
  • HTTPS only. Plain HTTP is refused, not redirected, so a key is never sent in clear text by mistake.
  • Requests and responses are UTF-8 JSON. Send Content-Type: application/json on requests with a body; responses are application/json, errors are application/problem+json.
  • Timestamps are UTC, RFC 3339 (2026-11-09T09:41:12Z). Money is an integer in the smallest currency unit (price_cents) plus a lower-case ISO 4217 currency; there are no floating-point prices.
  • Ids are opaque strings with a type prefix (prod_, cus_, ord_, whe_, evt_). Do not parse them.

Authentication#

Every request must carry credentials. Two methods are supported; both are scoped, and both travel in headers only.

API key in a header#

For server-to-server use. Send the key in X-API-Key. Keys belong on servers only (never in a mobile app, a browser or a repository), are shown once when created, can be limited to scopes, and can be revoked at any time. Rotate them on a schedule; 90 days is a sensible default.

curl "https://api.example.invalid/v1/orders?limit=5" \
  -H "X-API-Key: $SAMPLE_API_KEY"

Never in a query string. A key in a URL leaks into server logs, proxies, browser history and Referer headers. This API rejects it outright:

curl "https://api.example.invalid/v1/orders?api_key=NEVER-DO-THIS"
# -> 400 application/problem+json, type .../problems/credentials-in-query
#    (keys in URLs end up in logs, history and Referer headers)

OAuth 2.0 client credentials and OpenID Connect#

For integrations that should hold short-lived credentials. Exchange a client id and secret for an access token at the token endpoint, then send it as a bearer token.

curl -X POST "https://auth.example.invalid/oauth/token" \
  -u "$SAMPLE_CLIENT_ID:$SAMPLE_CLIENT_SECRET" \
  -d "grant_type=client_credentials" \
  -d "scope=orders:read orders:write"
{
  "access_token": "eyJhbGciOiJSUzI1NiJ9.sample-payload.sample-signature",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "orders:read orders:write"
}
curl "https://api.example.invalid/v1/orders?limit=5" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
  • Lifetime: access tokens last 900 seconds (15 minutes). The client-credentials flow issues no refresh token; request a new token shortly before expires_in runs out and cache it in memory until then.
  • OpenID Connect: for apps that act for a signed-in person, use the authorization-code flow with PKCE at your OIDC provider. The API accepts the resulting access token (a JWT) as a bearer token and validates iss, aud, exp and scope. It does not accept ID tokens.
  • Secrets: the client secret stays on a server. Never log tokens, and never put them in URLs.
  • A missing, expired or revoked credential returns 401. A valid credential without the needed scope returns 403.

Scopes#

ScopeAllows
products:readRead products.
products:writeCreate, update and archive products.
customers:readRead customers.
customers:writeCreate and update customers.
orders:readRead orders.
orders:writeCreate and cancel orders.
webhooks:manageManage webhook endpoints.

Versioning#

The major version is part of the path (/v1). Within a major version the API only changes in ways that should not break a careful client.

ChangeBreaking?How it ships
New endpoint, new optional parameter, new response fieldNoAdded to v1 at any time. Clients must ignore fields they do not know.
New value in an enum (for example a new order status)No, with a ruleClients must handle unknown enum values gracefully. Announced in the changelog first.
Remove or rename a field or endpoint, change a type, tighten validationYesNew major version (/v2) running side by side with v1.
Retire a versionYesAt least 12 months of notice (illustrative), with Deprecation and Sunset headers on every response.
HTTP/1.1 200 OK
Deprecation: true
Sunset: Wed, 11 Nov 2027 00:00:00 GMT
Link: <https://api.example.invalid/docs/migrate-v2>; rel="deprecation"

Clients may also send Sample-Version: 2026-11-01 to pin behaviour fixes inside v1 to a date. Without it you get the latest date at the time your key was created.

Rate limits#

Limits protect the service and keep one noisy client from slowing the others. The numbers below are illustrative.

BucketLimitApplies to
Reads600 requests per minute per credentialGET requests
Writes120 requests per minute per credentialPOST, PATCH, DELETE
Bursts20 requests per secondBoth, smoothed over the minute

Every response tells you where you stand, using the RateLimit-* header fields:

HTTP/1.1 200 OK
Content-Type: application/json
RateLimit-Limit: 600
RateLimit-Remaining: 598
RateLimit-Reset: 41
Request-Id: req_01JB8QX7
HeaderMeaning
RateLimit-LimitRequests allowed in the current window.
RateLimit-RemainingRequests left in the current window.
RateLimit-ResetSeconds until the window resets.
Retry-AfterOn 429 only: seconds to wait before retrying.
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 12
RateLimit-Limit: 600
RateLimit-Remaining: 0
RateLimit-Reset: 12
  • On 429, wait for Retry-After, then retry. Add random jitter so many clients do not retry in step.
  • Use exponential backoff for 5xx and network errors, and always with an idempotency key on writes.
  • Prefer webhooks and created_after over polling the list endpoints.

Pagination#

List endpoints use cursor pagination. Cursors stay correct while rows are added or removed, which offset pagination does not. Results are newest first.

ParameterTypeDescription
limitintegerItems per page, 1 to 100. Default 20.
cursorstringOpaque value from the previous response (page.next_cursor). Pass it back unchanged.
{
  "data": [
    {
      "id": "ord_01JB8N7R",
      "status": "paid",
      "customer_id": "cus_01JB8K2P",
      "items": [
        {
          "product_id": "prod_01JB8M3Q",
          "name": "Sourdough loaf",
          "quantity": 2,
          "unit_price_cents": 650,
          "line_total_cents": 1300
        },
        {
          "product_id": "prod_01JB8M4W",
          "name": "Butter croissant",
          "quantity": 4,
          "unit_price_cents": 375,
          "line_total_cents": 1500
        }
      ],
      "subtotal_cents": 2800,
      "tax_cents": 224,
      "total_cents": 3024,
      "currency": "usd",
      "note": "Pick-up at 8:30, please slice the loaves.",
      "created_at": "2026-11-09T09:41:12Z",
      "updated_at": "2026-11-09T09:41:30Z"
    }
  ],
  "page": {
    "next_cursor": "eyJpZCI6Im9yZF8wMUpCOE43UiJ9",
    "has_more": true
  }
}

When has_more is false, next_cursor is null and you have reached the end. A cursor is valid for 24 hours.

# page 1
curl "https://api.example.invalid/v1/orders?limit=2" -H "X-API-Key: $SAMPLE_API_KEY"

# page 2: pass next_cursor from page 1, unchanged
curl "https://api.example.invalid/v1/orders?limit=2&cursor=eyJpZCI6Im9yZF8wMUpCOE43UiJ9" -H "X-API-Key: $SAMPLE_API_KEY"
async function* allOrders(params = {}) {
  let cursor = null;
  do {
    const url = new URL("https://api.example.invalid/v1/orders");
    for (const [k, v] of Object.entries({ ...params, limit: 100 })) url.searchParams.set(k, v);
    if (cursor) url.searchParams.set("cursor", cursor);
    const res = await fetch(url, { headers: { "X-API-Key": process.env.SAMPLE_API_KEY } });
    if (!res.ok) throw await res.json();
    const { data, page } = await res.json();
    yield* data;
    cursor = page.has_more ? page.next_cursor : null;
  } while (cursor);
}

for await (const order of allOrders({ status: "paid" })) console.log(order.id);
<?php
function all_orders(array $params = []): Generator
{
    $cursor = null;
    do {
        $query = http_build_query($params + ['limit' => 100] + ($cursor ? ['cursor' => $cursor] : []));
        $ch = curl_init('https://api.example.invalid/v1/orders?' . $query);
        curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('SAMPLE_API_KEY')]]);
        $body = json_decode(curl_exec($ch), true);
        if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) >= 400) throw new RuntimeException($body['title'] ?? 'API error');
        yield from $body['data'];
        $cursor = $body['page']['has_more'] ? $body['page']['next_cursor'] : null;
    } while ($cursor);
}

foreach (all_orders(['status' => 'paid']) as $order) echo $order['id'], "\n";
import os, requests

def all_orders(**params):
    cursor = None
    while True:
        query = {**params, "limit": 100, **({"cursor": cursor} if cursor else {})}
        resp = requests.get("https://api.example.invalid/v1/orders", params=query,
                            headers={"X-API-Key": os.environ["SAMPLE_API_KEY"]}, timeout=10)
        resp.raise_for_status()
        body = resp.json()
        yield from body["data"]
        if not body["page"]["has_more"]:
            return
        cursor = body["page"]["next_cursor"]

for order in all_orders(status="paid"):
    print(order["id"])

Errors#

Errors use problem details (RFC 9457 style): HTTP status codes that mean what they say, and a JSON body of type application/problem+json with the same fields every time.

{
  "type": "https://api.example.invalid/problems/validation-failed",
  "title": "Validation failed",
  "status": 422,
  "detail": "One or more fields are invalid.",
  "instance": "/v1/orders",
  "request_id": "req_01JB8QX7",
  "errors": [
    {
      "field": "items[0].quantity",
      "code": "min",
      "message": "Must be at least 1."
    }
  ]
}
FieldMeaning
typeStable identifier of the problem kind. Branch on this, not on text.
titleShort summary, the same for every occurrence of a type.
statusThe HTTP status, repeated in the body.
detailExplains this occurrence. For people; it can change wording, so do not parse it.
instancePath of the request that failed.
request_idQuote it when you contact support.
errorsValidation problems only: one entry per field with field, code, message.

Problem types#

Type (after /problems/)StatusWhen
bad-request400The body is not valid JSON, or a parameter cannot be parsed.
credentials-in-query400A key or token was sent in the query string.
invalid-credentials401Missing, expired or revoked key or token.
insufficient-scope403Valid credential, missing scope.
not-found404No such resource, or it belongs to another account.
out-of-stock409Not enough stock to reserve for the order.
conflict409Duplicate SKU or e-mail, or an order in a state that cannot change.
idempotency-in-progress409The first request with this Idempotency-Key is still running.
validation-failed422Well-formed JSON with invalid values. See errors.
idempotency-key-reuse422Same key, different request body.
rate-limited429Over the limit. Read Retry-After.
internal-error500Our fault. Safe to retry with the same idempotency key.

Idempotency#

A network timeout leaves you not knowing whether an order was created. Retrying blindly could create two. So POST requests accept an Idempotency-Key header: a unique string of up to 64 characters (a UUID v4 is ideal). The server stores the first result for that key for 24 hours and replays it on a retry.

You sendThe server doesResult
Same key, same body, after the first finishedReplays the stored response, adds Idempotent-Replayed: true.Same status and body as the first call
Same key, same body, while the first is still runningRefuses to start a second one.409 idempotency-in-progress with Retry-After
Same key, different bodyTreats it as a client bug.422 idempotency-key-reuse
A new keyProcesses the request normally.A new resource
curl -X POST "https://api.example.invalid/v1/orders" \
  -H "X-API-Key: $SAMPLE_API_KEY" \
  -H "Idempotency-Key: 6f1d3c0e-8d6a-4a57-9a0b-2f2c6e3b9d11" \
  -H "Content-Type: application/json" \
  -d '{"customer_id": "cus_01JB8K2P", "items": [{"product_id": "prod_01JB8M3Q", "quantity": 2}]}'
# run it twice: the second response is identical and carries Idempotent-Replayed: true
  • Generate the key before the first attempt and reuse it for every retry of that same operation. Never reuse a key for a different operation.
  • GET, PATCH and DELETE are idempotent by design and need no key.

Webhooks#

Instead of polling, register an HTTPS endpoint and the API will POST an event to it when something happens.

EventSent when
order.createdAn order was created (pending).
order.paidPayment was confirmed.
order.fulfilledThe order was handed over.
order.cancelledThe order was cancelled and stock released.
product.low_stockstock_quantity fell below the threshold of 5.
{
  "id": "evt_01JB8Q9V",
  "type": "order.paid",
  "created_at": "2026-11-09T09:41:30Z",
  "data": {
    "object": {
      "id": "ord_01JB8N7R",
      "status": "paid",
      "total_cents": 3024,
      "currency": "usd"
    }
  }
}

Verify the signature#

Anyone can POST to a public URL, so verify every delivery before you trust it. Each request carries a Sample-Signature header:

POST /hooks/orders HTTP/1.1
Host: shop.example.com
Content-Type: application/json
User-Agent: Sample-Webhooks/1.0
Sample-Signature: t=1731144072,v1=40e9198e148b032bfc5d6a0c205e547d8dea4f51409377c6e8bd022efd3e9bc0
  1. Read the raw body exactly as received. Re-serialising parsed JSON changes the bytes and breaks the signature.
  2. Split the header into t (Unix seconds) and v1 (hex).
  3. Compute HMAC-SHA256 over the string t + "." + rawBody, keyed with your endpoint's signing secret.
  4. Compare with v1 using a constant-time function.
  5. Reject the request if t is more than 300 seconds away from your clock. That stops an attacker replaying a captured request later.
  6. Store processed event.id values and ignore repeats: deliveries are at least once.
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the exact bytes you received (Buffer or string), not re-serialised JSON.
export function verifySignature(rawBody, header, secret, { toleranceSeconds = 300, now = Math.floor(Date.now() / 1000) } = {}) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.trim().split("=", 2)));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(now - t) > toleranceSeconds) return false; // too old or from the future: possible replay
  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  const given = Buffer.from(parts.v1, "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

// Express: app.post("/hooks/orders", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifySignature(req.body, req.get("Sample-Signature") ?? "", process.env.SAMPLE_WEBHOOK_SECRET)) return res.sendStatus(400);
//   const event = JSON.parse(req.body); // safe to parse only now
//   res.sendStatus(200);                 // acknowledge fast, process later
// });
<?php
function verify_signature(string $rawBody, string $header, string $secret, int $tolerance = 300, ?int $now = null): bool
{
    $parts = [];
    foreach (explode(',', $header) as $kv) {
        [$k, $v] = array_pad(explode('=', trim($kv), 2), 2, '');
        $parts[$k] = $v;
    }
    if (!ctype_digit($parts['t'] ?? '') || ($parts['v1'] ?? '') === '') return false;
    if (abs(($now ?? time()) - (int) $parts['t']) > $tolerance) return false; // too old or from the future: possible replay
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1']); // constant-time compare
}

// $raw = file_get_contents('php://input');   // exact bytes, before json_decode
// $ok  = verify_signature($raw, $_SERVER['HTTP_SAMPLE_SIGNATURE'] ?? '', getenv('SAMPLE_WEBHOOK_SECRET'));
// if (!$ok) { http_response_code(400); exit; }
import hashlib, hmac, time

def verify_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300, now: int | None = None) -> bool:
    parts = dict(kv.strip().split("=", 1) for kv in header.split(",") if "=" in kv)
    if not parts.get("t", "").isdigit() or not parts.get("v1"):
        return False
    if abs((now if now is not None else int(time.time())) - int(parts["t"])) > tolerance:
        return False  # too old or from the future: possible replay
    expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

# raw_body must be the exact request bytes (Flask: request.get_data(), FastAPI: await request.body())

Test vector#

A fixed example to check your implementation against. The secret is fake. Pass the timestamp as "now" when you test, otherwise the replay check rejects the old time on purpose.

ItemValue
Signing secretwhsec_example_not_a_real_secret
t1731144072
Raw body{"id":"evt_01JB8Q9V","type":"order.paid","created_at":"2026-11-09T09:41:30Z","data":{"object":{"id":"ord_01JB8N7R","status":"paid","total_cents":3024,"currency":"usd"}}}
Expected v140e9198e148b032bfc5d6a0c205e547d8dea4f51409377c6e8bd022efd3e9bc0

Delivery and retries#

  • Reply with any 2xx within 10 seconds. Acknowledge first, process afterwards.
  • Anything else (or a timeout) is retried with exponential backoff for up to 24 hours. After repeated failures the endpoint is marked disabled.
  • Order is not guaranteed. Use created_at and fetch the resource if you need the latest state.
  • Manage endpoints with the webhook endpoint calls below. The signing secret is returned once, at creation.

Endpoints#

16 operations in four groups. Each row opens to show parameters, bodies, responses and code. Every endpoint accepts either credential type, and each lists the scope it needs.

16 of 16 endpoints

Products#

Items you sell. Prices are integer cents.

GET/productsList products

Returns products, newest first, in cursor pages. Combine filters freely; q matches name and SKU.

operationId listProductsscope products:read

Parameters

NameInTypeDescription
limitqueryintegerPage size, 1 to 100. Default 20.
cursorquerystringnext_cursor from the previous page.
categoryquerystringOne of bread, pastry, cake, drink.
in_stockquerybooleantrue to hide sold-out products.
qquerystringText search on name and SKU.

Responses

StatusMeaning
200A page of products.
400Malformed request (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl "https://api.example.invalid/v1/products?category=bread&in_stock=true&limit=20" \
  -H "X-API-Key: $SAMPLE_API_KEY"
const res = await fetch("https://api.example.invalid/v1/products?category=bread&in_stock=true&limit=20", {
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
  },
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/products?category=bread&in_stock=true&limit=20');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
    ],
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.get(
    "https://api.example.invalid/v1/products",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    params={
        "category": "bread",
        "in_stock": True,
        "limit": "20",
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "data": [
    {
      "id": "prod_01JB8M3Q",
      "sku": "SOUR-LOAF-800",
      "name": "Sourdough loaf",
      "description": "Naturally leavened, baked every morning.",
      "category": "bread",
      "price_cents": 650,
      "currency": "usd",
      "in_stock": true,
      "stock_quantity": 24,
      "created_at": "2026-10-02T08:15:00Z",
      "updated_at": "2026-11-09T06:30:00Z"
    },
    {
      "id": "prod_01JB8M4W",
      "sku": "BUTTER-CROIS",
      "name": "Butter croissant",
      "description": "Laminated dough, 27 layers.",
      "category": "pastry",
      "price_cents": 375,
      "currency": "usd",
      "in_stock": true,
      "stock_quantity": 60,
      "created_at": "2026-10-02T08:17:00Z",
      "updated_at": "2026-11-09T06:30:00Z"
    }
  ],
  "page": {
    "next_cursor": "eyJpZCI6InByb2RfMDFKQjhNNFcifQ",
    "has_more": true
  }
}
POST/productsCreate a product

Adds a product to the catalogue. The SKU must be unique; a duplicate returns 409.

operationId createProductscope products:write

Request body (application/json)

FieldTypeDescription
skuREQUIREDstringUnique SKU.
nameREQUIREDstringDisplay name.
descriptionstringOptional text.
categoryREQUIREDenumCategory. One of bread, pastry, cake, drink.
price_centsREQUIREDintegerUnit price in cents.
stock_quantityintegerInitial stock.
{
  "sku": "RYE-LOAF-600",
  "name": "Seeded rye",
  "category": "bread",
  "price_cents": 720,
  "stock_quantity": 12
}

Responses

StatusMeaning
201The new product.
409Not enough stock (problem+json).
422Validation failed (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl -X POST "https://api.example.invalid/v1/products" \
  -H "X-API-Key: $SAMPLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sku": "RYE-LOAF-600",
    "name": "Seeded rye",
    "category": "bread",
    "price_cents": 720,
    "stock_quantity": 12
  }'
const res = await fetch("https://api.example.invalid/v1/products", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    sku: "RYE-LOAF-600",
    name: "Seeded rye",
    category: "bread",
    price_cents: 720,
    stock_quantity: 12
  }),
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/products');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'sku' => 'RYE-LOAF-600',
        'name' => 'Seeded rye',
        'category' => 'bread',
        'price_cents' => 720,
        'stock_quantity' => 12,
    ]),
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.post(
    "https://api.example.invalid/v1/products",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    json={
        "sku": "RYE-LOAF-600",
        "name": "Seeded rye",
        "category": "bread",
        "price_cents": 720,
        "stock_quantity": 12,
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "id": "prod_01JB8M9K",
  "sku": "RYE-LOAF-600",
  "name": "Seeded rye",
  "description": null,
  "category": "bread",
  "price_cents": 720,
  "currency": "usd",
  "in_stock": true,
  "stock_quantity": 12,
  "created_at": "2026-11-09T10:20:00Z",
  "updated_at": "2026-11-09T10:20:00Z"
}
GET/products/{product_id}Retrieve a product

Returns one product by id.

operationId getProductscope products:read

Parameters

NameInTypeDescription
product_idREQUIREDpathstringProduct id, prefix prod_.

Responses

StatusMeaning
200The product.
404Resource not found (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl "https://api.example.invalid/v1/products/prod_01JB8M3Q" \
  -H "X-API-Key: $SAMPLE_API_KEY"
const res = await fetch("https://api.example.invalid/v1/products/prod_01JB8M3Q", {
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
  },
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/products/prod_01JB8M3Q');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
    ],
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.get(
    "https://api.example.invalid/v1/products/prod_01JB8M3Q",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "id": "prod_01JB8M3Q",
  "sku": "SOUR-LOAF-800",
  "name": "Sourdough loaf",
  "description": "Naturally leavened, baked every morning.",
  "category": "bread",
  "price_cents": 650,
  "currency": "usd",
  "in_stock": true,
  "stock_quantity": 24,
  "created_at": "2026-10-02T08:15:00Z",
  "updated_at": "2026-11-09T06:30:00Z"
}
PATCH/products/{product_id}Update a product

Changes only the fields you send. Sending the same values twice has the same result, so it is safe to retry.

operationId updateProductscope products:write

Parameters

NameInTypeDescription
product_idREQUIREDpathstringProduct id.

Request body (application/json)

FieldTypeDescription
namestringNew display name.
descriptionstringNew text.
price_centsintegerNew unit price in cents.
stock_quantityintegerSet the stock level.
{
  "price_cents": 690,
  "stock_quantity": 30
}

Responses

StatusMeaning
200The updated product.
404Resource not found (problem+json).
422Validation failed (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl -X PATCH "https://api.example.invalid/v1/products/prod_01JB8M3Q" \
  -H "X-API-Key: $SAMPLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "price_cents": 690,
    "stock_quantity": 30
  }'
const res = await fetch("https://api.example.invalid/v1/products/prod_01JB8M3Q", {
  method: "PATCH",
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    price_cents: 690,
    stock_quantity: 30
  }),
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/products/prod_01JB8M3Q');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'PATCH',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'price_cents' => 690,
        'stock_quantity' => 30,
    ]),
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.patch(
    "https://api.example.invalid/v1/products/prod_01JB8M3Q",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    json={
        "price_cents": 690,
        "stock_quantity": 30,
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "id": "prod_01JB8M3Q",
  "sku": "SOUR-LOAF-800",
  "name": "Sourdough loaf",
  "description": "Naturally leavened, baked every morning.",
  "category": "bread",
  "price_cents": 690,
  "currency": "usd",
  "in_stock": true,
  "stock_quantity": 30,
  "created_at": "2026-10-02T08:15:00Z",
  "updated_at": "2026-11-09T11:05:00Z"
}
DELETE/products/{product_id}Archive a product

Removes the product from the catalogue. Existing orders keep their line items. Archiving twice returns 204 both times.

operationId archiveProductscope products:write

Parameters

NameInTypeDescription
product_idREQUIREDpathstringProduct id.

Responses

StatusMeaning
204Archived. No body.
404Resource not found (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl -X DELETE "https://api.example.invalid/v1/products/prod_01JB8M3Q" \
  -H "X-API-Key: $SAMPLE_API_KEY"
const res = await fetch("https://api.example.invalid/v1/products/prod_01JB8M3Q", {
  method: "DELETE",
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
  },
});
if (!res.ok) throw await res.json(); // application/problem+json
// 204 No Content: nothing to parse
<?php
$ch = curl_init('https://api.example.invalid/v1/products/prod_01JB8M3Q');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'DELETE',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
    ],
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.delete(
    "https://api.example.invalid/v1/products/prod_01JB8M3Q",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
HTTP/1.1 204 No Content

Customers#

People who place orders.

GET/customersList customers

Returns customers, newest first. Filter by exact e-mail to look one up.

operationId listCustomersscope customers:read

Parameters

NameInTypeDescription
limitqueryintegerPage size, 1 to 100. Default 20.
cursorquerystringnext_cursor from the previous page.
emailquerystringExact match, case-insensitive.

Responses

StatusMeaning
200A page of customers.
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl "https://api.example.invalid/v1/customers?email=alex@example.com" \
  -H "X-API-Key: $SAMPLE_API_KEY"
const res = await fetch("https://api.example.invalid/v1/customers?email=alex@example.com", {
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
  },
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/customers?email=alex@example.com');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
    ],
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.get(
    "https://api.example.invalid/v1/customers",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    params={
        "email": "alex@example.com",
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "data": [
    {
      "id": "cus_01JB8K2P",
      "name": "Alex Example",
      "email": "alex@example.com",
      "marketing_opt_in": false,
      "created_at": "2026-10-21T14:02:00Z"
    }
  ],
  "page": {
    "next_cursor": null,
    "has_more": false
  }
}
POST/customersCreate a customer

Creates a customer. The e-mail must be unique within your account.

operationId createCustomerscope customers:write

Request body (application/json)

FieldTypeDescription
nameREQUIREDstringFull name.
emailREQUIREDstringUnique per account.
marketing_opt_inbooleanConsent flag.
{
  "name": "Sam Sample",
  "email": "sam@example.com",
  "marketing_opt_in": true
}

Responses

StatusMeaning
201The new customer.
409Not enough stock (problem+json).
422Validation failed (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl -X POST "https://api.example.invalid/v1/customers" \
  -H "X-API-Key: $SAMPLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Sam Sample",
    "email": "sam@example.com",
    "marketing_opt_in": true
  }'
const res = await fetch("https://api.example.invalid/v1/customers", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Sam Sample",
    email: "sam@example.com",
    marketing_opt_in: true
  }),
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/customers');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'name' => 'Sam Sample',
        'email' => 'sam@example.com',
        'marketing_opt_in' => true,
    ]),
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.post(
    "https://api.example.invalid/v1/customers",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    json={
        "name": "Sam Sample",
        "email": "sam@example.com",
        "marketing_opt_in": True,
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "id": "cus_01JB8K5D",
  "name": "Sam Sample",
  "email": "sam@example.com",
  "marketing_opt_in": true,
  "created_at": "2026-10-28T10:44:00Z"
}
GET/customers/{customer_id}Retrieve a customer

Returns one customer by id.

operationId getCustomerscope customers:read

Parameters

NameInTypeDescription
customer_idREQUIREDpathstringCustomer id, prefix cus_.

Responses

StatusMeaning
200The customer.
404Resource not found (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl "https://api.example.invalid/v1/customers/cus_01JB8K2P" \
  -H "X-API-Key: $SAMPLE_API_KEY"
const res = await fetch("https://api.example.invalid/v1/customers/cus_01JB8K2P", {
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
  },
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/customers/cus_01JB8K2P');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
    ],
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.get(
    "https://api.example.invalid/v1/customers/cus_01JB8K2P",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "id": "cus_01JB8K2P",
  "name": "Alex Example",
  "email": "alex@example.com",
  "marketing_opt_in": false,
  "created_at": "2026-10-21T14:02:00Z"
}
PATCH/customers/{customer_id}Update a customer

Changes only the fields you send.

operationId updateCustomerscope customers:write

Parameters

NameInTypeDescription
customer_idREQUIREDpathstringCustomer id.

Request body (application/json)

FieldTypeDescription
namestringNew name.
emailstringNew e-mail.
marketing_opt_inbooleanConsent flag.
{
  "marketing_opt_in": true
}

Responses

StatusMeaning
200The updated customer.
404Resource not found (problem+json).
409Not enough stock (problem+json).
422Validation failed (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl -X PATCH "https://api.example.invalid/v1/customers/cus_01JB8K2P" \
  -H "X-API-Key: $SAMPLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "marketing_opt_in": true
  }'
const res = await fetch("https://api.example.invalid/v1/customers/cus_01JB8K2P", {
  method: "PATCH",
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    marketing_opt_in: true
  }),
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/customers/cus_01JB8K2P');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'PATCH',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'marketing_opt_in' => true,
    ]),
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.patch(
    "https://api.example.invalid/v1/customers/cus_01JB8K2P",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    json={
        "marketing_opt_in": True,
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "id": "cus_01JB8K2P",
  "name": "Alex Example",
  "email": "alex@example.com",
  "marketing_opt_in": true,
  "created_at": "2026-10-21T14:02:00Z"
}

Orders#

Orders and their lifecycle.

GET/ordersList orders

Returns orders, newest first. Use created_after to sync incrementally.

operationId listOrdersscope orders:read

Parameters

NameInTypeDescription
limitqueryintegerPage size, 1 to 100. Default 20.
cursorquerystringnext_cursor from the previous page.
statusquerystringpending, paid, fulfilled or cancelled.
customer_idquerystringOnly this customer's orders.
created_afterquerystringRFC 3339 timestamp, exclusive.

Responses

StatusMeaning
200A page of orders.
400Malformed request (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl "https://api.example.invalid/v1/orders?status=paid&created_after=2026-11-01T00:00:00Z&limit=20" \
  -H "X-API-Key: $SAMPLE_API_KEY"
const res = await fetch("https://api.example.invalid/v1/orders?status=paid&created_after=2026-11-01T00:00:00Z&limit=20", {
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
  },
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/orders?status=paid&created_after=2026-11-01T00:00:00Z&limit=20');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
    ],
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.get(
    "https://api.example.invalid/v1/orders",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    params={
        "status": "paid",
        "created_after": "2026-11-01T00:00:00Z",
        "limit": "20",
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "data": [
    {
      "id": "ord_01JB8N7R",
      "status": "paid",
      "customer_id": "cus_01JB8K2P",
      "items": [
        {
          "product_id": "prod_01JB8M3Q",
          "name": "Sourdough loaf",
          "quantity": 2,
          "unit_price_cents": 650,
          "line_total_cents": 1300
        },
        {
          "product_id": "prod_01JB8M4W",
          "name": "Butter croissant",
          "quantity": 4,
          "unit_price_cents": 375,
          "line_total_cents": 1500
        }
      ],
      "subtotal_cents": 2800,
      "tax_cents": 224,
      "total_cents": 3024,
      "currency": "usd",
      "note": "Pick-up at 8:30, please slice the loaves.",
      "created_at": "2026-11-09T09:41:12Z",
      "updated_at": "2026-11-09T09:41:30Z"
    }
  ],
  "page": {
    "next_cursor": null,
    "has_more": false
  }
}
POST/ordersCreate an order

Creates an order in the pending state and reserves stock. Always send an Idempotency-Key: a retry after a timeout then returns the first result instead of a second order.

operationId createOrderscope orders:writesends Idempotency-Key

Parameters

NameInTypeDescription
Idempotency-KeyheaderstringUnique string, up to 64 characters. UUID v4 recommended. See Idempotency.

Request body (application/json)

FieldTypeDescription
customer_idREQUIREDstringExisting customer id.
itemsREQUIREDarrayItems as {product_id, quantity}.
notestringOptional note.
{
  "customer_id": "cus_01JB8K2P",
  "items": [
    {
      "product_id": "prod_01JB8M3Q",
      "quantity": 2
    },
    {
      "product_id": "prod_01JB8M4W",
      "quantity": 4
    }
  ],
  "note": "Pick-up at 8:30, please slice the loaves."
}

Responses

StatusMeaning
201The new order. Prices and tax are fixed by the server.
409Not enough stock (problem+json).
422Validation failed (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl -X POST "https://api.example.invalid/v1/orders" \
  -H "X-API-Key: $SAMPLE_API_KEY" \
  -H "Idempotency-Key: 6f1d3c0e-8d6a-4a57-9a0b-2f2c6e3b9d11" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "cus_01JB8K2P",
    "items": [
      {
        "product_id": "prod_01JB8M3Q",
        "quantity": 2
      },
      {
        "product_id": "prod_01JB8M4W",
        "quantity": 4
      }
    ],
    "note": "Pick-up at 8:30, please slice the loaves."
  }'
const res = await fetch("https://api.example.invalid/v1/orders", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
    "Idempotency-Key": crypto.randomUUID(),
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    customer_id: "cus_01JB8K2P",
    items: [
      {
        product_id: "prod_01JB8M3Q",
        quantity: 2
      },
      {
        product_id: "prod_01JB8M4W",
        quantity: 4
      }
    ],
    note: "Pick-up at 8:30, please slice the loaves."
  }),
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/orders');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'customer_id' => 'cus_01JB8K2P',
        'items' => [
            [
                'product_id' => 'prod_01JB8M3Q',
                'quantity' => 2,
            ],
            [
                'product_id' => 'prod_01JB8M4W',
                'quantity' => 4,
            ],
        ],
        'note' => 'Pick-up at 8:30, please slice the loaves.',
    ]),
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os, uuid
import requests

resp = requests.post(
    "https://api.example.invalid/v1/orders",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "customer_id": "cus_01JB8K2P",
        "items": [
            {
                "product_id": "prod_01JB8M3Q",
                "quantity": 2,
            },
            {
                "product_id": "prod_01JB8M4W",
                "quantity": 4,
            },
        ],
        "note": "Pick-up at 8:30, please slice the loaves.",
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "id": "ord_01JB8N7R",
  "status": "pending",
  "customer_id": "cus_01JB8K2P",
  "items": [
    {
      "product_id": "prod_01JB8M3Q",
      "name": "Sourdough loaf",
      "quantity": 2,
      "unit_price_cents": 650,
      "line_total_cents": 1300
    },
    {
      "product_id": "prod_01JB8M4W",
      "name": "Butter croissant",
      "quantity": 4,
      "unit_price_cents": 375,
      "line_total_cents": 1500
    }
  ],
  "subtotal_cents": 2800,
  "tax_cents": 224,
  "total_cents": 3024,
  "currency": "usd",
  "note": "Pick-up at 8:30, please slice the loaves.",
  "created_at": "2026-11-09T09:41:12Z",
  "updated_at": "2026-11-09T09:41:12Z"
}
GET/orders/{order_id}Retrieve an order

Returns one order by id.

operationId getOrderscope orders:read

Parameters

NameInTypeDescription
order_idREQUIREDpathstringOrder id, prefix ord_.

Responses

StatusMeaning
200The order.
404Resource not found (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl "https://api.example.invalid/v1/orders/ord_01JB8N7R" \
  -H "X-API-Key: $SAMPLE_API_KEY"
const res = await fetch("https://api.example.invalid/v1/orders/ord_01JB8N7R", {
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
  },
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/orders/ord_01JB8N7R');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
    ],
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.get(
    "https://api.example.invalid/v1/orders/ord_01JB8N7R",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "id": "ord_01JB8N7R",
  "status": "paid",
  "customer_id": "cus_01JB8K2P",
  "items": [
    {
      "product_id": "prod_01JB8M3Q",
      "name": "Sourdough loaf",
      "quantity": 2,
      "unit_price_cents": 650,
      "line_total_cents": 1300
    },
    {
      "product_id": "prod_01JB8M4W",
      "name": "Butter croissant",
      "quantity": 4,
      "unit_price_cents": 375,
      "line_total_cents": 1500
    }
  ],
  "subtotal_cents": 2800,
  "tax_cents": 224,
  "total_cents": 3024,
  "currency": "usd",
  "note": "Pick-up at 8:30, please slice the loaves.",
  "created_at": "2026-11-09T09:41:12Z",
  "updated_at": "2026-11-09T09:41:30Z"
}
POST/orders/{order_id}/cancelCancel an order

Cancels a pending or paid order and releases stock. A fulfilled order cannot be cancelled (409).

operationId cancelOrderscope orders:writesends Idempotency-Key

Parameters

NameInTypeDescription
order_idREQUIREDpathstringOrder id.
Idempotency-KeyheaderstringUnique string, up to 64 characters. UUID v4 recommended. See Idempotency.

Responses

StatusMeaning
200The cancelled order.
404Resource not found (problem+json).
409Not enough stock (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl -X POST "https://api.example.invalid/v1/orders/ord_01JB8N7R/cancel" \
  -H "X-API-Key: $SAMPLE_API_KEY" \
  -H "Idempotency-Key: 6f1d3c0e-8d6a-4a57-9a0b-2f2c6e3b9d11"
const res = await fetch("https://api.example.invalid/v1/orders/ord_01JB8N7R/cancel", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
    "Idempotency-Key": crypto.randomUUID(),
  },
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/orders/ord_01JB8N7R/cancel');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os, uuid
import requests

resp = requests.post(
    "https://api.example.invalid/v1/orders/ord_01JB8N7R/cancel",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
        "Idempotency-Key": str(uuid.uuid4()),
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "id": "ord_01JB8N7R",
  "status": "cancelled",
  "customer_id": "cus_01JB8K2P",
  "items": [
    {
      "product_id": "prod_01JB8M3Q",
      "name": "Sourdough loaf",
      "quantity": 2,
      "unit_price_cents": 650,
      "line_total_cents": 1300
    },
    {
      "product_id": "prod_01JB8M4W",
      "name": "Butter croissant",
      "quantity": 4,
      "unit_price_cents": 375,
      "line_total_cents": 1500
    }
  ],
  "subtotal_cents": 2800,
  "tax_cents": 224,
  "total_cents": 3024,
  "currency": "usd",
  "note": "Pick-up at 8:30, please slice the loaves.",
  "created_at": "2026-11-09T09:41:12Z",
  "updated_at": "2026-11-09T10:02:44Z"
}

Webhook endpoints#

Where your events are delivered.

GET/webhook-endpointsList webhook endpoints

Returns your registered endpoints. The signing secret is never shown again.

operationId listWebhookEndpointsscope webhooks:manage

Parameters

NameInTypeDescription
limitqueryintegerPage size, 1 to 100. Default 20.
cursorquerystringnext_cursor from the previous page.

Responses

StatusMeaning
200A page of endpoints.
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl "https://api.example.invalid/v1/webhook-endpoints" \
  -H "X-API-Key: $SAMPLE_API_KEY"
const res = await fetch("https://api.example.invalid/v1/webhook-endpoints", {
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
  },
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/webhook-endpoints');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
    ],
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.get(
    "https://api.example.invalid/v1/webhook-endpoints",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "data": [
    {
      "id": "whe_01JB8P1T",
      "url": "https://shop.example.com/hooks/orders",
      "events": [
        "order.created",
        "order.paid"
      ],
      "status": "enabled",
      "created_at": "2026-11-01T12:00:00Z"
    }
  ],
  "page": {
    "next_cursor": null,
    "has_more": false
  }
}
POST/webhook-endpointsCreate a webhook endpoint

Registers an HTTPS URL. The response includes signing_secret once; store it now.

operationId createWebhookEndpointscope webhooks:manage

Request body (application/json)

FieldTypeDescription
urlREQUIREDstringHTTPS only.
eventsREQUIREDarrayEvent types, see the webhooks section.
{
  "url": "https://shop.example.com/hooks/orders",
  "events": [
    "order.created",
    "order.paid"
  ]
}

Responses

StatusMeaning
201The endpoint, with its signing secret.
422Validation failed (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl -X POST "https://api.example.invalid/v1/webhook-endpoints" \
  -H "X-API-Key: $SAMPLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://shop.example.com/hooks/orders",
    "events": [
      "order.created",
      "order.paid"
    ]
  }'
const res = await fetch("https://api.example.invalid/v1/webhook-endpoints", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://shop.example.com/hooks/orders",
    events: ["order.created", "order.paid"]
  }),
});
if (!res.ok) throw await res.json(); // application/problem+json
const data = await res.json();
<?php
$ch = curl_init('https://api.example.invalid/v1/webhook-endpoints');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'url' => 'https://shop.example.com/hooks/orders',
        'events' => ['order.created', 'order.paid'],
    ]),
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.post(
    "https://api.example.invalid/v1/webhook-endpoints",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    json={
        "url": "https://shop.example.com/hooks/orders",
        "events": ["order.created", "order.paid"],
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
data = resp.json()
{
  "id": "whe_01JB8P1T",
  "url": "https://shop.example.com/hooks/orders",
  "events": [
    "order.created",
    "order.paid"
  ],
  "status": "enabled",
  "created_at": "2026-11-01T12:00:00Z",
  "signing_secret": "whsec_example_not_a_real_secret"
}
DELETE/webhook-endpoints/{endpoint_id}Delete a webhook endpoint

Stops deliveries to this endpoint. Events already queued are dropped.

operationId deleteWebhookEndpointscope webhooks:manage

Parameters

NameInTypeDescription
endpoint_idREQUIREDpathstringEndpoint id, prefix whe_.

Responses

StatusMeaning
204Deleted. No body.
404Resource not found (problem+json).
401 / 403 / 429Credentials, scope or rate limit. See Authentication, Rate limits and Errors.

Example request

curl -X DELETE "https://api.example.invalid/v1/webhook-endpoints/whe_01JB8P1T" \
  -H "X-API-Key: $SAMPLE_API_KEY"
const res = await fetch("https://api.example.invalid/v1/webhook-endpoints/whe_01JB8P1T", {
  method: "DELETE",
  headers: {
    "X-API-Key": process.env.SAMPLE_API_KEY,
  },
});
if (!res.ok) throw await res.json(); // application/problem+json
// 204 No Content: nothing to parse
<?php
$ch = curl_init('https://api.example.invalid/v1/webhook-endpoints/whe_01JB8P1T');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'DELETE',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'X-API-Key: ' . getenv('SAMPLE_API_KEY'),
    ],
]);
$raw    = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = $raw === '' ? null : json_decode($raw, true); // problem+json when $status >= 400
import os
import requests

resp = requests.delete(
    "https://api.example.invalid/v1/webhook-endpoints/whe_01JB8P1T",
    headers={
        "X-API-Key": os.environ["SAMPLE_API_KEY"],
    },
    timeout=10,
)
resp.raise_for_status()  # on errors the body is application/problem+json
HTTP/1.1 204 No Content

Status codes#

CodeNameUsed when
200OKSuccessful read or update.
201CreatedA resource was created. The body is the new resource.
204No ContentSuccess with nothing to return (archive, delete).
400Bad RequestMalformed JSON or an unparsable parameter.
401UnauthorizedMissing, expired or revoked credential.
403ForbiddenValid credential without the needed scope.
404Not FoundNo such resource for this account.
409ConflictState conflict: duplicate, out of stock, not cancellable, request in progress.
422Unprocessable ContentValid JSON, invalid values.
429Too Many RequestsRate limit. Read Retry-After.
500Internal Server ErrorOur fault. Retry with the same idempotency key.
503Service UnavailableMaintenance or overload. Retry with backoff.

Try it#

Copy these into a terminal to see the shape of a real session. Nothing runs on this page: it makes no API calls, and the host below is a placeholder that will not answer.

Show a three-step curl walk-through

Set your credentials as environment variables so they never appear in the command or your shell history file.

export SAMPLE_API_KEY="paste-your-key-here"
curl "https://api.example.invalid/v1/products?category=bread&in_stock=true&limit=5" \
  -H "X-API-Key: $SAMPLE_API_KEY"
curl -X POST "https://api.example.invalid/v1/orders" \
  -H "X-API-Key: $SAMPLE_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"customer_id": "cus_01JB8K2P", "items": [{"product_id": "prod_01JB8M3Q", "quantity": 2}]}'
curl "https://api.example.invalid/v1/orders/ord_01JB8N7R" \
  -H "X-API-Key: $SAMPLE_API_KEY"

OpenAPI file#

The whole contract in one file: OpenAPI 3.1.0, 9 paths, 16 operations, 15 schemas, three security schemes, and the webhook delivery described under webhooks. Import it into any OpenAPI-aware tool to browse it, generate a client or build contract tests.

The file was checked with a small structural script (references, unique operation ids, path parameters, examples against their schemas) and an OpenAPI linter before publishing. Its servers entry is the same placeholder host as above.

Design notes#

Honest limits#

  • Not a live service. There is no server behind this API. Nothing can be called, signed up for or tested against it.
  • Sample data. The bakery, products, customers, orders, ids, timestamps and totals are invented. E-mail addresses use example.com; hosts use .invalid.
  • Illustrative numbers. Rate limits, token lifetimes, retention windows and the 12-month sunset notice are plausible examples, not commitments.
  • Hand-written. The page and the OpenAPI file are written by hand, not generated from a running codebase. Real projects keep the spec and the code in step with contract tests; that part is not shown here.
  • Snippets. The webhook verification code was run against the test vector above. The request samples were syntax-checked, not executed against a server, because there is none.

Choices worth noting#

  • Integer cents for money, so totals add up exactly.
  • Cursor pagination with a fixed envelope (data, page), so every list looks the same.
  • One error format (problem details) with a stable type to branch on, plus a request_id for support.
  • Idempotency keys on the writes that cannot safely repeat, and naturally idempotent verbs everywhere else.
  • Signed, time-limited webhooks so a receiver can prove who sent a request and refuse replays.
  • Credentials in headers only, scoped per capability, with short-lived tokens as the default for integrations.
  • Server-fixed prices and tax: the client says what and how many, the server decides what it costs.

Need an API like this?

I design, build and document REST APIs, webhooks and integrations, from the contract to the code and the docs. Tell me what you need.