Errors

Every failure in the SDK — whether the network died, the request timed out, the response wasn't JSON, or the API returned an error envelope — throws the same exception class: PlugipayError. You catch one type and switch on error.code to decide what to do.

The PlugipayError shape

class PlugipayError extends Error {
  readonly status: number;            // HTTP status, or 0 for network/timeout
  readonly code: string;               // stable error identifier — switch on this
  readonly message: string;            // human-readable, safe for developer logs
  readonly requestId: string | undefined; // server-side trace ID, present on most API errors
}

The class is exported from the package root:

import { PlugipayClient, PlugipayError } from '@forjio/plugipay-node';

A minimal catch:

try {
  const customer = await plugipay.customers.get('cus_bad');
} catch (err) {
  if (err instanceof PlugipayError) {
    console.error(`[${err.code}] ${err.message} (status=${err.status}, requestId=${err.requestId})`);
  } else {
    throw err; // not a Plugipay error — rethrow
  }
}

The instanceof check is the right gate; the SDK never throws anything else for its own failures.

Error code categories

The SDK surfaces three classes of failure, distinguished by status:

status Source Examples
0 Network or timeout, before the request reached Plugipay or before a response was parsed. timeout, network_error, invalid_response
4xx Plugipay returned an error envelope — the request was rejected. validation_error, not_found, invalid_signature, rate_limited
5xx Plugipay had an internal problem. internal_error, upstream_error, service_unavailable

The codes themselves are documented in API errors. This page covers how to handle them from the SDK.

Network and timeout errors

These have status = 0 and a small fixed set of codes:

timeout

The request didn't complete within timeoutMs (default 30s). The SDK aborted the in-flight fetch. The operation may or may not have happened on the server — for mutating calls, your idempotency key (which the SDK attached automatically) makes retrying safe.

catch (err) {
  if (err instanceof PlugipayError && err.code === 'timeout') {
    // Safe to retry. Same idempotency key means no duplicate.
    return retry();
  }
}

network_error

The TCP connection died, DNS failed, or some other transport-layer failure happened. The underlying error message is in err.message.

catch (err) {
  if (err instanceof PlugipayError && err.code === 'network_error') {
    // Same advice as timeout: retry with backoff.
    return retry();
  }
}

invalid_response

Plugipay returned something that wasn't valid JSON. This is almost always an infrastructure problem (a misconfigured proxy returning HTML, a 502 from a load balancer with a non-JSON body). err.message includes the first 200 bytes of the response for triage.

API errors (4xx)

Anything Plugipay rejected has a code from the error catalog. The SDK doesn't translate codes — it surfaces them as-is so you can pattern-match against the catalog directly.

A typical handler:

try {
  const refund = await plugipay.refunds.create({
    sourceType: 'checkout_session',
    sourceId: 'cs_01H...',
    amount: 100_000,
    reason: 'requested_by_customer',
  });
} catch (err) {
  if (!(err instanceof PlugipayError)) throw err;

  switch (err.code) {
    case 'not_found':
      // The session ID is wrong or doesn't belong to this workspace.
      throw new BadRequest('Unknown checkout session');

    case 'invalid_state':
      // Session hasn't completed yet, or is already fully refunded.
      // err.message tells you which; sometimes details.currentState is useful.
      return null;

    case 'unprocessable_entity':
      // Business-rule rejection — e.g. refund amount exceeds original.
      throw new BadRequest(err.message);

    case 'rate_limited':
      // The SDK doesn't auto-retry. See the section below.
      await sleep(2_000);
      return retry();

    default:
      // Don't pattern-match on message; log requestId for support.
      logger.error('Unhandled Plugipay error', {
        code: err.code,
        message: err.message,
        requestId: err.requestId,
      });
      throw err;
  }
}

A few rules of thumb:

  • Switch on code, not message. Codes are stable. We tweak messages for clarity between releases.
  • Always log requestId. If you file a support ticket, this is the one piece of metadata that lets us trace the exact request in our logs.
  • Don't retry 4xx errors. They mean "you sent something wrong" — retrying won't help. The exception is rate_limited (429), which is a hint to slow down, not a reject.

