Error codes
The full RevenueErrorCode union with its HTTP-status mapping, typical causes, retry semantics, and the client's single bounded retry.
A complete reference of the RevenueErrorCode union — with the HTTP statuses each code is derived from
and its retry semantics. For the conceptual overview see Errors.
The RevenueError model
code?RevenueErrorCode
Normalized failure code — the field to branch on.
RevenueErrorCodeprovider?'polar' | 'lemon-squeezy' | 'stripe' | 'paddle' | 'dodo-payments' | 'testing'
Which provider produced the error.
'polar' | 'lemon-squeezy' | 'stripe' | 'paddle' | 'dodo-payments' | 'testing'status?number
The HTTP status, when the failure came from a response.
numberretryable?boolean
Whether retrying may succeed. True for rate_limited, network_error and any 5xx, unless the provider overrides it — Stripe's Stripe-Should-Retry does.
booleanretryAfter?number
Seconds to wait, parsed from Retry-After (numeric seconds or an HTTP date).
numbercause?unknown
The underlying JavaScript error — a fetch TypeError, or a SyntaxError from an unparseable body. Never the provider payload.
unknownresponseBody?unknown
The provider's parsed error response body. Non-enumerable, and never redacted.
unknownRevenueError extends Error, so error instanceof RevenueError and error.message work as usual.
name is always 'RevenueError'.
cause vs responseBody
Two fields, and the split is the point:
causecarries only the underlying JavaScript error — theTypeErrorfetchthrows on a dead connection, or theSyntaxErrorfrom a response that wasn’t JSON. PlainErrorsemantics.responseBodycarries the provider’s parsed error payload: a422validation detail, a402decline. It is installed withObject.definePropertyas non-enumerable, soconsole.error(error),util.inspect,JSON.stringify(error)and error reporters such as Sentry all skip it, whileerror.responseBodystill reads it. Getting at the body is a deliberate act.
if (error.code === 'validation') {
// Deliberate read — this is the only way the body reaches you.
logger.debug({ detail: error.responseBody });
}
The code union
RevenueErrorCode is a closed union of ten values:
code |
Typical cause | HTTP status | retryable (default) |
|---|---|---|---|
validation |
Bad parameters, from the provider or a client-side check | 400, 422 |
no |
unauthorized |
Missing, invalid, or revoked credentials | 401 |
no |
payment_required |
The provider refused for billing reasons (e.g. a declined card) | 402 |
no |
forbidden |
Authenticated, but not allowed | 403 |
no |
not_found |
The resource does not exist, or is gone | 404, 410 |
no |
conflict |
Conflicts with current state, or a precondition failed | 409, 412 |
no |
rate_limited |
The provider’s rate limit was hit | 429 |
yes |
provider_error |
An unclassified provider response | any other status | only when 5xx |
unsupported |
The active provider can’t do what was requested | — | no |
network_error |
The request never completed (DNS, TLS, connection, timeout) | — | yes |
unsupported and network_error have no HTTP status: the first is raised before a request is made, the
second when the request never produced a response.
Errors raised before any request
These never touch the network:
| Code | Raised by |
|---|---|
validation |
An empty id, customerId, product, or items[].product; an empty items array; a quantity that isn’t a positive integer |
validation |
An empty customerId or eventName on usage.report, or a usage value or numeric metadata entry that isn’t finite |
validation |
An expiresAt on checkouts.create that is an invalid Date, or is not in the future |
validation |
A malformed pagination cursor, a cursor from another provider, or a cursor URL on a different origin |
validation |
An empty id on licenseKeys.get/update, or a licenseKeys.update activationLimit that is neither null nor a positive integer |
unsupported |
Capability gating — successUrl, expiresAt, returnUrl, customerId on a subscription list, reason/comment, uncancel, endTrial, pause, resume, revoke, usage.report, licenseKeys.list/listAll/get/update, a behavior outside capabilities.pauseBehaviors, and a prorationBehavior outside capabilities.prorationBehaviors |
Adapters raise unsupported at call time for the finer-grained limits a capability boolean can’t
express — see the capability matrix.
validation is also raised by parseWebhookEvent when the payload isn’t valid JSON. Note that
verifyWebhook never throws — it returns false.
retryable and retryAfter
retryable defaults to true for rate_limited and network_error, and for any error whose HTTP
status is 5xx. A provider adapter may override the default in either direction when the provider
says so explicitly — only Stripe does, from Stripe-Should-Retry: a 429 the header vetoes is
retryable: false, and a lock conflict it endorses is retryable: true. retryAfter is parsed from the
provider’s Retry-After header, accepting either numeric seconds or an HTTP date (converted to seconds
from now, never negative).
if (error.retryable) {
await sleep((error.retryAfter ?? 1) * 1000);
// then retry with your own back-off policy
}
Provider behavior on rate limits:
| Provider | Retry-After on 429 |
Notes |
|---|---|---|
| Polar | yes | The retry waits exactly as long as Polar asks. |
| Lemon Squeezy | usually | |
| Stripe | no | Stripe-Should-Retry sets retryable instead; the retry falls back to one second, and a false cancels it. |
| Paddle | usually | |
| Dodo Payments | usually |
The client’s bounded retry
At most one retry per call, on one of two triggers:
| Error | Reads (get*, list*, listAll) |
Writes (create*, cancel, changePlan, pause, resume, uncancel, endTrial, revoke, update*, customerPortal.createSession) |
|---|---|---|
rate_limited with retryable: true |
retried | retried |
Any other retryable error (network_error, 5xx) |
retried | never retried |
| Anything else | not retried | not retried |
A 429 is rejected before the provider does any work, so replaying it is safe anywhere. A transport failure is not: the request may have landed and only the response was lost, and no SDK write carries an idempotency key.
The delay is retryAfter when the provider sent one and one second otherwise. A delay above
maxRetryAfterSeconds skips the retry rather than waiting the cap out.
const client = createClient({
provider,
retry: { maxRetryAfterSeconds: 10 }, // 0 disables it
});
The retry covers each page fetched inside listAll, and its wait is interrupted by the signal you
passed — an aborted call rejects with the abort reason instead of sleeping first. usage.report is the
one call it never covers, not even for a rate limit, because a replay is deduplicated only when you
passed an idempotencyKey. See Usage-based billing.
Secret redaction
RevenueError redacts the configured API key or access token from its message before construction,
replacing it with [redacted].
Redaction covers the message only. error.responseBody holds the provider’s response body verbatim and
is not redacted, on purpose: the secrets hook already covers secrets, and what remains is customer
PII — Stripe’s billing_details on a 402, Polar’s echoed values on a 422 — which no secrets list can
enumerate. Redacting it would also corrupt the same payload where the SDK returns it as data — a rejected
license validation carries it as LicenseKeyValidation.raw — and would imply the field is safe to log.
Non-enumerability is what keeps it out of logs; attach it to a bug report deliberately.
Some values are secrets by design and no redaction covers them: a customer portal URL is a signed, ready-to-use link into someone’s billing account. Never log one.
Handling errors
import { RevenueError } from 'revenue-sdk';
try {
await client.subscriptions.changePlan({ id, product, prorationBehavior: 'none' });
} catch (error) {
if (error instanceof RevenueError) {
switch (error.code) {
case 'unsupported':
// this provider lacks the capability — check client.capabilities
break;
case 'payment_required':
// the customer's payment failed — surface it, don't retry
break;
case 'not_found':
// the subscription or price no longer exists
break;
case 'rate_limited':
// back off using error.retryAfter
break;
default:
if (error.retryable) {
// transient — retry with back-off
}
}
}
throw error;
}