Reference

Errors

Every error uses one shape.

{ "error": { "code": "amount_too_small", "message": "amount is below the minimum", "request_id": "0af7651916cd43dd8448eb211c80319c" } }

error.request_id echoes the X-Request-Id response header — quote it when you contact support and we can find that exact request in our logs.

Match on error.code, never on error.message — the codes are the contract, the messages are prose and may be reworded. Three codes add a third field, error.fields, keyed by each field's path in the body you sent: validation_failed names the rules that were broken, payment_method_unavailable the offending code, and customer_required the buyer detail that is missing.

Below is every code /v1 can return. As a rule of thumb: 4xx other than 429 means the request itself is wrong and will stay wrong, so retrying it unchanged is wasted; 429 and 500 are worth retrying, with the same Idempotency-Key. The one exception is invalid_body at 400, which means the body never arrived intact — retry that one too.

Authentication & access

unauthorized401

No Authorization: Bearer … header, a header not in that form, or a key that does not resolve — mistyped, revoked, or from another environment.

What to do: Send Authorization: Bearer kp_live_…. If the header is already right, the key is dead — issue a new one in the dashboard. Retrying the same key never succeeds, and more than 30 failed attempts a minute from one IP get rate_limited instead.

merchant_suspended403

The account is suspended. New payment requests are blocked; reading existing ones still works.

What to do: Nothing in the request will change this. Contact Kasera Pay support.

bad_origin403

A create — or any request that is not GET — arrived carrying an Origin header that is not on our allow-list. This is the browser CSRF backstop, and it sits in front of everything: it is checked before your API key, so it can answer an unauthenticated request too. A call with no Origin header at all, which is what a server sends, passes straight through, and GET is never affected.

What to do: Call /v1 from your server, not from a browser: Origin is set by the browser and client-side JavaScript cannot remove it. Your secret key has no business in a browser in any case. Note this is the second 403 on this API — match on error.code, never on the status, to tell it from merchant_suspended.

Request validation

validation_failed422

A body field broke its rule: amount not a whole number between 1 and 1,000,000,000, description over 255 characters, external_id or merchant_ref over 64, an email that is not one, a payer.phone that is not E.164 (customer.phone is free-form, max 32 characters), a return_url that is not https or is over 2048 characters, more than 50 order_items, or expires_in_minutes outside 1–10080.

What to do: error.fields names each broken field and the rule it failed, keyed by the field's path in the body you sent: expires_in_minutes, payer.phone, customer.phone, and a bad line as order_items.3.price — index included, counting from 0. Dots all the way, no brackets, so the key drops straight into a path getter. Fix the request — the same body will be refused every time.

invalid_body400 · 422

400 when the body could not be read off the connection at all. 422 when the bytes arrived but could not be bound — either they are not JSON, or a field carries the wrong JSON type: "amount": "150000" is perfectly valid JSON and still refused. A body over 1 MB is not this code; see body_too_large.

What to do: Read the message: on a type mismatch it names the field and the type expected there, as amount must be a number, got string — a number your client library quoted is still a string. Otherwise check the JSON and the Content-Type. Only the 400 is worth retrying, and only because the connection may have been the problem.

body_too_large413

The request body is over 1 MB. It is refused at that mark and never parsed, so this is our ceiling talking and not your syntax — it used to arrive truncated and be reported as invalid_body, which sent you looking for a bracket that was never missing.

What to do: Send less. A create is well under a kilobyte in ordinary use; a body this size usually means order_items is carrying a whole catalogue, or a field was filled with something that is not text.

payment_method_unavailable422

A code in payment_methods that does not exist, or exists but is not enabled on your account. Sending a code you cannot use is refused rather than quietly dropped: a typo like va_bca_ would otherwise ship a checkout silently missing a bank.

What to do: error.fields names the offending code. Read the account's list from GET /v1/payment_methods rather than hard-coding it — what is enabled can change without a release on your side.

customer_required422

A method this payment can end up on needs something about the buyer that you did not send: a Virtual Account needs customer.name, a card needs customer.email. Which methods are checked follows what you sent — every code in payment_methods, or, when you named none, the one method the payment will be created on. On Direct API — no checkout object — that is an error, since there is no page to ask. Send a checkout object and it is only an error when you also dropped the customer step from checkout.steps, because otherwise the form collects it.

What to do: error.fields names the missing field. Send it in customer — not payer, which does not satisfy a method. If you would rather not collect it, use Checkout.

order_items_mismatch422

order_items was sent and the sum of price × quantity does not equal amount.

What to do: Make the lines add up, or drop order_items — it is display detail, and amount is what the buyer pays either way.

invalid_cursor422

starting_after on the list endpoint carries a payreq_ id that is not one of yours — another account's, or one that no longer exists.

What to do: Page with the last id from the previous page's data, exactly as it was returned. A value without the payreq_ prefix — the bare uuid — is refused with this same code rather than quietly treated as page one.

invalid_filter422

created_after or created_before on the list endpoint is not an RFC3339 timestamp.

