Skip to main content

Error Handling

PayConnect uses standard HTTP status codes and returns structured error responses to help you diagnose and handle issues.

Error Response Format​

All error responses — validation and domain/business-rule — follow one consistent structure:

{
"statusCode": 404,
"error": "not_found",
"message": "Customer not found",
"correlationId": "b2f6c1e0-8f4a-4c8b-9d2e-1a2b3c4d5e6f"
}
FieldTypeDescription
statusCodenumberHTTP status code
errorstringStable machine-readable code — branch on this, not on message. Values include validation_failed, bad_request, unauthorized, forbidden, not_found, conflict, unprocessable_entity, rate_limited, and internal_error
messagestringHuman-readable error description
correlationIdstringThe request's correlation id — quote it when contacting support (also returned in the CorrelationId response header)

Field-level errors additionally carry fields[] and validationDetail (below).

Field-level Errors (400)​

Failures tied to a specific field — request-schema validation and field-level domain rules (an unknown or wrong-typed custom field value, a duplicate externalUserId) — carry error: "validation_failed" plus per-field detail:

{
"statusCode": 400,
"error": "validation_failed",
"message": "Request validation failed: body.filters.email.operator: Invalid enum value. Expected 'eq' | 'contains' | 'startsWith', received 'like'",
"validationDetail": "body.filters.email.operator: Invalid enum value. Expected 'eq' | 'contains' | 'startsWith', received 'like'",
"correlationId": "b2f6c1e0-8f4a-4c8b-9d2e-1a2b3c4d5e6f",
"fields": [
{
"path": "body.filters.email.operator",
"code": "invalid_enum_value",
"message": "Invalid enum value. Expected 'eq' | 'contains' | 'startsWith', received 'like'"
}
]
}
FieldTypeDescription
errorstringvalidation_failed for any field-level 400 (schema or domain)
fieldsarrayOne entry per failing field: path (dot-notation location prefixed with the request part — body., query., path., or header.), code (machine-readable cause, e.g. invalid_type, unrecognized_keys, too_small, unknown_custom_field, invalid_custom_field_value, duplicate), and a human-readable message
validationDetailstringAll field failures joined into one string (same content as fields, flattened)

Request bodies are validated strictly — unknown keys are rejected, not silently dropped. A mis-cased key is named, and when it case-insensitively matches a real field the response suggests the correct spelling:

{
"path": "body.filters.ExternalUserID",
"code": "unrecognized_keys",
"message": "Unrecognized key 'ExternalUserID' — did you mean 'externalUserId'?"
}

Domain errors that aren't tied to a single field (e.g. paying an already-settled invoice) use the same envelope without fields[] — statusCode, error (the status-derived code, e.g. conflict or unprocessable_entity), message, and correlationId. See Payment Errors below.

HTTP Status Codes​

Success Codes​

CodeMeaningDescription
200OKRequest succeeded
201CreatedResource created successfully

Client Error Codes​

CodeMeaningCommon Causes
400Bad RequestMissing required fields, invalid field values, malformed JSON
401UnauthorizedMissing or invalid API key, expired session token
403ForbiddenValid credentials but insufficient permissions
404Not FoundResource does not exist or belongs to a different company
422Unprocessable EntityRequest is well-formed but semantically invalid (e.g., refunding a pending transaction)
429Too Many RequestsYou exceeded your account's rate limit or monthly quota, or the per-IP safeguard — see Rate Limiting

Server Error Codes​

CodeMeaningDescription
500Internal Server ErrorUnexpected server error — contact support if persistent
502Bad GatewayPayment processor communication failure
503Service UnavailableTemporary service disruption

Common Error Scenarios​

Authentication Errors​

Missing or invalid API key:

{
"statusCode": 401,
"message": "Invalid API key",
}

Expired or invalid session token:

{
"statusCode": 401,
"message": "Session expired. Please authenticate again.",
}

The exact message wording varies by endpoint — some routes return a generic "Unauthorized" for an expired, invalid, or missing session token. Always branch on the 401 status, not the message text.

Fix: Create a new session token using POST /api/auth/session.

Validation Errors​

Missing required field:

{
"statusCode": 400,
"error": "validation_failed",
"message": "Request validation failed: body.customerId: Required",
"validationDetail": "body.customerId: Required",
"correlationId": "b2f6c1e0-8f4a-4c8b-9d2e-1a2b3c4d5e6f",
"fields": [
{ "path": "body.customerId", "code": "invalid_type", "message": "Required" }
]
}

Unknown / mis-cased key (strict validation):

