Developer docs v0.1.0

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.

StatusMeaning
400Invalid JSON (invalid_json), failed validation (validation_failed), or a rule violation
401Missing or invalid API key
402Synchronous unkeyed proration charge declined (proration_payment_failed)
404Unknown route, or object not found / not yours (resource_missing)
409State conflict: invalid_status_transition, instrument_in_use, idempotency conflicts
413Request body or JSON dimensions exceed documented limits
429Rate limited (rate_limit_exceeded or too_many_auth_failures); honor retry-after
503Shared budget storage is unavailable (rate_limit_store_unavailable); retry later
500Server 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 (incomplete on 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 a 402.
  • 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) return 409 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.