Authentication, Rate Limiting & Conventions
Base URL: https://your-openshopy-domain/api/public/v1
All endpoints except OPTIONS require an API key. The API version is part of the path (v1).
API keys
Two key types exist:
- Secret keys —
os_sk_...— server-side only. Can use every scope granted to the key, including writes and sensitive reads (customers, orders, invoices, discounts, analytics). - Publishable keys —
os_pk_...— safe for browser/storefront code. They can only be created with the storefront scopes marked Yes in the table below. Endpoints that expose private data return403 secret_key_requiredwhen called with a publishable key, even if the scope matches.
Keys can be revoked (revoked_at) or expire (expires_at); either makes the key invalid.
Sending the key
Send the raw key in one of:
Authorization: Bearer <key>or
X-Api-Key: <key>The Authorization header is checked first (must start with Bearer ); if absent, X-Api-Key is used. If neither is present: 401 unauthenticated.
The server looks up the SHA-256 hash of the key against api_keys.key_hash. If not found, revoked, or expired: 401 invalid_api_key.
Scopes
Every endpoint requires a scope (except GET /orders/track). If the authenticated key's scopes array does not include the route's scope: 403 insufficient_scope.
| Scope | Allows | Allowed on publishable keys? |
|---|---|---|
store:read | Read store info | Yes |
products:read | Read products, variants, collections, inventory movements | Yes |
products:write | Create/update/delete products, variants, images, collections | No |
inventory:write | Adjust inventory levels | No |
orders:read | Read orders, customer order history, abandoned carts | No |
orders:write | Create/update orders, payments, fulfillments, refunds, returns, notes, notifications | No |
customers:read | Read customer records | No |
customers:write | Create/update/delete customers, manage addresses | No |
discounts:read | Read discounts | No |
discounts:write | Create/update/delete discounts | No |
shipping:read | Read shipping rates/zones/pickup locations/carriers | Yes |
shipping:write | Create shipping zones/rates | No |
taxes:read | Read tax rates, calculate tax | Yes |
taxes:write | Upsert tax rates | No |
invoices:read | Read invoices | No |
invoices:write | Issue/correct/credit-note/cancel invoices | No |
checkout | Cart & checkout flow, quote, mark-sent-by-token, discount validation | Yes |
customer_accounts | Storefront customer auth (register/login/me/addresses/orders/password reset) | Yes |
analytics:write | Ingest analytics events | Yes |
analytics:read | Read analytics summary | No |
Routes with scope null (no scope check, but still require a valid key): GET /orders/track.
Rate limiting
Each key has a rate_limit_per_minute. Requests are counted per key in one-minute windows (default limit: 120 requests per minute, configurable per key from 10 to 5000).
Response headers on every request (success or error, once a valid key is matched):
X-RateLimit-Limit: <key.rate_limit_per_minute>
X-RateLimit-Remaining: <remaining, floored at 0>When the limit is exceeded (remaining < 0):
HTTP/1.1 429
Retry-After: 60{ "error": { "code": "rate_limited", "message": "Too many requests. Slow down and retry after the next minute.", "details": null } }CORS
All responses (including the 401/403/404 error paths once routing starts) include:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Api-Key, X-Customer-Token, Idempotency-Key
Access-Control-Expose-Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-Request-Id
Access-Control-Max-Age: 86400OPTIONS <any path> returns 204 with just the CORS headers (no auth required).
Note: Idempotency-Key is listed as an allowed request header for CORS purposes, but no route in the codebase reads or honors an Idempotency-Key header — there is no idempotency implementation server-side. Retries of POST requests (e.g. order/cart creation) are not deduplicated by the API itself.
Request logging
Every request made with a valid key is logged (visible in the dashboard under API keys → Request log) with: store_id, key_id, method, path (+ truncated query string, 200 chars), status, duration_ms, and ip (from cf-connecting-ip or x-forwarded-for).
Every response also carries X-Request-Id: <uuid>.
Pagination
List endpoints accept:
| Query param | Type | Default | Notes |
|---|---|---|---|
page | integer | 1 | Minimum 1 |
limit | integer | 25 | Clamped between 1 and 100 |
Response envelope for paginated endpoints:
{
"data": [ /* array of resources */ ],
"meta": {
"page": 1,
"limit": 25,
"total": 134,
"total_pages": 6
}
}Non-paginated endpoints return { "data": ... } with no meta, unless a handler sets custom meta (e.g. /taxes/rates includes meta: { prices_include_tax, store_country, destination_based }).
Error format
Every error response is JSON:
{
"error": {
"code": "not_found",
"message": "Order not found",
"details": null
}
}Validation errors (zod) return 422 with details as an array of { path, message }:
{
"error": {
"code": "validation_failed",
"message": "Request body is invalid",
"details": [{ "path": "email", "message": "Invalid email" }]
}
}Known error codes observed in the code:
| HTTP status | code | When |
|---|---|---|
| 400 | invalid_json | Body is not parseable JSON |
| 400 | invalid_request | Missing required query params (e.g. order tracking) |
| 401 | unauthenticated | Missing API key |
| 401 | invalid_api_key | Key not found / revoked / expired |
| 401 | customer_unauthenticated | Missing/expired X-Customer-Token |
| 401 | invalid_credentials | Bad login/password |
| 403 | secret_key_required | Endpoint needs a secret key but a publishable key (or a secret-key-only check) was used |
| 403 | insufficient_scope | Key lacks the route's required scope |
| 404 | route_not_found | No matching path |
| 404 | not_found | Resource not found (Store/Product/Variant/Order/Customer/Invoice/Collection/Image/Shipment/Return/Discount/Zone/Cart) |
| 404 | store_not_found | API key's store missing (shouldn't normally happen) |
| 405 | method_not_allowed | Path matches but method doesn't |
| 409 | duplicate | Customer with email already exists |
| 409 | account_exists | Customer account (password) already exists |
| 409 | cart_converted | Cart already checked out |
| 409 | invalid_discount (409 variant) | Discount code unique constraint violated |
| 422 | validation_failed | Zod schema failure |
| 422 | unprocessable | Generic business-rule violation (e.g. "At least one line item is required") |
| 422 | last_variant | Attempt to delete a product's only variant |
| 422 | invalid_state | Payment confirm attempted in wrong state |
| 422 | order_cancelled | Action attempted on a cancelled order |
| 422 | not_paid | Refund attempted on an unpaid order |
| 422 | checkout_invalid | Quote has blocking issues (stock/discount/shipping/product) — details.issues is the full QuoteIssue[] array |
| 422 | invalid_address | Address missing line1/city/postal_code |
| 422 | update_failed / invalid_discount | DB update failed validation |
| 422 | email_required / address_required / shipping_required | Cart checkout preconditions not met |
| 429 | rate_limited | Over the per-minute limit |
| 500 | internal_error | Unhandled exception |
Money format
All monetary amounts are integers in minor currency units (e.g. cents/grosze) — never floats. Example: 4999 = 49.99 in the order's currency. currency is always one of PLN, USD, EUR. Currency conversion between the store's base currency and the requested currency is done via store.exchange_rates (a flat multiplier map keyed by currency code — not a timestamped rate history).
Tax rates (tax_rates.rate) are decimal percentages, e.g. 23 for 23%, returned as a Number.
Dates
All timestamps are ISO 8601 strings, e.g. "2024-05-01T12:00:00+00:00". Query filters like created_after/created_before/updated_after on /orders accept ISO date/time strings and are passed straight to gte/lt.
Idempotency
Not implemented. The Idempotency-Key header is accepted by CORS but ignored by all handlers. Clients must implement their own deduplication (e.g. checking for an existing cart/order before retrying a POST).