Money out — withdrawals
The chapter most likely to differ from what you expect, so it opens with the constraint rather than the endpoints.
The withdrawal endpoints live on the /me/* surface and need a wallet-holder token. A machine
token is rejected on them.
That is a statement about tokens, not about people, and the difference matters. You can obtain a
wallet-holder token for any user you have registered, through
POST /auth/delegated-token — that is what a headless integration is for. So the
endpoint requires a user token; it does not require the user.
There is no OTP and no step-up on money-out today. At the moment a payout is authorised, nothing distinguishes a token your backend minted from one the customer logged in for.
The design exists and the schema is built; the enforcement is not. Until it ships, the control that a customer actually asked for a withdrawal is yours, and a disputed payout has no evidence on our side that they did. Under the e-Money Policy Document the burden of proving the customer authorised a payment sits with the issuer, so this is a live commercial exposure and not a theoretical one. Raise it before you go live on a self-service payout flow.
And the wallet holder must be KYC VERIFIED. That is not configurable, and only a GoHubPay
reviewer can grant it — see Signup. Until then, both adding a payout account and
posting a withdrawal return 403 KYC verification required.
What your app does
With a wallet-user token (documented in the Client app guide):
POST /me/withdrawal-accounts/enquiry | confirmation-of-payee before saving |
POST /me/withdrawal-accounts | save a bank or DuitNow destination |
GET /me/withdrawal-accounts | list saved destinations |
POST /me/withdrawals | request the payout |
GET /me/withdrawals | status |
What your backend can see
Read-only visibility across your tenant, plus one intervention:
GET /admin/withdrawals | every withdrawal in your tenant |
GET /admin/withdrawals/{id} | one, with its gateway detail |
GET /admin/withdrawal-accounts | saved destinations across your users |
POST /admin/withdrawals/{id}/reverse | release a stuck withdrawal — one still CREATED, PENDING or MANUAL_REVIEW — marking it FAILED and returning the hold |
The /admin prefix on these is a historical misnomer — they are yours to call, not
platform-only. We intend to move them to unprefixed paths before general availability, without an
alias at the old location. Pin the operation, not the string, and watch the changelog.
A payout that has already failed returned its hold automatically — there is nothing left to
reverse, and calling it does nothing. The endpoint is for a withdrawal that never reached a terminal
state. On a succeeded one it is refused with 422.
How a withdrawal behaves
Funds are held when the withdrawal is created — debited immediately, so the user cannot spend them twice while the payout is in flight. Then:
| Outcome | What happens to the hold |
|---|---|
SUCCEEDED | Kept. The money has left |
FAILED | Reversed automatically — funds return to the wallet |
PENDING / MANUAL_REVIEW | Kept while it resolves |
Every one of those transitions writes ledger entries, so a withdrawal is reconcilable exactly like any other movement.
A payout must be at least RM 1.00 and at most RM 5,000.00, with a rolling 24-hour ceiling of
RM 10,000.00 per user. Breaching any of them is a 422.
These are platform-wide constants today, not per-tenant settings — if your business needs different numbers, that is a conversation to have before launch rather than a value you can configure.
What is true afterwards
The withdrawal reaches a terminal status; the wallet balance reflects the hold (kept or returned);
and GET /transactions/by-reference ties the whole thing back to your own record of it.
Next: eKYC onboarding for where identity verification is heading, or the Go-live checklist.