Skip to main content

For developers

Is there an SDK?#

No, and none is needed. One JSON REST API, three headers, fetch or curl. The quickstart has zero dependencies. An OpenAPI 3.1 document is generated at boot from the live route table if you want to generate a client.

What are the three headers?#

x-api-key (or Authorization: Bearer) with your key; x-pehmen-organization with your workspace id, a plain UUID shown on the overview and the Quickstart screen; x-pehmen-environment, which defaults to test and must match the key's environment. API overview.

How do I get an API key?#

Settings → API keys, with a session that has api_keys:manage and a fresh second factor. Choose the scopes from the fourteen a key may hold. The secret is shown once. A key cannot mint a key. Authentication.

Why does my key get 403 on approve, release or publish?#

Those need a person with a fresh second factor, and a key cannot provide one. The eight step-up permissions are never grantable to a key, and a key that hits one gets 403 forbidden with a message saying so. Do those in the dashboard. Roles and guards.

Why is idempotency-key mandatory?#

So a retry after a timeout can never create a second payment. Same key, same request: the stored response comes back, nothing re-runs. Same key, different request: 409 idempotency_conflict. Missing: 400. Derive the key from your own order id. Idempotency and retries.

I got 409 conflict saying the outcome is ambiguous.#

A previous attempt acquired the key and died mid-way. Do not resend blindly: read the resource (or the list filtered by your reference) and continue from its real state.

What are the rate limits?#

Per key: a bucket of 50 refilled at 10 per second (429 rate_limited). Mutations per source IP: 600 per minute, burst 900, with retry-after. The provider webhook inbox: 3000 per minute per IP, burst 4500, then a cooldown. Retrying with the same idempotency key after the wait is safe.

How are amounts represented?#

Integer minor units plus an ISO 4217 currency: 12800 and SGD is SGD 128.00. A JSON number that was already rounded by parsing is refused rather than paid.

How do lists paginate?#

limit up to 200 and an after cursor. The response is { data, has_more, next_cursor }. The audit log's cursor is a UUID; every other list's cursor is a prefixed id.

How do I know a payment succeeded?#

Subscribe a webhook endpoint to payment.intent.succeeded.v1 and payment.intent.failed.v1. Do not poll in a loop; GET /v1/payment-intents/{id} is the fallback for a late delivery. Integration guide.

How do I verify a webhook?#

Fetch the public keys from /.well-known/pehmen-webhook-keys.json, pick the key named by kid, verify the Ed25519 signature over t + "." + raw body, reject a timestamp older than 300 seconds. There is no shared secret. Node and Python examples are on Verify a webhook.

Why is my endpoint refused when I save it?#

It must be https:// on port 443 and publicly routable. Private, loopback, link-local and cloud-metadata addresses are refused (422 webhook_url_rejected), and DNS is re-resolved and re-checked on every delivery. Tunnels to localhost that resolve to a public address work; http:// does not.

How are deliveries retried?#

Ten-second timeout, then 30 seconds doubling to an hour, each wait jittered by 0.5 to 1.5, up to twenty attempts, one at a time per endpoint, oldest first. After twenty failures the endpoint is disabled; fix it, enable it, replay from the dashboard.

Can I test webhooks without a public server?#

Use a tunnel that gives you a public HTTPS hostname, or start with the Webhooks screen, which shows every delivery, its status code and a replay button, so you can see what would have been sent before you have a handler.

How do Test and Live differ for code?#

They are two sets of objects. A key's prefix (pehmen_test_, pehmen_live_) names its environment; the header must agree or you get 403 environment_mismatch. Nothing converts. No live key can exist until the workspace holds a live entitlement and the deployment switch is on; today neither is true. Environments.

Why did confirm return 422 no_eligible_route?#

Every connection failed a routing filter. details carries the route decision id; GET /v1/route-decisions/{id} shows which filter stopped each candidate. Usually no ACTIVE connection, or PayNow with manual capture.

What does next_action mean after confirm?#

NONE: wait for the webhook. REDIRECT: send the buyer to the URL. CLIENT_CONFIRM: hand the client secret to the provider's hosted fields. QR: show the PayNow payload until it expires.

Can I pass a bank account number in a payout call?#

No. A payout names a registered beneficiary id. Registering a beneficiary returns 202 while the provider checks the account holder's name; the account number itself is never stored.

What is the API version?#

Every response carries pehmen-version: 2026-08-01. Sending a different version header is a 400. There is one version.

Where is the reference?#