Errors

Every Plugipay API error follows the same shape so your error-handling code can branch on code rather than parsing strings. This page is the catalog: every code, what it means, what to do.

The error envelope

{
  "data": null,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "amount must be a positive integer",
    "field": "amount",
    "details": { "received": -100 }
  },
  "meta": {
    "requestId": "req_01H...",
    "timestamp": "2026-05-12T10:42:00Z"
  }
}
Field Type Notes
code string Stable identifier — switch on this
message string Human-readable description — safe to surface to developers, not end users
field string | absent Specific field for validation errors
details object | absent Extra context, varies per error

The HTTP status code is also meaningful (4xx vs 5xx), but error.code is the canonical identifier.

Don't pattern-match on message. We tweak the wording for clarity. The code is stable across versions.

Error code categories

We group codes by the type of fix:

  • Authentication / authorization (4xx) — the request didn't have the right credentials.
  • Validation (4xx) — the request was malformed or violates a business rule.
  • Resource state (4xx) — the resource exists but can't accept this operation right now.
  • Rate limit (429) — you're going too fast.
  • Upstream / system (5xx) — our fault, or a transient provider issue.

Authentication / authorization

INVALID_SIGNATURE (401)

The HMAC signature didn't match. Causes:

  • Wrong secret.
  • String-to-sign assembled differently than the server computes it (whitespace, field order).
  • Body bytes don't match what was hashed.

See Authentication for the exact signing recipe and debugging tips.

INVALID_KEY (401)

The access key ID is unrecognized. Possibilities:

  • You revoked the key.
  • Typo in the key ID.
  • Mixing test/live keys: a pk_test_* key against a live-only path returns this.

INVALID_TIMESTAMP (401)

Your X-Plugipay-Timestamp is more than 300 seconds off server time. Sync your clock with NTP.

EXPIRED_SIGNATURE (401)

Same root cause as INVALID_TIMESTAMP — the request was rejected because of replay window. Resign with the current timestamp.

MISSING_AUTHORIZATION (401)

No Authorization header. Add it; see Authentication.

MODE_MISMATCH (401)

The key's mode (test/live) doesn't match the requested resource. Examples:

  • Using a test key on a live-only admin endpoint.
  • Trying to fetch a live resource with a test key.

INSUFFICIENT_SCOPE (403)

The key authenticates, but doesn't have permission for this operation. Check the key's role in the dashboard.

WORKSPACE_NOT_AUTHORIZED (403)

The key belongs to a different workspace than the resource. Fix: mint a key in the correct workspace, or pass X-Plugipay-On-Behalf-Of if you're a platform partner.

Validation

VALIDATION_ERROR (400)

A field has the wrong type, format, or value. The field and details fields will tell you which one:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "amount must be a positive integer",
    "field": "amount",
    "details": { "received": -100, "minimum": 1 }
  }
}

Common subcases:

  • amount not a positive integer
  • currency not a recognized ISO 4217 code
  • email malformed
  • Required field missing
  • Unknown field present (we accept unknown fields silently but error on typos when they look intentional, e.g., customerID instead of customerId)

INVALID_CURSOR (400)

The pagination cursor is expired, malformed, or doesn't match the current filter set. Restart the iteration without cursor.

INVALID_LIMIT (400)

limit out of range (must be 1-100).

INVALID_IDEMPOTENCY_KEY (400)

Idempotency key is empty, too long (max 255 chars), or contains illegal characters. ASCII-printable only.

UNSUPPORTED_CURRENCY (400)

You passed a currency the workspace's payment provider doesn't support. The error details includes the list of supported currencies for this workspace.

Resource state

NOT_FOUND (404)

The resource doesn't exist, or isn't visible to this key (different workspace). The field may say which resource: customerId, paymentId, etc.

We don't distinguish "doesn't exist" from "exists but you can't see it" — both return NOT_FOUND to prevent enumeration.

RESOURCE_DELETED (404)

You're trying to operate on an archived/deleted resource. The body confirms which one. Some operations on archived resources return NOT_FOUND; ones where it matters (e.g., refunding an archived payment) return this more specific code.

INVALID_STATE (409)

