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. Thecodeis 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:
amountnot a positive integercurrencynot a recognized ISO 4217 codeemailmalformed- Required field missing
- Unknown field present (we accept unknown fields silently but error on typos when they look intentional, e.g.,
customerIDinstead ofcustomerId)
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 requestId — always 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:
- The
requestIdfrommeta.requestIdor theX-Request-Idresponse header. - The endpoint and method.
- What you were trying to do (in business terms).
- The error code and message.
We can trace the request in our logs in seconds with that info.