{
"statusCode": 400,
"error": "validation_failed",
"message": "Request validation failed: body.filters.Email: Unrecognized key 'Email' — did you mean 'email'?",
"validationDetail": "body.filters.Email: Unrecognized key 'Email' — did you mean 'email'?",
"correlationId": "b2f6c1e0-8f4a-4c8b-9d2e-1a2b3c4d5e6f",
"fields": [
{ "path": "body.filters.Email", "code": "unrecognized_keys", "message": "Unrecognized key 'Email' — did you mean 'email'?" }
]
}

Payment Errors​

Invalid payment method:

{
"statusCode": 422,
"message": "Payment method is inactive or expired",
}

Payment declined:

{
"statusCode": 422,
"message": "Transaction declined by processor",
}

Pay invoice rejected (PUT /invoices/:invoiceId/payment):

The pay-invoice endpoint returns a 400 with a machine-readable reasonCode so you can branch without string-matching the message. Branch on typeof reasonCode:

reasonCodeTypeMeaning
1616, 1622, …numberProcessor gateway decline (see reasonText, e.g. INSUFFICIENT_FUNDS).
"OVER_PAYMENT"stringThe requested amount exceeds the invoice's remaining balance.
"ALREADY_PROCESSING"stringNothing left to pay — the invoice is settled, or an in-flight ACH already covers the balance.
"QUOTE_CHANGED"stringA confirmed surcharge quote no longer matches the surcharge rules (the merchant's fee settings changed). Nothing was charged; quote the card again.
"OUTCOME_UNKNOWN"stringThe charge was submitted but its outcome did not arrive in time. It may have gone through. Re-read the invoice (GET /invoices/:id) before charging again; never retry blind.
{
"statusCode": 400,
"message": "Payment of 61 exceeds the remaining balance of 60 on this invoice",
"reasonCode": "OVER_PAYMENT"
}
tip

Size each payment to the invoice's amountDue (re-read via GET /invoices/:id before charging) to avoid the OVER_PAYMENT rejection entirely.

Rate Limiting​

Rate limit exceeded:

{
"statusCode": 429,
"message": "Too Many Requests"
}

Rate limits are enforced per account — your usage is metered on its own, so another client's traffic never affects yours (and yours never affects theirs). The default limits are:

LimitValue
Requests per second25 (sustained)
Burst50
Monthly quota2,000,000 requests
info

Limits apply to your account only. Exceed the per-second/burst rate and you get a 429 immediately; exceed the monthly quota and requests are rejected until it resets at the start of the next month. Retry 429s with exponential backoff — see Retry Strategy below.

Need higher limits? Contact support — higher-volume tiers are available. Postback traffic from payment processors does not count against your quota.

Per-IP safeguard​

Separately from your account's limit, a network-level safeguard limits each client IP address:

Requests from one IP addressLimit
Any API endpointabout 300 per second, averaged over 5 minutes
Unauthenticated hosted-page endpoints (hosted payment, invoice and subscription setup pages, login, verification)about 10 per second, averaged over 5 minutes

The first limit is above every account tier, so one account's traffic stays under it even when all of it comes from a single IP address. If several accounts share one outbound IP address (for example, a platform calling on behalf of many merchants), their combined traffic counts toward that IP's limit; contact support if that applies to you. If a safeguard does trip, you get the same 429 body as above with a Retry-After header giving the seconds to wait. Honor Retry-After when it's present; otherwise back off exponentially.

Best Practices​

Retry Strategy​

Use exponential backoff for retryable errors:

async function apiCall(fn, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
const status = error.response?.status;

// Don't retry client errors (except rate limiting)
if (status >= 400 && status < 500 && status !== 429) {
throw error;
}

if (attempt === maxRetries) throw error;

const delay = Math.pow(2, attempt) * 1000;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}

// Usage
const invoice = await apiCall(() =>
axios.post("https://dev.payconnect.us/api/invoices", data, { headers })
);

Error Handling Checklist​

  1. Check the status code — distinguish between client errors (fix your request) and server errors (retry)
  2. Read the message — error messages describe exactly what went wrong
  3. Handle token expiration — refresh session tokens when you receive a 401
  4. Respect rate limits — back off when you receive a 429
  5. Log correlation IDs — every error body echoes a correlationId; log it (or send your own via the x-correlation-id request header) for easier debugging with support
  6. Don't retry 4xx errors — except for 429, client errors require fixing the request

Using Correlation IDs​

Include a x-correlation-id header to trace requests through the system:

curl -X POST https://dev.payconnect.us/api/invoices \
-H "x-session-token: YOUR_SESSION_TOKEN" \
-H "x-correlation-id: my-unique-request-id-123" \
-H "Content-Type: application/json" \
-d '{ ... }'

If you don't provide one, PayConnect generates a correlation ID automatically. Include this ID when contacting support about specific requests.

Next Steps​