Server errors (5xx)

These are transient and safe to retry with backoff:

Code Meaning
internal_error Plugipay hit an unexpected error.
upstream_error An underlying provider (Xendit, Midtrans, PayPal) returned an error.
upstream_timeout An underlying provider didn't respond in time.
service_unavailable Maintenance window or systemic outage.

A simple retry helper:

async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
  for (let i = 0; i < max; i++) {
    try {
      return await fn();
    } catch (err) {
      if (!(err instanceof PlugipayError)) throw err;
      const retriable = err.status >= 500 || err.code === 'timeout' || err.code === 'network_error';
      if (!retriable || i === max - 1) throw err;
      await new Promise((r) => setTimeout(r, 2 ** i * 500));
    }
  }
  throw new Error('unreachable');
}

const customer = await withRetry(() => plugipay.customers.create({ email: 'dewi@example.com' }));

Because the SDK attaches a fresh idempotency key on each mutating call (and the same key on retries if you call the same method again? See note below), retries are safe.

Idempotency keys on retries. The SDK generates a fresh idempotency key on every call to a resource method — including retries from the example above. That's actually fine for the network/timeout case: if Plugipay never processed the original, the new key processes once; if Plugipay did process the original, you'll just create a duplicate. To get strict at-most-once semantics across retries, generate the key yourself one level up and call the lower-level request() method directly. We're considering exposing a higher-level helper in a future version; let us know if you need it.

Rate limits

rate_limited (HTTP 429) means you've exceeded the per-workspace quota for the endpoint class. The SDK does not auto-retry — you decide the back-off policy.

try {
  await plugipay.checkoutSessions.create({ /* ... */ });
} catch (err) {
  if (err instanceof PlugipayError && err.code === 'rate_limited') {
    await sleep(5_000); // crude — real code reads Retry-After
    return retry();
  }
}

The raw Retry-After header isn't currently exposed on PlugipayError (we'll add it in a future version). For now, fixed back-off with jitter is the pragmatic approach.

See Rate limits for the per-endpoint quotas.

Validation errors

The validation_error code carries the most extra context. The server returns a field and a details blob in the error envelope, but PlugipayError only carries code and message — the field/details data is folded into the message for now.

try {
  await plugipay.checkoutSessions.create({
    amount: -100,    // invalid
    currency: 'IDR',
    methods: ['qris'],
    successUrl: '...',
    cancelUrl: '...',
  });
} catch (err) {
  if (err instanceof PlugipayError && err.code === 'validation_error') {
    // err.message → "amount must be a positive integer (received -100)"
    showFormError(err.message);
  }
}

If you need the structured field/details payload, the lower-level request() method preserves the raw envelope — or fall back to the HTTP API directly until we surface them.

Comparing to the API error catalog

The SDK is a thin pass-through. Every code in the API error catalog can appear as err.code here. The only codes the SDK adds are the network ones (timeout, network_error, invalid_response).

When in doubt, the catalog is authoritative for what each code means and how to react.

Common pitfalls

Catching Error instead of PlugipayError

A bare catch (err) will swallow type information. Type-guard with instanceof PlugipayError so you can switch on err.code with TypeScript's narrowing.

Logging the secret

PlugipayError.message never contains the secret — but if you log err.stack along with the original request body in some places, double-check the body doesn't include credentials. Plugipay request bodies don't include credentials (they're in headers), so the common case is safe.

Retrying validation errors

Don't. The request will fail the same way every time. Fix the inputs and re-call.

Eating errors silently

PlugipayError extends Error. If you catch (err) {} you lose visibility on real bugs. At minimum, log code, message, and requestId.

Next

  • Pagination — how list errors surface.
  • WebhooksPlugipayError codes for signature verification.
  • API errors — the full code catalog.
Plugipay — Payments that don't tax your success