Conventions
Authentication
Send your API key as a bearer token: Authorization: Bearer sk_test_…. Keys beginning
sk_test_/sk_live_ reflect the merchant's is_live flag; the key body is 192 random bits and the first 12 characters are stored as key_prefix for identification. Every resource is scoped to the
merchant that owns the key; accessing another merchant's object returns 404.
Platform-internal routes (merchant registration, provider callbacks) are not part of this API and are not documented here.
Errors
Non-2xx responses use one envelope:
{
"error": {
"type": "invalid_request_error",
"message": "A default instrument is required to start a subscription without a trial.",
"code": "instrument_required",
"param": "default_instrument_id",
"request_id": "6a1f…"
}
}
type is one of invalid_request_error, authentication_error, idempotency_error,
card_error, api_error. Quote request_id (also logged server-side) when reporting problems.
| Status | Meaning |
|---|---|
| 400 | Invalid JSON (invalid_json), failed validation (validation_failed), or a rule violation |
| 401 | Missing or invalid API key |
| 402 | Synchronous unkeyed proration charge declined (proration_payment_failed) |
| 404 | Unknown route, or object not found / not yours (resource_missing) |
| 409 | State conflict: invalid_status_transition, instrument_in_use, idempotency conflicts |
| 413 | Request body or JSON dimensions exceed documented limits |
| 429 | Rate limited (rate_limit_exceeded or too_many_auth_failures); honor retry-after |
| 503 | Shared budget storage is unavailable (rate_limit_store_unavailable); retry later |
| 500 | Server error (api_error) |
Validation failures (validation_failed) name the first offending field in param (dotted for
nested fields, e.g. recurring.interval) and in the message.
Updates and deletes
Updates are POST /v1/<resource>/:id with only the fields to change (partial update; an empty
body returns 400 empty_update where applicable). Customer, card, and webhook endpoint deletion
return {"object":"<type>","id":"…","deleted":true}. Product deletion returns the archived
product (active: false); subscription deletion returns the canceled or scheduled subscription.
Pagination
List endpoints accept limit (1–100, default 20) and starting_after (an object ID; results are
ordered by ascending ID) and return:
{ "object": "list", "url": "/v1/customers", "has_more": true, "data": [ … ] }
To page, pass the last id of data as starting_after while has_more is true.
Idempotency
Send an Idempotency-Key header (≤ 96 chars) on merchant-authenticated POST/DELETE requests you may retry. Scope is merchant + key + method + path.
- Same key, same body and query parameters → the stored response is replayed with
idempotency-replayed: true. Query names are sorted; repeated values retain their order. - Same key, different body or query →
409 idempotency_key_in_use. - Same key while the first request is still running →
409 idempotency_key_in_progress; retry shortly. - Database mutations, lifecycle events, and the encrypted replay response commit in one transaction. A failure before commit rolls them all back; an exact retry may execute safely. A lost response after commit replays the original result. Rejected requests also roll back their mutations.
- Payment-producing requests with a key return the committed pending result (
incompleteon first payment; held items and a pending transaction on proration). The charge is submitted after commit. GET the subscription/transaction or consume webhooks for the final outcome. A later decline does not replace that saved pending response with a402. - The billing scheduler recovers queued submissions after a restart. An attempt is durably marked submitted before contacting NETOPIA Payments, using its transaction ID as the stable provider order ID. Submitted attempts are reconciled, never blindly sent again. A crash between marking and sending can require provider/operator reconciliation when the provider cannot confirm the order.
- Revoked credentials remain invalid, including for replay. Before rotating the credential making the request, keep another active merchant credential available: it can recover a lost rotation response using the same idempotency key/method/path/body/query.
- Completed results remain replayable until retention purges them after 24 h. Unfinished claims are
retained. Unfinished legacy claims (
recovery_version = 0) return409 idempotency_recovery_required: reconcile their resources/payments before deciding their disposition; do not delete them and retry blindly. Completed legacy responses retain their original body-only fingerprint and plaintext format until expiry; legacy records cannot reconstruct a query that was never recorded.
Lifecycle events and recovery
Customer/card mutations and subscription lifecycle changes commit their events with their state, including requests without an idempotency key. Trial notices and expiry/cancellation jobs use the same guarantee: an outbox failure rolls back the change so it can be retried. Creation and elapsed-period resume also commit a durable charge intent before contacting the provider. If post-commit dispatch is interrupted, the billing worker recovers queued work. An unkeyed retry can still create a second resource; use an idempotency key to replay the original request safely.
Request limits
API and callback bodies are limited to 64 KiB, counted from actual stream bytes before
JSON parsing, idempotency fingerprinting or signature-body verification. A large declared
Content-Length rejects early; a small/missing length cannot bypass the limit. Streams are
canceled on overflow. The Bun server applies the same transport cap. Missing/blocked credentials
are rejected before application body buffering (callback signature presence is checked first).
JSON envelopes allow at most 16 nesting levels. Each metadata value is capped at 16 KiB of
UTF-8 JSON, 8 nesting levels and 100 total object properties/array elements. Subscription items
are capped at 100. Descriptions allow 500 characters; token fields and netopia_api_key allow
4096 UTF-8 bytes. Oversized bodies return 413 body_too_large; excessive JSON dimensions return
413 payload_limit_exceeded with the affected param. Ordinary schema errors still return 400.
These are input-envelope limits; monetary/date/storage constraints remain tracked separately.
Rate limits
RATE_LIMIT_PER_MINUTE (default 120, range 1–1,000,000) is a merchant-wide request budget
shared across that merchant's keys and every API replica using the same primary MySQL database.
A window lasts 60 seconds from its first request, using database time. Excess requests return
429 rate_limit_exceeded with retry-after seconds. Replays and rejected authenticated requests
consume budget; rolling back a business transaction does not refund it. Creating/rotating keys
or restarting API processes does not reset a merchant's budget.
Failed authentication has its own shared source window: AUTH_FAILURES_PER_MINUTE (default 10,
range 1–1,000,000) failures per resolved source per 60 seconds. Further attempts from that source,
including valid credentials, return 429 too_many_auth_failures. Successful authentication does not
increase failure counts.
Counters live in rate_limit_windows, not process memory. Updates use short database row
transactions independent of business transactions; expired rows are removed by retention. Keep
limits consistent across replicas. If the shared store is unavailable, requests fail closed with
503 rate_limit_store_unavailable; no handler is executed through a local fallback.
The socket peer is the default source. Behind controlled ingress, set TRUST_PROXY=true and
TRUSTED_PROXY_CIDRS to a comma-separated allowlist of actual proxy IPs/subnets. Startup refuses
proxy trust without a list; universal /0 trust is rejected. Only an allowlisted socket peer may
supply X-Forwarded-For. The chain is walked right-to-left through trusted proxies to the first
untrusted hop, ignoring spoofed client prefixes. IPv4/IPv6 are supported; malformed chains or
chains over 32 hops/4096 characters fall back to the socket peer. Keep ingress headers sanitized
and prevent access that bypasses your configured proxy. See the runbook.