What to do: Send the full shape, 2026-08-11T00:00:00+07:00, not 2026-08-11. Percent-encode the + of the zone offset (%2B) if your HTTP client does not: in a query string a bare + means a space. A bound that does not parse is refused rather than ignored, so a filter you can see applied is a filter that applied.

Idempotency

invalid_idempotency_key400

The Idempotency-Key header is longer than 255 bytes. The limit is bytes, not characters, so a key built from non-ASCII text runs out sooner than it looks. Sending no key at all is not an error — it means you have opted out of retry protection.

What to do: Use a shorter key. A UUID is 36 bytes and always fits.

idempotency_conflict409

This key is already on one of your payment requests, and this body differs from the one that created it. The comparison is over the exact bytes sent, so reordered keys or changed whitespace count as a different body.

What to do: A new payment needs a new key. A retry needs the byte-identical body. The original is untouched either way — but this response does not name it: there is no id in the body, and if the key came from the header there may be nothing on the payment to search by. Recover it from your own record of the first attempt, or list your recent requests and match on created_at. Storing the id the create returned is what keeps this a non-event.

Limits

rate_limited429

Over 300 requests a minute on this API key, or over 30 failed authentications a minute from one IP.

What to do: Wait out the minute and retry — with the same Idempotency-Key, so a create that did land comes back instead of happening twice.

daily_count_cap429

The account has already issued its maximum number of payment requests for the day.

What to do: Not a rate to wait out: the window is a calendar day in Asia/Jakarta, so it clears at midnight WIB. Ask support to raise the cap if the volume is real.

daily_amount_cap429

This request would push the day's total issued amount past the account cap. Counted the same way, over the Asia/Jakarta day.

What to do: Also clears at midnight WIB, or support raises it. A smaller amount may still fit under what is left of today.

unverified_count_cap429

The account's onboarding is not active yet, and it has already created its lifetime allowance of payment requests (10 by default). Canceled requests do not count; expired and failed ones do.

What to do: Waiting does nothing — this allowance is not a daily one and never resets. Finish account verification; the daily caps apply from then on. See /docs/limits.

unverified_amount_cap429

The account's onboarding is not active yet, and this request would push its lifetime receivable total past the allowance (Rp1.000.000 by default). Only pending and succeeded requests count toward it.

What to do: Finish account verification. A smaller amount may still fit, and an expired or canceled request releases its share of the allowance again — but neither is a substitute for verifying.

amount_too_small422

amount is below the account minimum — Rp10.000 by default.

What to do: Raise the amount. Separate from the validation_failed rule, which refuses anything outside 1–1,000,000,000 whatever your account minimum is.

amount_too_large422

amount is above the account maximum — Rp10.000.000 by default.

What to do: Split the order, or ask support to raise the ceiling.

expiry_too_long422

expires_in_minutes is beyond the account ceiling — 24 hours by default. Anything above 10080 is refused earlier as validation_failed.

What to do: Ask for a shorter lifetime, or ask support to raise the ceiling.

expiry_too_short422

The lifetime worked out to zero or less. expires_in_minutes below 1 is refused earlier as validation_failed, so on this API the only way here is a bad expiry default on our side.

What to do: If you ever see it, it is our configuration and not your request. Tell us.

State

not_found404

No payment request with that id belongs to your account — a wrong id, another account's id, or an id sent without the payreq_ prefix, which is refused without a lookup.

What to do: Check the id, prefix included. "Not yours" and "does not exist" are deliberately the same answer, so this never confirms that someone else's id is real.

Refunds

refund_not_supported422

The payment is not a card. Only cards refund through the API; QRIS and Virtual Account refunds are manual.

What to do: Refund it from the dashboard, or out of band. Nothing in the request will change this.

not_refundable409

The payment has not succeeded — it is still pending, or it expired, was canceled or failed.

What to do: Only a succeeded payment holds money to return. Check status on GET /v1/transactions/:id first.

refund_exceeds_amount422

amount is more than what is left unrefunded on the payment. After a partial refund, an omitted amount no longer means the original total.

What to do: Send the amount explicitly, at most what remains. Nothing moved.

refund_refused502

The card processor declined the refund. Nothing moved; the amount is still available to refund.

What to do: Safe to retry, with the same Idempotency-Key or a new one. If it keeps refusing, tell us the refund id.

refund_unresolved502

The card processor could not be reached, so we do not know whether the refund landed. The amount is held pending on a refund record until a human resolves it.

What to do: Do not retry — a second attempt could refund twice. Contact Kasera Pay support with the payment id.

Our side

internal500

Something failed on our side — a database, a dependency, a bug. It carries no detail because there is no detail you could act on: nothing in your request produced it.

What to do: Retry with the same Idempotency-Key — that is exactly the case it exists for, and a create that did land comes back instead of happening twice. If it keeps up, it is ours to fix: tell us the time and, if you have one, the id.

upstream_unavailable502/503/504

The edge in front of the API could not get an answer from it — the API is deploying, restarting, or down. The status is whichever the edge saw; the body is this same envelope, so error.code still parses. This is the one family of errors produced in front of the API rather than by it.

What to do: Retry with backoff and the same Idempotency-Key. Nothing in your request produced it, and nothing reached the API.