Grab Payment Webhook API Documentation
Draft v0.1. This integration follows the Grab × Tulus integration proposal. Field names and agency codes may change until both parties sign off. Test against the Tulus staging environment; production secrets are issued only after sign-off, and until then every production call receives 400 {"status":"invalid secret"}.
Overview
This is a server-to-server webhook between Grab Financial Group (GrabPay) and the Tulus Payment Gateway. When a payer completes a payment through GrabPay, Grab sends a real-time notification to Tulus, which validates, records and confirms the transaction.
The webhook accepts a JSON object containing an array of transactions. Multiple transactions may be batched in a single call. A lightweight sync endpoint lets Grab verify connectivity and credentials without submitting transaction data.
Scope TBD: which collection types Grab will route through this integration (zakat, donations, bill payments, or a combination) is still to be confirmed. Agency codes and the data field schema will be finalised from that.
Endpoint Details
Staging webhook: POST https://staging.tulus.my/api/grab/webhook
Staging sync check: POST https://staging.tulus.my/api/grab/sync
Production webhook (after sign-off): POST https://pay.tulus.my/api/grab/webhook
Production sync check (after sign-off): POST https://pay.tulus.my/api/grab/sync
Content-Type: application/json
Authentication: shared secret in an HTTP header (see below)
Authentication
Each request must carry a shared secret in an HTTP header. Tulus provisions the secrets during onboarding; the webhook and the sync endpoint use different secrets.
| Endpoint | Header | Notes |
|---|---|---|
/api/grab/webhook |
X-API-Password |
Also accepted in X-Notification-Secret if X-API-Password is absent |
/api/grab/sync |
X-API-Password |
Also accepted in X-Notification-Secret if X-API-Password is absent. Uses its own secret, not the webhook's. |
A missing or incorrect secret receives 400 Bad Request with body {"status":"invalid secret"}. Secrets must be rotated through Tulus if compromised; never embed them in client-side code.
TBD: Grab may prefer a different mechanism (HMAC request signing, OAuth 2.0 client credentials). Please confirm Grab's outbound webhook signing standard.
Request Body Schema
Root Object
| Field | Type | Required | Description |
|---|---|---|---|
transactions |
array | Yes | Array of 1 to 100 transaction records |
Transaction Object
| Field | Type | Required | Description |
|---|---|---|---|
agency |
string | Yes | Agency code identifying the collection body. See Agency Codes. |
orderno |
string | Yes | Unique order reference, e.g. GRAB-20250901-00123. At most 255 characters (64 or fewer, ASCII, recommended). Tulus de-duplicates on orderno alone, so it must be unique across all Grab collections and identical on every retry. |
amount |
number / string | Yes | Amount in Ringgit Malaysia (MYR only), e.g. 22.11 or "22.11". Minimum 1.00 for every status, at most 2 decimal places, no exponent notation. |
name |
string | Yes | Payer's full name, at most 255 characters |
email |
string | Yes | Payer's email address, at most 255 characters. Recorded with the transaction; Tulus does not email the payer a separate receipt (GrabPay's receipt is the payer's record). |
time |
string | Yes | Transaction timestamp, ISO 8601 with timezone offset. See Date & Time Format. |
status |
string | Yes | success or failed. See Status Values. |
category |
string | No | Payment category, e.g. zakat, donation, bill_payment. At most 255 characters. |
data |
object | Yes | Collection-specific details. See Extra Data. |
name, email and data.ic_no are required. If the payer's GrabPay profile does not hold one of them, collect it from the payer before payment.
Extra Data (data object)
Tulus uses these fields for reporting, compliance and auto-classification. Keys are matched case-insensitively with underscores optional.
| Key | Description |
|---|---|
collection_type | Type of collection, e.g. zakat_fitrah, zakat_harta, donation. Required for auto-classification. |
collection_state | State associated with the collection, e.g. selangor, kuala lumpur. Required for auto-classification. |
ic_no | Payer IC number (MyKad / NRIC), as a string of at most 20 characters. Required: the zakat bodies need it for the tax-rebate receipt. |
grab_order_id | Grab's internal order / transaction reference |
grab_merchant_id | Grab merchant identifier |
phone | Payer phone number |
address, postcode, city | Payer address |
A transaction without data.ic_no, or whose data object is larger than 50,000 bytes as JSON, is rejected with 400 {"status":"invalid request"} and nothing in the batch is stored. All length limits on this page count characters.
TBD: these key names are a starting proposal. Tulus can adapt the mapping to whatever Grab's platform naturally produces.
Agency Codes
| Code | Collection body |
|---|---|
GRAB | Generic / Grab-routed. Tulus auto-classifies into a sub-code from collection_type and collection_state. Use this when the collection body is decided at runtime. |
GRABLZS proposed | Lembaga Zakat Selangor via GrabPay |
GRABPPZ proposed | Pusat Pungutan Zakat-MAIWP via GrabPay |
GRABFLZS proposed | LZS -- zakat fitrah (Selangor) |
GRABFPPZ proposed | PPZ -- zakat fitrah (Wilayah Persekutuan) |
With agency = "GRAB" you do not need to compute the mapping yourself. A GRAB transaction whose data does not identify a collection body is kept as GRAB for manual review.
Test transactions
Start the orderno with TEST- (for example TEST-GRAB-20261110-0001) to mark a test payment. It keeps its agency code, is never settled to the collection body, and is hidden from the collection body's reports; Tulus staff still see it, highlighted. No extra field is needed.
On staging every payment is a test and nothing is settled, so the prefix is not needed there; use realistic order numbers. Send synthetic payer names, emails and IC numbers only: staging is visible to Tulus staff and testers.
To confirm with Grab: the TEST- order prefix as the production test signal.
Transaction Status Values
| Value | Meaning |
|---|---|
success / SUCCESS | Payment completed successfully |
failed / FAILED / failure / FAILURE | Payment failed or was declined. Statuses are matched in any letter case. |
TBD: confirm the exact status strings GrabPay's outbound webhook emits so Tulus can map them.
Date & Time Format
The time field must be ISO 8601 with a timezone offset. Both forms are accepted:
2025-09-01T10:00+08:00(minutes precision)2025-09-01T10:00:00+08:00(RFC 3339)
Malaysia time is UTC+8; UTC timestamps passed with +00:00 are converted by Tulus. Epoch milliseconds are not accepted.
Maximum age: 90 days. A transaction whose time is more than 90 days ago is rejected with 400 {"status":"invalid request"}, whether it is new, a retry or a status update. Finish retries for an event within 90 days of it.
Example Requests
The examples post to staging. Replace time with the current time: a transaction more than 90 days old is rejected.
Successful payment -- auto-classified
curl -X POST https://staging.tulus.my/api/grab/webhook \
-H "Content-Type: application/json" \
-H "X-API-Password: YOUR_API_PASSWORD_HERE" \
-d '{
"transactions": [
{
"agency": "GRAB",
"orderno": "GRAB-20261110-00123",
"amount": "7.00",
"name": "Ahmad bin Ali",
"email": "[email protected]",
"time": "2026-11-10T09:45:00+08:00",
"status": "success",
"category": "zakat",
"data": {
"collection_type": "zakat_fitrah",
"collection_state": "selangor",
"ic_no": "800101011234",
"phone": "0123456789",
"grab_order_id": "G-TXN-2026111000123"
}
}
]
}'
Failed payment -- explicit agency code
curl -X POST https://staging.tulus.my/api/grab/webhook \
-H "Content-Type: application/json" \
-H "X-API-Password: YOUR_API_PASSWORD_HERE" \
-d '{
"transactions": [
{
"agency": "GRABLZS",
"orderno": "GRAB-20261110-00124",
"amount": "500.00",
"name": "Siti binti Mohd",
"email": "[email protected]",
"time": "2026-11-10T10:00:00+08:00",
"status": "failed",
"data": {
"ic_no": "850505105678"
}
}
]
}'
Connectivity check
curl -X POST https://staging.tulus.my/api/grab/sync \
-H "Content-Type: application/json" \
-H "X-Notification-Secret: YOUR_SYNC_SECRET_HERE" \
-d '{}'
Response & Error Handling
| HTTP status | Body | Meaning |
|---|---|---|
200 OK | {"status":"ok"} | Every transaction in the batch was durably recorded |
400 Bad Request | {"status":"invalid secret"} | Missing or incorrect secret -- do not retry until the secret is verified or rotated |
400 Bad Request | {"status":"invalid request"} | Malformed JSON, empty transactions, or a transaction failing validation -- fix the payload |
405 Method Not Allowed | {"status":"method not allowed"} | Not a POST -- final |
409 Conflict | {"status":"conflict"} | The batch tried to change the status or amount of a transaction already recorded as success. A recorded success is final. Every other transaction in the batch was saved -- do not retry; contact Tulus operations |
500 Internal Server Error | {"status":"error"} | The call could not be recorded -- retry |
502 Bad Gateway | {"status":"error"} | Recorded by Tulus but not yet saved to the ledger; some transactions in the batch may already be saved. Tulus completes the rest from its own copy within about 10 minutes; retrying the whole batch is optional and safe |
Every 400, 405 and 409 is final: resending the same payload gets the same answer. Only 500, 502 and timeouts should be retried. A 400 invalid request rejects the whole batch and stores nothing.
Tulus returns 200 OK only once every transaction has been durably recorded. Retrying is safe: Tulus de-duplicates by orderno, and a resend with the same status and amount returns 200 without changing anything. A later status change for the same orderno (e.g. failed then success) replaces the record, except that a success can never be changed. There are no refunds or reversals.
Timeouts and retries. Use a 30-second client timeout and exponential back-off starting at 5 s. A 502 or a timeout is completed on the Tulus side within about 10 minutes, so aggressive retrying is not needed. There is no rate limit on the webhook.
Batch size. At most 100 transactions per call, and a body of at most 1 MiB. A call with more is rejected with 400 {"status":"invalid request"} and nothing is stored: send the rest as further calls. One call per payment event is recommended. The cap keeps every call well inside the 30-second timeout, so a timeout means a real failure worth retrying.
Open Items
- Collection scope -- which collection bodies Grab will route (zakat, donations, bill payments).
- Authentication -- a shared-secret header over TLS, plus an IP allowlist, is acceptable to Tulus; HMAC signing is Grab's choice.
- Status strings -- confirm GrabPay's values map onto
success/failed. - Test signal -- Tulus proposes the
TEST-order prefix (above). - Extra-data names -- confirm or substitute the
datakeys above. - IP allowlisting -- Grab's egress IP ranges.
- Dashboard access -- the email addresses that need Grab's Tulus dashboard login.
Settlement and Reconciliation
- Grab pays the collected funds into a GrabPay account controlled by Tulus. Tulus pays Lembaga Zakat Selangor and Pusat Pungutan Zakat-MAIWP.
- Grab gets a Tulus dashboard login scoped to the Grab agency codes, showing every recorded Grab transaction with its status, amount and payer details.
- There are no refunds or reversals of a recorded transaction.
Support
For technical support or staging credentials, contact the Tulus development team at [email protected]
Go-live contact: Shahril, [email protected]. Escalations, including any 409 or outage: [email protected].
Schema Reference
The JSON schema for the webhook body is available at:
https://developers.tulus.my/schema/grab.json