Integration guide
Create a payment intent when the buyer commits, confirm it, act on next_action, learn the outcome from a signed webhook, send fulfilment evidence when you deliver, and let payouts release only when the rules and the evidence allow.
Written for the engineer who has run the quickstart and now has to wire a real checkout.
Before you write code
- Mint a test API key on Settings → API keys. Start with
payments:writeandpayments:read; addorders:write,trust:write,payouts:writeandwebhooks:readas you reach those steps. A key is shown once and needs a fresh second factor to mint. Settings. - Have at least one active connection to a provider sandbox, or confirm will find no route and say so (
422 no_eligible_route, with the route decision id so you can read why). Connect a provider. - Add a webhook endpoint on Webhooks: your HTTPS URL on port 443, subscribed at least to
payment.intent.succeeded.v1andpayment.intent.failed.v1. Verify a webhook.
The payment path
1. Create the intent when the buyer commits
POST /v1/payment-intents with the order inline. Send your references so you never have to store Pehmen's ids as the primary key:
{
"merchant_payment_ref": "pay-8812",
"order": {
"merchant_order_ref": "order-8812",
"amount_minor": 12800,
"currency": "SGD",
"category": "event_ticket",
"channel": "web_checkout",
"customer": { "merchant_customer_ref": "cust-2201" },
"seller_ref": "organizer-41"
},
"payment_method": { "family": "CARD" },
"capture_mode": "AUTOMATIC"
}
Use idempotency-key: order-8812-create or similar, built from your own order id, so a retry after a timeout can never create a second payment. Store the returned pi_… id against your order.
category and channel are read by your policies. seller_ref is the same reference that later names the seller in Trust, so use the id your system already has for the organizer or merchant.
2. Confirm
POST /v1/payment-intents/{id}/confirm, optionally with an https:// return_url. This is the moment the Gate decides and the routing engine runs. Read the response:
status | next_action | Do |
|---|---|---|
PROCESSING | NONE | Nothing. Wait for the webhook. |
REQUIRES_ACTION | REDIRECT | Send the buyer to the URL given. |
REQUIRES_ACTION | CLIENT_CONFIRM | Hand the client secret to the provider's hosted fields in the browser. |
REQUIRES_ACTION | QR | Show the PayNow payload until it expires. |
EVALUATING | The Gate said REVIEW or HOLD. A person will resolve it in the dashboard; you will get a webhook. | |
DENIED | The Gate said DENY. Terminal. The decision id tells you why. |
A 422 no_eligible_route means every connection failed a filter; details carries the route decision id, and GET /v1/route-decisions/{id} shows which filter stopped each candidate.
3. Learn the outcome from a webhook
Do not poll in a loop. Handle payment.intent.succeeded.v1 and payment.intent.failed.v1 on your endpoint. Verify the signature, key your handling on Pehmen-Event-Id so a replay is harmless, answer 2xx within ten seconds. If a delivery is late, GET /v1/payment-intents/{id} is the fallback, not the primary path.
4. Capture and refund
With capture_mode: MANUAL the intent stops at AUTHORIZED; POST …/capture with an optional amount_minor moves the money, POST …/cancel releases the hold. PayNow is sale-only and cannot be captured manually; the router will not pick a PayNow connection for a manual-capture intent, and the trace says so.
Refunds are their own object: POST /v1/refunds with the intent, your refund reference and the amount. A refund that exceeds what was captured, or a per-refund cap, is refused with the reason.
5. Tell Pehmen when you deliver
When you issue the tickets or ship the goods, send a fulfilment event. First create a fulfilment source once (POST /v1/fulfillment-sources, kind PLATFORM_SIGNED), retrieve its signing secret once with the one-time token, and sign each event with it:
{
"source_key": "ticketing-issuer",
"order_id": "ord_…",
"event_type": "TICKETS_ISSUED",
"source_event_ref": "issue-77213",
"occurred_at": "2026-09-13T04:12:00Z",
"payload": { "tickets": 2 },
"signature": "…",
"signature_timestamp": "2026-09-13T04:12:01Z"
}
That evidence is what lets a held payout release under a policy that requires it. A cancelled delivery is a second event with reversal_of set.
The payout path
Payouts are instructions to a licensed provider, and the destination is a registered beneficiary, never a bank account in the request.
- Register the seller:
POST /v1/sellerswith yourmerchant_seller_ref. Its verification level rises only through an operator attestation. - Register the destination:
POST /v1/beneficiarieswith the seller id and the account details. This returns202; the provider checks the account holder's name asynchronously and the beneficiary becomes usable when that completes. The account number is not stored; a masked form, a fingerprint and the name-match result are. - Create the instruction:
POST /v1/payout-instructionswith yourmerchant_payout_ref, the beneficiary, the connection, the amount and an optional release condition. The Gate evaluates it on creation.ALLOWmoves it toAPPROVED; anything else holds it with the reasons. - Approve and release are dashboard acts. They need a person with
payouts:approveandpayouts:releaseand a fresh second factor, and above the dual-control threshold two different people. A key cannot do either. Subscribe topayout.instruction.held.v1,.released.v1,.succeeded.v1and.failed.v1to follow it.
Changing a seller's destination is POST /v1/beneficiary-changes; it enters cooling-off, notifies both channels, and waits for a second person. The flow shows every state.
Sizing and limits
- Body limit 1 MiB. Evidence uploads on a case are base64 up to 512 KiB.
- Lists take
limitup to 200 and anaftercursor; the response is{ data, has_more, next_cursor }. - A key has a bucket of 50 requests refilled at 10 per second. Mutations from one IP are capped at 600 per minute with a burst of 900. Honour
retry-afteron a429.
Checklist before you call it done
- Every mutation marked idempotent in OpenAPI sends a key derived from your own ids.
- Your webhook handler verifies the Ed25519 signature on the raw body, rejects a timestamp older than 300 seconds, and dedupes on the event id.
- Your handler answers within ten seconds and does its work afterwards.
- You act on
next_actionforREQUIRES_ACTIONand do not poll for outcomes. - Fulfilment events are signed and sent when you actually deliver.
- Payout destinations are registered beneficiaries; nobody types a bank account into a payout.
- Your key has only the scopes it needs, and the people who approve and release are people.