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/jsonon requests with a body; responses areapplication/json, errors areapplication/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 4217currency; 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_inruns 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,expandscope. 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#
| Scope | Allows |
|---|---|
products:read | Read products. |
products:write | Create, update and archive products. |
customers:read | Read customers. |
customers:write | Create and update customers. |
orders:read | Read orders. |
orders:write | Create and cancel orders. |
webhooks:manage | Manage 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.
| Change | Breaking? | How it ships |
|---|---|---|
| New endpoint, new optional parameter, new response field | No | Added 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 rule | Clients must handle unknown enum values gracefully. Announced in the changelog first. |
| Remove or rename a field or endpoint, change a type, tighten validation | Yes | New major version (/v2) running side by side with v1. |
| Retire a version | Yes | At 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.
| Bucket | Limit | Applies to |
|---|---|---|
| Reads | 600 requests per minute per credential | GET requests |
| Writes | 120 requests per minute per credential | POST, PATCH, DELETE |
| Bursts | 20 requests per second | Both, 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| Header | Meaning |
|---|---|
RateLimit-Limit | Requests allowed in the current window. |
RateLimit-Remaining | Requests left in the current window. |
RateLimit-Reset | Seconds until the window resets. |
Retry-After | On 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_afterover 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.
| Parameter | Type | Description |
|---|---|---|
limit | integer | Items per page, 1 to 100. Default 20. |
cursor | string | Opaque 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."
}
]
}| Field | Meaning |
|---|---|
type | Stable identifier of the problem kind. Branch on this, not on text. |
title | Short summary, the same for every occurrence of a type. |
status | The HTTP status, repeated in the body. |
detail | Explains this occurrence. For people; it can change wording, so do not parse it. |
instance | Path of the request that failed. |
request_id | Quote it when you contact support. |
errors | Validation problems only: one entry per field with field, code, message. |
Problem types#
| Type (after /problems/) | Status | When |
|---|---|---|
bad-request | 400 | The body is not valid JSON, or a parameter cannot be parsed. |
credentials-in-query | 400 | A key or token was sent in the query string. |
invalid-credentials | 401 | Missing, expired or revoked key or token. |
insufficient-scope | 403 | Valid credential, missing scope. |
not-found | 404 | No such resource, or it belongs to another account. |
out-of-stock | 409 | Not enough stock to reserve for the order. |
conflict | 409 | Duplicate SKU or e-mail, or an order in a state that cannot change. |
idempotency-in-progress | 409 | The first request with this Idempotency-Key is still running. |
validation-failed | 422 | Well-formed JSON with invalid values. See errors. |
idempotency-key-reuse | 422 | Same key, different request body. |
rate-limited | 429 | Over the limit. Read Retry-After. |
internal-error | 500 | Our 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 send | The server does | Result |
|---|---|---|
| Same key, same body, after the first finished | Replays the stored response, adds Idempotent-Replayed: true. | Same status and body as the first call |
| Same key, same body, while the first is still running | Refuses to start a second one. | 409 idempotency-in-progress with Retry-After |
| Same key, different body | Treats it as a client bug. | 422 idempotency-key-reuse |
| A new key | Processes 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,PATCHandDELETEare 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.
| Event | Sent when |
|---|---|
order.created | An order was created (pending). |
order.paid | Payment was confirmed. |
order.fulfilled | The order was handed over. |
order.cancelled | The order was cancelled and stock released. |
product.low_stock | stock_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- Read the raw body exactly as received. Re-serialising parsed JSON changes the bytes and breaks the signature.
- Split the header into
t(Unix seconds) andv1(hex). - Compute
HMAC-SHA256over the stringt + "." + rawBody, keyed with your endpoint's signing secret. - Compare with
v1using a constant-time function. - Reject the request if
tis more than 300 seconds away from your clock. That stops an attacker replaying a captured request later. - Store processed
event.idvalues 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.
| Item | Value |
|---|---|
| Signing secret | whsec_example_not_a_real_secret |
| t | 1731144072 |
| 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 v1 | 40e9198e148b032bfc5d6a0c205e547d8dea4f51409377c6e8bd022efd3e9bc0 |
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_atand 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.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size, 1 to 100. Default 20. |
cursor | query | string | next_cursor from the previous page. |
category | query | string | One of bread, pastry, cake, drink. |
in_stock | query | boolean | true to hide sold-out products. |
q | query | string | Text search on name and SKU. |
Responses
| Status | Meaning |
|---|---|
| 200 | A page of products. |
| 400 | Malformed request (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Request body (application/json)
| Field | Type | Description |
|---|---|---|
skuREQUIRED | string | Unique SKU. |
nameREQUIRED | string | Display name. |
description | string | Optional text. |
categoryREQUIRED | enum | Category. One of bread, pastry, cake, drink. |
price_centsREQUIRED | integer | Unit price in cents. |
stock_quantity | integer | Initial stock. |
{
"sku": "RYE-LOAF-600",
"name": "Seeded rye",
"category": "bread",
"price_cents": 720,
"stock_quantity": 12
}Responses
| Status | Meaning |
|---|---|
| 201 | The new product. |
| 409 | Not enough stock (problem+json). |
| 422 | Validation failed (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
product_idREQUIRED | path | string | Product id, prefix prod_. |
Responses
| Status | Meaning |
|---|---|
| 200 | The product. |
| 404 | Resource not found (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
product_idREQUIRED | path | string | Product id. |
Request body (application/json)
| Field | Type | Description |
|---|---|---|
name | string | New display name. |
description | string | New text. |
price_cents | integer | New unit price in cents. |
stock_quantity | integer | Set the stock level. |
{
"price_cents": 690,
"stock_quantity": 30
}Responses
| Status | Meaning |
|---|---|
| 200 | The updated product. |
| 404 | Resource not found (problem+json). |
| 422 | Validation failed (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
product_idREQUIRED | path | string | Product id. |
Responses
| Status | Meaning |
|---|---|
| 204 | Archived. No body. |
| 404 | Resource not found (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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+jsonHTTP/1.1 204 No ContentCustomers#
People who place orders.
GET/customersList customers
Returns customers, newest first. Filter by exact e-mail to look one up.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size, 1 to 100. Default 20. |
cursor | query | string | next_cursor from the previous page. |
email | query | string | Exact match, case-insensitive. |
Responses
| Status | Meaning |
|---|---|
| 200 | A page of customers. |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Request body (application/json)
| Field | Type | Description |
|---|---|---|
nameREQUIRED | string | Full name. |
emailREQUIRED | string | Unique per account. |
marketing_opt_in | boolean | Consent flag. |
{
"name": "Sam Sample",
"email": "sam@example.com",
"marketing_opt_in": true
}Responses
| Status | Meaning |
|---|---|
| 201 | The new customer. |
| 409 | Not enough stock (problem+json). |
| 422 | Validation failed (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customer_idREQUIRED | path | string | Customer id, prefix cus_. |
Responses
| Status | Meaning |
|---|---|
| 200 | The customer. |
| 404 | Resource not found (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
customer_idREQUIRED | path | string | Customer id. |
Request body (application/json)
| Field | Type | Description |
|---|---|---|
name | string | New name. |
email | string | New e-mail. |
marketing_opt_in | boolean | Consent flag. |
{
"marketing_opt_in": true
}Responses
| Status | Meaning |
|---|---|
| 200 | The updated customer. |
| 404 | Resource not found (problem+json). |
| 409 | Not enough stock (problem+json). |
| 422 | Validation failed (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size, 1 to 100. Default 20. |
cursor | query | string | next_cursor from the previous page. |
status | query | string | pending, paid, fulfilled or cancelled. |
customer_id | query | string | Only this customer's orders. |
created_after | query | string | RFC 3339 timestamp, exclusive. |
Responses
| Status | Meaning |
|---|---|
| 200 | A page of orders. |
| 400 | Malformed request (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string | Unique string, up to 64 characters. UUID v4 recommended. See Idempotency. |
Request body (application/json)
| Field | Type | Description |
|---|---|---|
customer_idREQUIRED | string | Existing customer id. |
itemsREQUIRED | array | Items as {product_id, quantity}. |
note | string | Optional 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
| Status | Meaning |
|---|---|
| 201 | The new order. Prices and tax are fixed by the server. |
| 409 | Not enough stock (problem+json). |
| 422 | Validation failed (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
order_idREQUIRED | path | string | Order id, prefix ord_. |
Responses
| Status | Meaning |
|---|---|
| 200 | The order. |
| 404 | Resource not found (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
order_idREQUIRED | path | string | Order id. |
Idempotency-Key | header | string | Unique string, up to 64 characters. UUID v4 recommended. See Idempotency. |
Responses
| Status | Meaning |
|---|---|
| 200 | The cancelled order. |
| 404 | Resource not found (problem+json). |
| 409 | Not enough stock (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
limit | query | integer | Page size, 1 to 100. Default 20. |
cursor | query | string | next_cursor from the previous page. |
Responses
| Status | Meaning |
|---|---|
| 200 | A page of endpoints. |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Request body (application/json)
| Field | Type | Description |
|---|---|---|
urlREQUIRED | string | HTTPS only. |
eventsREQUIRED | array | Event types, see the webhooks section. |
{
"url": "https://shop.example.com/hooks/orders",
"events": [
"order.created",
"order.paid"
]
}Responses
| Status | Meaning |
|---|---|
| 201 | The endpoint, with its signing secret. |
| 422 | Validation failed (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
endpoint_idREQUIRED | path | string | Endpoint id, prefix whe_. |
Responses
| Status | Meaning |
|---|---|
| 204 | Deleted. No body. |
| 404 | Resource not found (problem+json). |
| 401 / 403 / 429 | Credentials, 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 >= 400import 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+jsonHTTP/1.1 204 No ContentStatus codes#
| Code | Name | Used when |
|---|---|---|
| 200 | OK | Successful read or update. |
| 201 | Created | A resource was created. The body is the new resource. |
| 204 | No Content | Success with nothing to return (archive, delete). |
| 400 | Bad Request | Malformed JSON or an unparsable parameter. |
| 401 | Unauthorized | Missing, expired or revoked credential. |
| 403 | Forbidden | Valid credential without the needed scope. |
| 404 | Not Found | No such resource for this account. |
| 409 | Conflict | State conflict: duplicate, out of stock, not cancellable, request in progress. |
| 422 | Unprocessable Content | Valid JSON, invalid values. |
| 429 | Too Many Requests | Rate limit. Read Retry-After. |
| 500 | Internal Server Error | Our fault. Retry with the same idempotency key. |
| 503 | Service Unavailable | Maintenance 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
typeto branch on, plus arequest_idfor 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.