Errors
Every error response follows the same shape:
{ "success": false, "error": { "code": "invalid_signature", "message": "..." } }
Codes
code | HTTP | When | Notes |
|---|---|---|---|
missing_authentication | 401 | Neither Authorization: Bearer nor X-App-Key was sent | |
invalid_signature | 401 | HMAC signature doesn’t match | Also used for app_not_found-like cases (wrong api_key, app not hmac) — deliberately vague to avoid revealing which check failed |
expired_timestamp | 401 | X-Timestamp more than 5 minutes off the server’s clock | Check both directions — a client clock running fast fails the same as one running slow |
invalid_token | 401 | Firebase ID Token failed signature/issuer/expiry checks | |
app_not_found | 401 | Firebase ID Token’s aud doesn’t match any registered app | |
app_disabled | 403 | The app was disabled in the portal | Returned only after the signature (HMAC) or token (Firebase) checked out. Re-enable the app in the portal |
invalid_request | 400 | Body validation failed (missing/invalid field) | message describes the specific field. Also returned when a mode: "subscription" checkout sends plan_id, amount, currency, interval, interval_count or plan_ref, or omits price_id/customer_id |
payment_method_not_allowed | 403 | Requested payment_method isn’t enabled for your app’s environment/configuration | e.g. test against a production app, or stripe against an app with no active Stripe credentials |
provider_error | 502 | Stripe/PayOS itself rejected the request | Transient — safe to retry with backoff |
invalid_card_details | 400 | Test Payment Method card declined on the hosted picker page (test_card_code=4000000000000002) — not returned by checkout-sessions itself | |
insufficient_funds | 402 | Test Payment Method card declined on the hosted picker page (test_card_code=4000000000009995) — not returned by checkout-sessions itself | |
not_found | 404 | /subscriptions/{external_ref} — no subscription record for that user; GET /transactions/{transaction_id} — id doesn’t exist, or belongs to a different app/user; /customers/{id}, /plans/{id}, /plans/{id}/prices/{price_id} — doesn’t exist, belongs to another app (or, for customers with Firebase ID Token, another user), or the price isn’t under that plan | Not necessarily an error in your flow |
forbidden | 403 | Firebase ID Token caller requested a different external_ref than their own token’s sub; or a Firebase ID Token app tried to write plans/prices (POST/PATCH on /plans…) or to change a subscription’s plan (PUT /subscriptions/{external_ref}/plan) — those are HMAC only | |
customer_not_found | 400 | customer_id in a mode: "subscription" checkout, or in PUT /subscriptions/{external_ref}/customer, is missing, unknown, belongs to another app, or (Firebase ID Token) was created by another user | Create the customer first — 03.06 |
price_not_found | 400 | price_id in a mode: "subscription" checkout, or in PUT /subscriptions/{external_ref}/plan, is unknown, belongs to another app, is archived, or its plan is archived | 03.07 |
already_lifetime | 409 | mode: "subscription" checkout for an end user who already owns a lifetime subscription | Nothing to buy — see 03.07 |
plan_code_taken | 409 | POST /plans with a code that already exists in your app | Pick another code |
Any other error.code not listed here — including the case where a webhook’s own signature check fails (returns invalid_signature too, but at 400 rather than 401, since that’s PayGate’s own inbound endpoint, not one you call) — should be treated as opaque; don’t pattern-match on message text, only on code.
Retry guidance
| Situation | Retry? |
|---|---|
401/403 codes | No — fix the request (signature, token, permissions) first |
400 (invalid_request, customer_not_found, price_not_found, invalid_card_details) | No — fix the payload |
409 (plan_code_taken) | No — use another plan code |
402 (insufficient_funds) | No — this is a real decline, ask the user for another payment method |
502 (provider_error) | Yes — exponential backoff, this is Stripe/network flaking, not your integration |
| Network timeout / no response | Yes — but see idempotency note below |
Idempotency
POST /api/v1/checkout-sessions is not idempotent — retrying a timed-out request creates a second transaction. If you need safe retries, track your own request ID client-side and check GET /api/v1/subscriptions/{external_ref} before retrying, to avoid double-charging a user who actually completed the first attempt.