Conventions

Conventions

Money, IDs, idempotency, pagination and errors — the rules every request follows.

JSON
{
  "object": "transfer",
  "id": "transfer_01K5X8QT8D4RFMJ2NVWXZ9QBCH",
  "amount": "1000.00",
  "currency": "USD",
  "created": 1789041600,
  "livemode": true
}

Money is a string#

DoDon't
"amount": "1000.00""amount": 1000

Floats lose cents. Parse into a decimal type — decimal.js, Python Decimal, BigDecimal.

IDs are prefixed and sortable#

transfer_01K5X8QT8D4RFMJ2NVWXZ9QBCH — object type, then a time-ordered ULID.

  • The prefix tells you the type.
  • Newer IDs sort after older ones. Use them as pagination cursors.

Time is Unix seconds#

"created": 1789041600 — an integer. Never ISO-8601.

Every object has#

Field
objectType discriminator. Switch on this
idPrefixed ID
createdUnix seconds
livemodetrue for live keys

Field names are snake_case. Attach your own references in metadata.

Idempotency#

Retry any write safely. It runs exactly once. Required on every POST.

HTTP
Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890
  • 16–64 characters. Use a UUID.
  • Scoped to your key. Expires after 24 hours.
  • JSON key order doesn't count as a change.

Responses#

You sendYou get
Same key, same bodyOriginal response + Idempotent-Replayed: true
Same key, different body or endpoint409 idempotency_key_reuse
Same key, first request still running409 resource_locked — retry shortly
No key400 parameter_empty
Key too short / long400 parameter_min_length_invalid / parameter_max_length_invalid

Completed 4xx responses replay as-is. A 5xx releases the key, so a retry runs again.

Retry pattern#

Node.js
const key = crypto.randomUUID(); // once per operation, not per attempt

for (let attempt = 0; attempt < 5; attempt++) {
  try {
    const res = await fetch('https://api.withsuperpay.com/v1/transfers', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.SUPERPAY_KEY}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': key,
      },
      body: JSON.stringify(transfer),
    });
    const body = await res.json();

    const locked = body.errors?.[0]?.code === 'resource_locked';
    if (res.status < 500 && !locked) return body;
  } catch {
    // network error — safe to retry with the same key
  }
  await new Promise(r => setTimeout(r, 250 * 2 ** attempt));
}

Pagination#

Cursor-based, on every list endpoint.

HTTP
GET /v1/transfers?limit=25&starting_after=transfer_01K5X8QT8D4RFMJ2NVWXZ9QBCH
limitinteger

1–100. Default 10.

starting_afterstring

ID to page forward from.

ending_beforestring

ID to page backward from. Don't combine with starting_after.

Response
{ "object": "list", "data": [ ... ], "has_next": true }

No total count — loop until has_next is false.

Fetch everything#

Node.js
async function* allTransfers() {
  let cursor;
  while (true) {
    const qs = new URLSearchParams({ limit: '100', ...(cursor && { starting_after: cursor }) });
    const page = await superpay(`/v1/transfers?${qs}`);
    yield* page.data;
    if (!page.has_next) return;
    cursor = page.data.at(-1).id;
  }
}

Errors#

One shape. Every problem at once.

JSON
{
  "object": "error",
  "errors": [
    {
      "code": "parameter_empty",
      "message": "You did not provide a value for amount.",
      "metadata": { "field_name": "amount" }
    }
  ],
  "http_status_code": 400
}
  • errors is an array — fix all fields in one pass.
  • Branch on code. message is for humans and may change.
  • metadata.field_name points at the offending field.

Codes#

CodeHTTPMeaning
parameter_empty400Required field missing
parameter_value_invalid400Not an accepted value
parameter_min_length_invalid400Too short
parameter_max_length_invalid400Too long
parameter_range_invalid400Outside the allowed range
parameter_unexpected400Unknown field
request_body_invalid400Malformed JSON
request_query_parameter_invalid400Bad query parameter
secret_key_invalid401Bad or missing API key
action_unauthorized401 · 403Not permitted
object_not_found404No such object
idempotency_key_reuse409Key reused with a different request
resource_locked409Another request holds this object

When to retry#

StatusRetry
400 401 403 404No — fix the request
409 resource_lockedYes, same key, with backoff
409 idempotency_key_reuseNo — use a new key for a new request
5xx · network errorYes, same key, with backoff