Skip to main content

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.

Verified, not aspirational

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:

  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.
  2. 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.
  3. 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 receiveNotes
Tenant codee.g. ACME_RIDES
client_id and client_secretThe secret is shown once and is not recoverable. Put it in your secret manager immediately.
Subdomaine.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.
  • userId is 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" }.

There is no destination field, and there never will be

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

Not built yet

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.

Do not call this for a type that was auto-opened

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" } }
DirectionEndpointTypes
CreditPOST /issuancesNET_EARNINGS, TIP, BONUS, REIMBURSEMENT, OFFLINE_TOP_UP
DebitPOST /deductionsCASH_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.

Needs gateway credentials

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.

The failure mode to design against

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 externalUserRef returns 400, 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" }
A transfer writes two transactions

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.

Nothing compares that name to the customer's identity

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.

Designed, not yet shipped

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.

CallAnswers
GET /transactions?walletId=everything that happened to a wallet
GET /transactions/by-reference?referenceId=find our record from your identifier
GET /transactions/{id}/detailone transaction with its ledger entries
GET /ledger/entries?walletId=the raw double-entry log
GET /wallets/{id}/balancecurrent 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

AreaStatus
SMS delivery501 — pending MCMC sender-ID registration. Email works.
OTP purposes beyond CONTACT_VERIFYnot shipped
Money-out step-updesigned, not shipped
Outbound webhooks to your endpointnot shipped — poll for now
Automated eKYCnot shipped; evidence capture exists, adjudication is ours
Top-upneeds gateway credentials, else 503

Errors

StatusMeansDo
400malformed, or unknown externalUserReffix the call; do not retry
401token missing, expired, or client disabledre-request a token once
403wrong token typeusually a user token where a machine token is required
409a request with this key is in flightwait and re-poll — do not retry with a new key
422policy refused itread detail — usually an allowlist or balance floor
501not built yetsee the table above
503dependency unavailableretry 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.