Skip to content

Settings ​

http
GET   /v1/bo/settings
PATCH /v1/bo/settings

The 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 ​

LevelWhere it is setExamples
Per requestin the body of each individual requestorder lifetime · accepted routing · metadata
Per accountPATCH /v1/bo/settings and the webhook settings pagewebhook destination URLs · API version · technical contact
Ceilings from abovenot adjustablethe 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.

json
{
  "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" }
}
FieldValueNotes
expires_in_secondsseconds🔴 clamped by the tier above · read the expires_at that comes back
min_ttl_seconds0–86,400 secondsif a match has less time left, skip it; values that cannot fit the order lifetime return 422
fill_preferenceany (default) or internal_onlyinternal_only means no fallback route ⇒ a higher chance of sitting in awaiting_liquidity longer
return_urlhttps://where the user lands after paying on the hosted checkout page
metadataobjectat 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{} sentOpen-order limit per userRate-limit bucket
Complete (device fingerprint + IP)2 orders per userpay_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 ​

SettingRequiredIf 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 3xx counts 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 2xx within 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.

TopicPolicy
FormatYYYY-MM-DD
Upgradesopt-in — we never move you
Breaking changesannounced 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 ​

http
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 new kid 7 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 · false means sandbox.
  • 🔴 A sandbox event can never reach a live endpoint, or vice versa — enforced at the database level, not by application logic.
  • Checking livemode against your own environment is a go-live requirement.

Level 3 — ceilings from the tier above (not adjustable) ​

TopicValueWhat happens when you hit it
Unpaid deposits per user2 (1 if client{} is omitted)409 too_many_open_deposits + details.open_deposit_ids[]
An open order with the same amount and the same sendernot allowed409 duplicate_open_deposit + details.open_deposit_ids[]
Minimum/maximum amountper policy422 amount_not_supported + details.min_minor / details.max_minor
Fractional satang in a deposit amount🔴 not accepted, amounts must be round422 amount_not_supported
Maximum fills per withdrawalread max_splits on the order objecthandled by the system
Dispute-clearing window72 hoursfunds sit in pending_clearing
Late-slip window24 hours after expires_at410 late_slip_window_closed + claim_url
Withdrawal approval wait24 hoursauto-cancelled with cancel_reason_code: "approval_ttl_expired"
Webhook retry cycle11 attempts over ~92 hours 36 minutesretries stop; the event is kept 30 days for resend
Idempotency-Key lifetime7 days🔴 your own TTL must not be shorter
Slip filestype/size from that capability's slip_upload.content_types / slip_upload.max_bytes400 unsupported_media_type / 422 validation_error
History on /v1/pay/events30 daysyou 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: 41

The 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.

BucketScopeLimitWindow
pay_write (creating deposits/withdrawals)API key60060 seconds
pay_create_per_useruser + device + IP1060 seconds
pay_readAPI key1,20060 seconds
slip_uploadsigned capability rate_subject/jti + IP1,20060 seconds
slip_upload_per_usersigned capability rate_subject + IP2060 seconds
bo_reademployee OIDC tenant + principal30060 seconds
bo_reportsemployee OIDC tenant + principal6060 seconds
webhook_resend (counts commands, not deliveries)endpoint1060 seconds
banks_quoteAPI key3,00060 seconds

429 and 503 are not the same thing

  • 429 rate_limited = you exceeded your own quota ⇒ respect Retry-After plus ±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 a 500.

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 configurePossibleWhy 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 usespartlyyou 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_url is set and the endpoint answers 2xx within 3 seconds
  • [ ] All five non-optional events are subscribed
  • [ ] PG-Version is pinned, with an upgrade procedure tested in sandbox first
  • [ ] A default: exists in every switch over 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 ​

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01