Skip to main content

Integration guide

In one sentence

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​

  1. Mint a test API key on Settings → API keys. Start with payments:write and payments:read; add orders:write, trust:write, payouts:write and webhooks:read as you reach those steps. A key is shown once and needs a fresh second factor to mint. Settings.
  2. 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.
  3. Add a webhook endpoint on Webhooks: your HTTPS URL on port 443, subscribed at least to payment.intent.succeeded.v1 and payment.intent.failed.v1. Verify a webhook.

The payment path​

Generated diagram · colour encodes component and relationship kind

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:

statusnext_actionDo
PROCESSINGNONENothing. Wait for the webhook.
REQUIRES_ACTIONREDIRECTSend the buyer to the URL given.
REQUIRES_ACTIONCLIENT_CONFIRMHand the client secret to the provider's hosted fields in the browser.
REQUIRES_ACTIONQRShow the PayNow payload until it expires.
EVALUATINGThe Gate said REVIEW or HOLD. A person will resolve it in the dashboard; you will get a webhook.
DENIEDThe 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.

  1. Register the seller: POST /v1/sellers with your merchant_seller_ref. Its verification level rises only through an operator attestation.
  2. Register the destination: POST /v1/beneficiaries with the seller id and the account details. This returns 202; 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.
  3. Create the instruction: POST /v1/payout-instructions with your merchant_payout_ref, the beneficiary, the connection, the amount and an optional release condition. The Gate evaluates it on creation. ALLOW moves it to APPROVED; anything else holds it with the reasons.
  4. Approve and release are dashboard acts. They need a person with payouts:approve and payouts:release and a fresh second factor, and above the dual-control threshold two different people. A key cannot do either. Subscribe to payout.instruction.held.v1, .released.v1, .succeeded.v1 and .failed.v1 to 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 limit up to 200 and an after cursor; 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-after on a 429.

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_action for REQUIRES_ACTION and 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.