The resource is in a state that doesn't accept this operation. Examples:

  • Refunding a payment that hasn't yet succeeded.
  • Canceling an already-canceled subscription.
  • Marking a void invoice as paid.

details.currentState tells you what state the resource is in.

IDEMPOTENCY_MISMATCH (409)

The idempotency key matches a prior request, but parameters differ. field tells you which field changed.

IDEMPOTENCY_IN_PROGRESS (409)

A concurrent request with the same key is still running. Wait briefly and retry, or accept that the original will succeed.

CONFLICT (409)

Generic conflict not covered by a more specific code. Example: trying to create a second checkout session with the same merchant-supplied identifier. details will explain.

UNPROCESSABLE_ENTITY (422)

Business-rule violation:

  • Refund amount exceeds the original payment.
  • Subscription plan currency doesn't match the customer's existing payment method currency.
  • Trying to cancel a subscription that's already scheduled to end at the period boundary.

Always check details for specifics.

Rate limit

RATE_LIMITED (429)

You've exceeded the per-workspace rate limit for this endpoint class. Headers tell you when you can retry:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1715526900
Retry-After: 12

Wait Retry-After seconds and retry. SDKs handle this automatically with exponential backoff.

See Rate limits.

Upstream / system

UPSTREAM_ERROR (502)

The underlying payment provider (Xendit, Midtrans, PayPal) returned an error. Subcodes via details.upstreamCode map to provider-specific errors.

For transient upstream errors, retry with backoff. Persistent errors usually mean a configuration issue on the provider side — check Settings → Providers in the dashboard.

UPSTREAM_TIMEOUT (504)

The upstream provider didn't respond in time. Always safe to retry — the operation may have succeeded server-side, but use an idempotency key to be sure.

INTERNAL_ERROR (500)

Plugipay had an unexpected error. The response includes a requestIdalways include this when you report the issue. We can trace what happened.

Most internal errors are safe to retry. If they recur, contact us.

SERVICE_UNAVAILABLE (503)

Maintenance window or systemic outage. We display planned maintenance on status.plugipay.com. Retry with backoff.

Webhooks-specific

These appear in webhook delivery attempts, not in your direct API calls:

WEBHOOK_TIMEOUT

Your endpoint took longer than 10 seconds to respond. We mark the attempt failed and retry per the backoff schedule.

WEBHOOK_HTTP_ERROR

Your endpoint returned 4xx or 5xx. We retry only on 5xx (server errors); 4xx means "you don't accept this payload" and we stop.

WEBHOOK_SIGNATURE_REJECTED

Diagnostic-only — not an actual error we send. If your endpoint is verifying signatures correctly and rejects ones it can't verify, that becomes a WEBHOOK_HTTP_ERROR 401 from our side. Visible in webhook delivery logs.

Handling errors well

A robust client handles errors by code, with sensible defaults:

try {
  const refund = await plugipay.refunds.create({...});
} catch (err) {
  switch (err.code) {
    case 'IDEMPOTENCY_MISMATCH':
      // Different params with same key. Investigate or use a new key.
      throw err;

    case 'INVALID_STATE':
      // Already refunded, or payment hasn't settled yet.
      // Re-fetch the payment and decide.
      return;

    case 'RATE_LIMITED':
      // SDK should auto-retry. If you reach here, the SDK gave up.
      await sleep(parseInt(err.headers['retry-after']) * 1000);
      return retry();

    case 'UPSTREAM_TIMEOUT':
    case 'UPSTREAM_ERROR':
    case 'INTERNAL_ERROR':
      // Transient. Retry with idempotency key.
      return retryWithKey(err.idempotencyKey);

    case 'VALIDATION_ERROR':
      // Bug in our code. Don't retry.
      logger.error('Bad refund params', { field: err.field, ... });
      throw err;

    default:
      throw err;
  }
}

The SDKs surface error codes as typed exceptions (PlugipayError subclasses or equivalent).

Including details in a bug report

When something errors and you can't figure out why, file a support ticket with:

  1. The requestId from meta.requestId or the X-Request-Id response header.
  2. The endpoint and method.
  3. What you were trying to do (in business terms).
  4. The error code and message.

We can trace the request in our logs in seconds with that info.

Next

Plugipay — Payments that don't tax your success