Settings
GET /v1/bo/settings
PATCH /v1/bo/settingsThe live Integrations surface
Redacted health reads and webhook endpoint revision changes use GET /v1/bo/integrations and PUT /v1/bo/webhooks/{webhook_id}. See Integrations and webhooks for the transport, CAS, step-up, and URL contracts.
One principle covers every value on this page — you can tighten, you cannot loosen
Every setting here is clamped by the policy of the tier above you. Sending a value looser than the ceiling does not error. It is silently reduced to fit. ⇒ 🔴 Always read the value the system returns. Never assume you got what you sent.
The three levels of configuration
| Level | Where it is set | Examples |
|---|---|---|
| Per request | in the body of each individual request | order lifetime · accepted routing · metadata |
| Per account | PATCH /v1/bo/settings and the webhook settings page | webhook destination URLs · API version · technical contact |
| Ceilings from above | not adjustable | the retry schedule · the 72-hour window · rate-limit quotas |
Level 1 — values that travel with each request
These need no setup in advance. Send them when you create the order.
{
"amount_minor": 500000,
"currency": "THB",
"merchant_reference": "ORD-9981",
"end_user_ref": "u_88213",
"sender_id": "snd_01K2R4M8N0PZ3XT5VKD9WY2C7Q",
"client": {
"device_fingerprint": "df_9a1c4e77b2",
"ip": "203.0.113.44",
"user_agent": "Mozilla/5.0"
},
"fill_preference": "any",
"min_ttl_seconds": 300,
"expires_in_seconds": 1200,
"return_url": "https://shop.example.com/topup/9981",
"metadata": { "campaign": "aug-cashback", "order_id": "ORD-9981" }
}| Field | Value | Notes |
|---|---|---|
expires_in_seconds | seconds | 🔴 clamped by the tier above · read the expires_at that comes back |
min_ttl_seconds | 0–86,400 seconds | if a match has less time left, skip it; values that cannot fit the order lifetime return 422 |
fill_preference | any (default) or internal_only | internal_only means no fallback route ⇒ a higher chance of sitting in awaiting_liquidity longer |
return_url | https:// | where the user lands after paying on the hosted checkout page |
metadata | object | at most 20 keys · each value at most 500 bytes |
client{} is not really optional
The reference calls it optional. Here is what leaving it out actually costs you.
client{} sent | Open-order limit per user | Rate-limit bucket |
|---|---|---|
| Complete (device fingerprint + IP) | 2 orders per user | pay_create_per_user, 10/minute per user |
| Omitted | 🔴 1 order | 🔴 falls into a coarse bucket shared by all of your users ⇒ 429 from the first minute |
Why: without a device fingerprint, end_user_ref is a value you fill in yourself and can forge for free, so we have to tighten the limits.
fee_breakdown[] is split by component, never by who receives the money
The fee_breakdown[] on deposit.settled has the shape {component, amount_minor, rate_bps?} — for example processing_fee · fixed_fee · vat. component is an open enum ⇒ your switch needs a default: that records and alerts.
Level 2 — per-account settings
Webhook destinations
| Setting | Required | If unset |
|---|---|---|
deposit_url | ✅ | — |
withdrawal_url | ❌ | withdrawal events go to deposit_url |
account_url | ❌ | balance.* and webhook.* events go to deposit_url |
Non-negotiable URL requirements
- Must be
https://· TLS 1.2 or newer · a genuinely valid certificate (self-signed fails). - 🔴 We do not follow redirects — a
3xxcounts as a failure (this blocks SSRF and stops signatures being replayed elsewhere). - We add no query parameters and put no data in the query string, ever.
- Answer
2xxwithin 3 seconds (recommended) · the hard limit is 15 seconds. - 🔴 Do not rely on an IP allowlist — we have no fixed source IPs.
Five events you cannot unsubscribe from
deposit.reversed · withdrawal.fill_reversed · balance.debited · webhook.key.rotating · webhook.endpoint.disabled
The first three are money leaving your wallet. If they could be turned off, you would only ever see money coming in and would never be able to reconcile. This is enforced at the database level, not written down as guidance — an endpoint that does not subscribe to all five cannot be saved.
API version
Send it as the header PG-Version: 2026-08-01 on every request. It is also part of the canonical string of the signature.
| Topic | Policy |
|---|---|
| Format | YYYY-MM-DD |
| Upgrades | opt-in — we never move you |
| Breaking changes | announced 90 days ahead · old versions supported for 12 months · signalled by the X-PG-Deprecation header |
These do not count as breaking changes
- Adding a new field to a payload
- Adding a new value to an enum declared open:
reasons[].code·rejection.code·balance_transaction.type·fee_breakdown[].component·EventType
⇒ 🔴 Every switch that touches these values must have a default: that records and alerts. Not throw, and not silence. Code that throws in default: makes your endpoint answer non-2xx ⇒ retry cycle ⇒ the failed-delivery count climbs until the system disables your endpoint automatically.
The api_version of an outgoing event is rendered against the version you had pinned at the moment the event was created ⇒ a retry of an old event always uses the original version.
Signature verification keys
GET https://api.example-wl.com/v1/webhooks/signing-keys- No authentication needed (public keys are public) · quota of 60/minute/IP.
- Cache for at most 24 hours · on an unknown
kid, refetch once (throttled to once a minute) before deciding the signature fails. - You may pin them into your environment, but you must then subscribe to
webhook.key.rotating, which announces a newkid7 days in advance.
We sign with the key that is active at the second of sending, not the key from when the event was created
Because t in the signature has to be fresh, a merchant who has not picked up the new key fails verification immediately on rotation — including for old events still in the retry cycle. That is exactly why webhook.key.rotating cannot be unsubscribed.
Technical contact
It has to be a mailbox somebody actually reads. We use it when your endpoint has been down for a long stretch and when the system disables an endpoint automatically. If you have only one endpoint and it gets disabled, the events that cannot be unsubscribed are emailed here.
Sandbox versus live
- Every event carries
livemode·falsemeans sandbox. - 🔴 A sandbox event can never reach a live endpoint, or vice versa — enforced at the database level, not by application logic.
- Checking
livemodeagainst your own environment is a go-live requirement.
Level 3 — ceilings from the tier above (not adjustable)
| Topic | Value | What happens when you hit it |
|---|---|---|
| Unpaid deposits per user | 2 (1 if client{} is omitted) | 409 too_many_open_deposits + details.open_deposit_ids[] |
| An open order with the same amount and the same sender | not allowed | 409 duplicate_open_deposit + details.open_deposit_ids[] |
| Minimum/maximum amount | per policy | 422 amount_not_supported + details.min_minor / details.max_minor |
| Fractional satang in a deposit amount | 🔴 not accepted, amounts must be round | 422 amount_not_supported |
| Maximum fills per withdrawal | read max_splits on the order object | handled by the system |
| Dispute-clearing window | 72 hours | funds sit in pending_clearing |
| Late-slip window | 24 hours after expires_at | 410 late_slip_window_closed + claim_url |
| Withdrawal approval wait | 24 hours | auto-cancelled with cancel_reason_code: "approval_ttl_expired" |
| Webhook retry cycle | 11 attempts over ~92 hours 36 minutes | retries stop; the event is kept 30 days for resend |
Idempotency-Key lifetime | 7 days | 🔴 your own TTL must not be shorter |
| Slip files | type/size from that capability's slip_upload.content_types / slip_upload.max_bytes | 400 unsupported_media_type / 422 validation_error |
History on /v1/pay/events | 30 days | you cannot read further back |
🔴 Deposit amounts must be round — no fractional satang
If your current system uses the "add random cents to tell orders apart" trick (amount + random_cents), delete it. We match on the identity of the sender, not on cents ⇒ an amount with a fractional part returns 422 amount_not_supported. And your old matcher, the one that identified slips by their cents, will never work again.
Rate-limit quotas
Every response carries these headers, not just the 429s.
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 41
RateLimit-Policy: 600;w=60
X-RateLimit-Scope: api_key
Retry-After: 41The example above is a normal merchant API response, hence X-RateLimit-Scope: api_key. Slip ingress scopes from signed capability rate_subject/jti + IP, while /v1/bo/* uses the employee OIDC tenant + principal; neither falls back to an API-key bucket.
| Bucket | Scope | Limit | Window |
|---|---|---|---|
pay_write (creating deposits/withdrawals) | API key | 600 | 60 seconds |
pay_create_per_user | user + device + IP | 10 | 60 seconds |
pay_read | API key | 1,200 | 60 seconds |
slip_upload | signed capability rate_subject/jti + IP | 1,200 | 60 seconds |
slip_upload_per_user | signed capability rate_subject + IP | 20 | 60 seconds |
bo_read | employee OIDC tenant + principal | 300 | 60 seconds |
bo_reports | employee OIDC tenant + principal | 60 | 60 seconds |
webhook_resend (counts commands, not deliveries) | endpoint | 10 | 60 seconds |
banks_quote | API key | 3,000 | 60 seconds |
429 and 503 are not the same thing
429 rate_limited= you exceeded your own quota ⇒ respectRetry-Afterplus ±20% jitter · 🔴 never retry in a tight loop, the penalty compounds.503 service_unavailable/503 route_unavailable= the system is shedding load ⇒ not your bug, and not a500.
A 429 does not consume your Idempotency-Key ⇒ you can resend with the same key as soon as Retry-After elapses.
What we do not let you configure — and why
| You want to configure | Possible | Why not |
|---|---|---|
| One webhook at the end of a withdrawal instead of one per fill | ❌ | each fill comes from a different payer ⇒ your bank statement arrives line by line. A single aggregated event makes reconciliation impossible. |
| Turning off the 72-hour window so funds are instantly withdrawable | ❌ | it is the mechanism that makes a reversal hit pending_clearing instead of pushing your wallet negative |
| Auto-approving review-queue orders after a timeout | ❌ | there is no deposit.auto_approved event and there never will be · past the deadline we resend deposit.under_review with review.escalated: true and a new version |
| Choosing which liquidity source the system uses | partly | you can set fill_preference per request · anything more granular is internal policy |
| Seeing the deadline staff work to | ❌ | the support queue belongs to the provider above you · what you get is review.estimated_resolution_bucket (under_15m / under_1h / under_4h / extended) |
| Rewording the review-queue reason shown to users | ❌ | we ship display_th / display_en ready to render ⇒ 🔴 do not write your own copy, and never hardcode "within 15 minutes" |
Settings checklist
- [ ] Read the values returned after every
PATCH; never assume you got what you sent - [ ]
deposit_urlis set and the endpoint answers2xxwithin 3 seconds - [ ] All five non-optional events are subscribed
- [ ]
PG-Versionis pinned, with an upgrade procedure tested in sandbox first - [ ] A
default:exists in everyswitchover an open enum - [ ] Your idempotency TTL is not shorter than 7 days
- [ ]
sk_live_*lives in a secret manager and can be rotated without a redeploy - [ ] The technical contact is a mailbox somebody reads
- [ ]
client{}is sent on every request
Next
- Retries and endpoint outages — what happens when your endpoint cannot answer
- Logs and audit trail — verifying your configured endpoint really receives events
- Approving withdrawals — the two-approver threshold
- All error codes
- Go-live checklist
