Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

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

PropType
code?RevenueErrorCode

Normalized failure code — the field to branch on.

TypeRevenueErrorCode
provider?'polar' | 'lemon-squeezy' | 'stripe' | 'paddle' | 'dodo-payments' | 'testing'

Which provider produced the error.

Type'polar' | 'lemon-squeezy' | 'stripe' | 'paddle' | 'dodo-payments' | 'testing'
status?number

The HTTP status, when the failure came from a response.

Typenumber
retryable?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.

Typeboolean
retryAfter?number

Seconds to wait, parsed from Retry-After (numeric seconds or an HTTP date).

Typenumber
cause?unknown

The underlying JavaScript error — a fetch TypeError, or a SyntaxError from an unparseable body. Never the provider payload.

Typeunknown
responseBody?unknown

The provider's parsed error response body. Non-enumerable, and never redacted.

Typeunknown

RevenueError 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:

  • cause carries only the underlying JavaScript error — the TypeError fetch throws on a dead connection, or the SyntaxError from a response that wasn’t JSON. Plain Error semantics.
  • responseBody carries the provider’s parsed error payload: a 422 validation detail, a 402 decline. It is installed with Object.defineProperty as non-enumerable, so console.error(error), util.inspect, JSON.stringify(error) and error reporters such as Sentry all skip it, while error.responseBody still 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;
}

Last updated on August 8, 2026

Was this page helpful?