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
| Code | HTTP | Meaning |
|---|---|---|
| UNAUTHORIZED | 401 | The Authorization header is missing, malformed, or the key does not exist. Keys must be sent as a bearer token and begin with pk_live_. |
| REVOKED | 401 | The key was valid but has since been revoked in the Payna dashboard. Mint a new one. |
| FORBIDDEN | 403 | The 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_LIMITED | 429 | Budget exhausted for the current window. The Retry-After response header carries the number of seconds to wait. |
| INVALID_PARAM | 400 | A query parameter failed validation. The message names the offending parameter. |
| INVALID_STATE_CODE | 400 | The state path segment or filter is not a recognised two-letter US state code. |
| INVALID_STATUS_VALUE | 400 | A value passed to the status filter is not one of the five license statuses. |
| LICENSE_NOT_FOUND | 404 | No license record exists for the requested state. |
| INDIVIDUAL_NOT_FOUND | 404 | No individual exists with that ID at your company. |
| INTERNAL_ERROR | 500 | Something 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.
BOOK A DEMO
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.