Authentication and errors
API keys
Every request carries an API key in the Authorization header:
GET /api/v1/products HTTP/1.1
Host: zerpy.app
Authorization: Bearer sk_live_9f2c…- Who creates keys: only an owner or admin, in Integrations → API keys → New API key. Give the key a name so you know where it is used, choose its access (the Webshop preset = all four scopes, Product feed (read only) =
products:read+stock:read) and click Create key. - Shown once: the full key is displayed only right after creation. ZERPY stores only a SHA-256 hash of it — a lost key cannot be recovered; create a new one.
- Revoking: Revoke in the key list. Systems using the key stop working immediately (
401 invalid_api_key). - The key list shows when each key was last used.
- Key prefixes:
sk_live_= company key,sk_feed_= partner feed key created by a reseller in the partner portal.
WARNING
Keep keys on your server. Anyone with the key can read your catalog and stock — and, with the order scopes, create and cancel orders.
Only GET /feed.csv also accepts the key as a query parameter (?key=…), because spreadsheet and shop importers can only fetch a URL. Every other endpoint needs the header.
Scopes
| Scope | UI label | Allows |
|---|---|---|
products:read | Read products and prices | GET /products, GET /products/{sku}, GET /feed.csv |
stock:read | Read stock levels | GET /stock |
orders:read | Read orders and shipment tracking | GET /orders, GET /orders/{id} |
orders:write | Create and cancel orders | POST /orders, POST /orders/{id}/cancel, POST /orders/{id}/payment-link |
A request with a key that lacks the scope gets 403 insufficient_scope. Partner feed keys always have exactly products:read + stock:read, whatever else is stored.
Rate limit
120 requests per minute per key (a token bucket that refills continuously). Each successful authentication returns X-RateLimit-Remaining. Above the limit:
HTTP/1.1 429 Too Many Requests
Retry-After: 1
{ "error": { "code": "rate_limited", "message": "Too many requests (limit 120/minute)" } }Wait Retry-After seconds and retry. For bulk syncs use the largest page size (limit=200 for products, limit=500 for stock) instead of one call per product.
Errors
Every error has the same shape:
{
"error": {
"code": "unknown_sku",
"message": "Unknown SKU: ABC",
"details": { "skus": ["ABC"] }
}
}code is stable and machine-readable; message is for humans and may change. details is present only for some codes.
| HTTP | code | When |
|---|---|---|
| 400 | invalid_amount | Payment link: amount is more than the open balance |
| 401 | missing_api_key | No Authorization: Bearer … header (and no ?key= on feed.csv) |
| 401 | invalid_api_key | Unknown, revoked or expired key; partner feed key of an archived partner |
| 403 | insufficient_scope | The key lacks the scope; partner key used on an order endpoint |
| 404 | not_found | Product or order does not exist (or belongs to another company) |
| 409 | cannot_cancel | Order is already shipped, has an invoice, or is already cancelled |
| 409 | cannot_pay | Payment link: Montonio not connected, order cancelled, currency not EUR / PLN, nothing left to pay |
| 409 | no_warehouse | POST /orders: the company has no own warehouse |
| 422 | validation_failed | Body or query is invalid — details is a list of { "path", "message" } |
| 422 | unknown_sku | POST /orders: a SKU does not exist or is archived (details.skus) |
| 422 | unsupported_currency | POST /orders: currency is not the company currency |
| 422 | options_required | POST /orders: a required option group of a configurable product is not answered (details.sku, details.groups) |
| 422 | invalid_options | POST /orders: unknown option value, or two values of one group (details.sku) |
| 429 | rate_limited | Rate limit exceeded |
| 500 | internal_error | Something went wrong on our side — retry later |
| 503 | provider_unavailable | Payment link: Montonio did not answer |
Example validation error:
{
"error": {
"code": "validation_failed",
"message": "Request is invalid",
"details": [
{ "path": "customer.email", "message": "Invalid email address" },
{ "path": "lines", "message": "Too small: expected array to have >=1 items" }
]
}
}Pagination
List endpoints take page (from 1) and limit and return:
{ "data": [ … ], "page": 1, "limit": 100, "total": 342, "has_more": true }| Endpoint | Default limit | Max limit | Sort order |
|---|---|---|---|
GET /products | 100 | 200 | SKU; by updated_at when updated_since is given |
GET /stock | 500 | 500 | SKU |
GET /orders | 50 | 100 | updated_at (oldest first) |
Keep requesting page + 1 while has_more is true.
Incremental sync
GET /products and GET /orders accept updated_since (ISO 8601 timestamp with time zone, e.g. 2026-11-07T10:00:00Z) and return only records changed after it, oldest change first. Store the updated_at of the last record you processed and use it as the next updated_since.
Idempotency
POST /orders is idempotent on external_id (your shop's order number, unique per company):
- Sending an order whose
external_idalready exists does not create a second order. ZERPY returns the existing order with status200and the headerIdempotent-Replayed: true(and emptywarnings). - This also holds for two identical requests arriving at the same moment.
- So it is safe to retry
POST /ordersafter a timeout or a network error.
POST /orders/{id}/payment-link reuses an unpaid link for the same amount (200) instead of creating a new one (201). POST /orders/{id}/cancel on an already cancelled order returns 409 cannot_cancel.