Payna is backed by Y Combinator

Getting started

Errors

Failures replace the usual data and meta envelope with a single error object. The HTTP status and the machine code always agree.

The envelope

{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Missing or invalid API key.",
    "status": 401
  }
}

code is a stable machine string and is the only part safe to branch on. message is written for a human reading a log and may be reworded at any time. status repeats the HTTP status so a client that only has the body still knows what happened.

Every code

CodeHTTPMeaning
UNAUTHORIZED401The Authorization header is missing, malformed, or the key does not exist. Keys must be sent as a bearer token and begin with pk_live_.
REVOKED401The key was valid but has since been revoked in the Payna dashboard. Mint a new one.
FORBIDDEN403The key is valid but lacks the capability for this endpoint, or the account does not have that API switched on. Both have to pass.
RATE_LIMITED429Budget exhausted for the current window. The Retry-After response header carries the number of seconds to wait.
INVALID_PARAM400A query parameter failed validation. The message names the offending parameter.
INVALID_STATE_CODE400The state path segment or filter is not a recognised two-letter US state code.
INVALID_STATUS_VALUE400A value passed to the status filter is not one of the five license statuses.
LICENSE_NOT_FOUND404No license record exists for the requested state.
INDIVIDUAL_NOT_FOUND404No individual exists with that ID at your company.
INTERNAL_ERROR500Something failed on our side. Safe to retry with backoff.

Handling them

const body = await res.json()

if (!res.ok) {
  // Branch on code, never on message. Messages are written for
  // humans and are not part of the contract.
  switch (body.error.code) {
    case 'RATE_LIMITED':
      return retryAfter(res.headers.get('Retry-After'))
    case 'UNAUTHORIZED':
    case 'REVOKED':
      return alertOncall('Payna API key needs rotating')
    case 'LICENSE_NOT_FOUND':
      return null // absence, not a failure
    default:
      throw new Error(body.error.code)
  }
}

Four of these deserve deliberate handling rather than a generic throw.

401, both variants

UNAUTHORIZED and REVOKED both mean the key will not start working again on its own. Retrying is pointless. Page someone.

403 is not always about the key

FORBIDDEN covers two different situations: the key lacks the capability, or your account does not have that API enabled. Neither is fixed by rotating the key. See authentication.

404 usually means absence, not failure

LICENSE_NOT_FOUND means no license record exists for that state, which for most integrations is a legitimate answer rather than an error. Treat it as not licensed here, not as a broken call.

429 carries its own instructions

Read the Retry-After header and wait exactly that long. Retrying sooner spends budget without succeeding and pushes the window further out.

Retries

Every endpoint is a GET and has no side effects, so retries are always safe. Retry 429 after the interval it gives you, and 500 with exponential backoff. Do not retry a 400, 401, or 403: the request will fail identically until something changes at your end.

See how Payna runs licensing.

Tell us a little about the company and we’ll walk through how Payna handles licensing and compliance in every US state.