Integration walkthrough
Every call, in order, from the credentials we hand you to money moving and reconciling. If you read one page before writing code, read this one — the pages after it go deeper on each step.
Every request and response below was executed against a running backend and the output pasted back. Where something is not built yet, this page says so and gives the status code you will actually receive.
The shape of it
Three rules fall out of that diagram, and they are worth internalising before step 1:
- You keep your user ids; we keep the ledger subject. Every call names a person by your identifier. You do not store our UUIDs.
- Your backend acts as itself; your app acts for one person. Two tokens, obtained two different ways. Confusing them is the most common integration error.
- Nothing here creates a person as a side effect. Registration is always an explicit call.
Step 0 · What we give you
You cannot self-serve these — ask your GoHubPay contact:
| You receive | Notes |
|---|---|
| Tenant code | e.g. ACME_RIDES |
client_id and client_secret | The secret is shown once and is not recoverable. Put it in your secret manager immediately. |
| Subdomain | e.g. acme — used for host-based tenant resolution in browser flows |
We also seed your transaction-type allowlist. It is default-deny: a type that is not on it
returns 422, by design. Tell us which types you need.
Step 1 · Get a machine token
Standard OAuth2 client_credentials. Use whatever library you already have.
POST /oauth2/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=wallet/tenant.admin
{ "access_token": "eyJhbGciOiJFUzI1NiJ9...", "token_type": "Bearer", "expires_in": 899 }
The token already names your tenant:
{ "kind": "m2m", "pop": "TENANT_STAFF", "tenant": "ACME_RIDES",
"roles": ["TENANT_ADMIN"], "amr": ["client_secret"] }
Cache it. Fifteen minutes, re-request on expiry — not one per call. Verification is stateless, so that TTL is also our revocation horizon: if a client is disabled, its last token stops working within fifteen minutes and no new one is issued.
Step 2 · Register your user as a subject
A natural-key upsert on your identifier. Safe to replay — call it at signup and again whenever their details change.
PUT /subjects/drv-7001
Authorization: Bearer <machine token>
X-Tenant-Code: ACME_RIDES
Idempotency-Key: 9f2b1c44-...
{ "fullName": "Nurul Aina", "email": "nurul@example.com", "phone": "+60123456789" }
{
"status": "success",
"message": "Subject registered",
"data": {
"userId": "141a0cb5-fbcd-4427-b3e7-2f705e3dc0f3",
"externalUserRef": "drv-7001",
"identityMode": "SHADOW",
"status": "ACTIVE",
"contacts": [{ "channel": "EMAIL", "maskedDestination": "n•••••@example.com" }]
}
}
Three things worth noticing:
identityMode: SHADOW— you authenticate this person, we hold their ledger subject. That is the right mode when you have your own login.- Contacts come back masked, always. We hold the real value; you already have it.
userIdis ours. You need it once, in the next step. You do not have to persist it.
Raising a contact to verified
A contact you supply is TENANT_ASSERTED — we are taking your word. To make it
PLATFORM_VERIFIED we complete a round trip ourselves:
POST /otp/challenges
{ "externalUserRef": "drv-7001", "purpose": "CONTACT_VERIFY", "channel": "EMAIL" }
{ "challengeId": "b93285c8-...", "channel": "EMAIL",
"maskedDestination": "n•••••@example.com", "ttlSeconds": 300, "resendAfterSeconds": 60 }
Your screens collect the code; you relay it to
POST /otp/challenges/{challengeId}/verify with { "code": "123456" }.
destination field, and there never will beWe send to the contact we already hold for that subject. A compromised integration cannot redirect a code to itself — which is the entire reason the contact lives with us.
SMS returns 501 (pending MCMC sender-ID registration); email works. CONTACT_VERIFY is
currently the only purpose — money-out step-up purposes are designed but not shipped.
Step 3 · Wallets
Most integrations do nothing here. Your tenant is configured with the wallet types a new user receives, and Step 2 already opened them — registering the subject is the whole of signup. Read them back from the subject response, or:
GET /wallets?userId=141a0cb5-fbcd-4427-b3e7-2f705e3dc0f3
Ask us which types your tenant opens automatically; it is a per-tenant setting and more than one can be switched on. A user holds at most one wallet of each type, so replaying Step 2 never creates a duplicate.
Opening one yourself
Only needed for a type your tenant does not open automatically. CASH is opened for you at
step 2, so this call is for the others.
A wallet is unique per (user, type). Asking for a second CASH wallet does not replay and does not
return the existing one — it violates the constraint and surfaces as a 500. Check the step 2
response for what you already have before calling this.
POST /wallets
Idempotency-Key: <uuid>
{ "userId": "141a0cb5-fbcd-4427-b3e7-2f705e3dc0f3", "walletType": "CREDIT" }
{ "data": { "id": 2, "walletType": "CREDIT", "status": "ACTIVE", "balance": 0, "currency": "MYR",
"funds": [{ "id": 2, "fundType": "TRANSFERABLE", "balance": 0 }] } }
CASH is the earnings layer, CREDIT the obligation layer if your tenant has it enabled. Money
always sits in a fund bucket, never on the wallet itself — balance is the sum of the buckets.
This is the one call that takes our userId rather than your externalUserRef. Read it from the
step 2 response.
Step 4 · Move money in
You are the source — issuance
POST /issuances
Idempotency-Key: <uuid>
{ "type": "NET_EARNINGS", "walletId": 2, "amount": "250.00",
"fundType": "TRANSFERABLE", "referenceId": "trip-8842" }
{ "message": "Issuance posted",
"data": { "id": 1, "transactionType": "NET_EARNINGS", "status": "COMPLETED",
"amount": 250.0, "referenceId": "trip-8842" } }
| Direction | Endpoint | Types |
|---|---|---|
| Credit | POST /issuances | NET_EARNINGS, TIP, BONUS, REIMBURSEMENT, OFFLINE_TOP_UP |
| Debit | POST /deductions | CASH_TRANSACTION_DEDUCTION, CASH_TRANSACTION_REPLACEMENT |
Always send referenceId — your own identifier for the business event. It is how you reconcile
later via GET /transactions/by-reference, and it costs nothing to include now.
The wallet holder is the source — top-up
POST /wallets/{walletId}/top-up-link { "amount": "100.00" }
You get a payment link to hand the user. We credit the wallet only after the gateway confirms — never on the strength of your request.
Without them this returns 503 "No payment-gateway merchant configured for tenant N". Ask us to
configure it before you test this path.
Step 5 · Idempotency — what it actually protects
Replaying a key returns the original response, not a duplicate and not an error:
POST /issuances Idempotency-Key: abc-123 → 201, transaction id 1
POST /issuances Idempotency-Key: abc-123 → 201, transaction id 1 ← same transaction
Keys are namespaced per endpoint, so reusing one UUID across two different calls is safe.
Generate the key once per business event and reuse it on retry. A fresh key on every attempt defeats the mechanism completely: your network times out, you retry with a new key, and you have paid someone twice.
A duplicate still in flight returns 409 — wait and re-poll rather than retrying immediately.
Step 6 · Let your app act for a signed-in user
Your app must never hold your machine credential. Your backend mints a short-lived user token instead:
POST /auth/delegated-token
Authorization: Bearer <machine token>
{ "externalUserRef": "drv-7001" }
{ "data": { "accessToken": "eyJ...", "tokenType": "Bearer", "expiresIn": 900 } }
That token is that person:
{ "kind": "user", "pop": "SUBJECT", "tenant": "ACME_RIDES", "roles": ["WALLET_USER"],
"amr": ["tenant_delegated"], "act": { "sub": "cli_8a91e0e8..." } }
amr: ["tenant_delegated"] records that you authenticated this person, not us — that is what
answers a dispute years later, from the token alone. act names the client that asserted them.
Your app then calls /me/* with it, and every one resolves to that person automatically:
GET /me/top-ups GET /me/withdrawals GET /me/notifications
GET /me/withdrawal-accounts GET /me/wallet-types POST /me/withdrawals
Three rules:
- No refresh token. When it expires, mint another — one call. A refresh would let the session outlive your ability to re-assert the user, which is authority you have not given us.
- The subject must already exist (step 2). An unknown
externalUserRefreturns400, never a silently-created account. - Only a machine token can mint. Trying with a user token returns
403.
Step 7 · Move money between wallets
POST /transfers
{ "sourceWalletId": 2, "targetWalletId": 3, "amount": "120.00",
"sourceFundType": "TRANSFERABLE", "targetFundType": "TRANSFERABLE",
"referenceId": "settle-11" }
FUND_TRANSFER_OUT and FUND_TRANSFER_IN, sharing one idempotency key — that shared key is what
ties the legs together. Expect two rows when you reconcile; it is not a duplicate.
POST /transfers/peer is the holder-to-holder variant, with GET /transfers/lookup-recipient to
resolve and confirm a recipient before you render a confirmation screen.
Step 8 · Move money out
POST /me/withdrawal-accounts/enquiry → confirm the account holder's name first
POST /me/withdrawal-accounts → save the destination
POST /me/withdrawals → request the payout
Adding a payout destination is the operation an attacker actually wants. The enquiry returns the name the bank holds for that account, and it is stored on the destination alongside it.
The KYC record holds no verified name, so there is no automated match between who the account belongs to and who the wallet holder is — and a destination that fails verification can still receive a payout, because requiring a verified account is off by default today.
Show the returned account-holder name to the user and make them confirm it. That confirmation is currently the only check standing between a mistyped account number and a payout to a stranger.
Step-up on money-out — a delivered code or an in-app device approval. Both will satisfy the same gate and compose into the same field, so you will not need two code paths.
If you serve travelling or foreign users, plan for in-app approval: a code sent to a phone is exactly what a traveller does not reliably have.
Step 9 · Reconcile
Your database is not the record of truth for money. Ours is — and these let you prove the two agree.
| Call | Answers |
|---|---|
GET /transactions?walletId= | everything that happened to a wallet |
GET /transactions/by-reference?referenceId= | find our record from your identifier |
GET /transactions/{id}/detail | one transaction with its ledger entries |
GET /ledger/entries?walletId= | the raw double-entry log |
GET /wallets/{id}/balance | current balance |
Run a nightly job that walks your own money events and confirms each has a matching transaction by
referenceId. A discrepancy found next morning is an incident; the same one found six months later
is an investigation.
What is not built yet
| Area | Status |
|---|---|
| SMS delivery | 501 — pending MCMC sender-ID registration. Email works. |
OTP purposes beyond CONTACT_VERIFY | not shipped |
| Money-out step-up | designed, not shipped |
| Outbound webhooks to your endpoint | not shipped — poll for now |
| Automated eKYC | not shipped; evidence capture exists, adjudication is ours |
| Top-up | needs gateway credentials, else 503 |
Errors
| Status | Means | Do |
|---|---|---|
400 | malformed, or unknown externalUserRef | fix the call; do not retry |
401 | token missing, expired, or client disabled | re-request a token once |
403 | wrong token type | usually a user token where a machine token is required |
409 | a request with this key is in flight | wait and re-poll — do not retry with a new key |
422 | policy refused it | read detail — usually an allowlist or balance floor |
501 | not built yet | see the table above |
503 | dependency unavailable | retry with backoff |
Every error is an RFC 7807 problem document. Log detail and instance — they are written to be
read by a human at 3am.
The whole thing, as a script
BASE=https://api-wallet.dev.gohubpay.com.my/api/v1/wallet
# 1 · machine token
TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials&scope=wallet/tenant.admin" \
$BASE/oauth2/token | jq -r .access_token)
# 2 · register your user
curl -X PUT $BASE/subjects/drv-7001 \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $(uuidgen)" \
-d '{"fullName":"Nurul Aina","email":"nurul@example.com"}'
# 3 · open a wallet → id 2
# 4 · credit it → transaction 1, COMPLETED
curl -X POST $BASE/issuances \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $(uuidgen)" \
-d '{"type":"NET_EARNINGS","walletId":2,"amount":"250.00",
"fundType":"TRANSFERABLE","referenceId":"trip-8842"}'
# 5 · confirm → 250.0000
curl $BASE/wallets/2/balance -H "Authorization: Bearer $TOKEN"
# 6 · act for your signed-in user
DT=$(curl -s -X POST $BASE/auth/delegated-token -H "Authorization: Bearer $TOKEN" \
-d '{"externalUserRef":"drv-7001"}' | jq -r .data.accessToken)
curl $BASE/me/top-ups -H "Authorization: Bearer $DT" # 200
Next: How the integration works if you are still deciding how much of the wallet you host, or go straight to the API reference.