Skip to content

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.

EndpointHeaderNotes
/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

FieldTypeRequiredDescription
transactions array Yes Array of 1 to 100 transaction records

Transaction Object

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

KeyDescription
collection_typeType of collection, e.g. zakat_fitrah, zakat_harta, donation. Required for auto-classification.
collection_stateState associated with the collection, e.g. selangor, kuala lumpur. Required for auto-classification.
ic_noPayer 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_idGrab's internal order / transaction reference
grab_merchant_idGrab merchant identifier
phonePayer phone number
address, postcode, cityPayer 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

CodeCollection body
GRABGeneric / 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 proposedLembaga Zakat Selangor via GrabPay
GRABPPZ proposedPusat Pungutan Zakat-MAIWP via GrabPay
GRABFLZS proposedLZS -- zakat fitrah (Selangor)
GRABFPPZ proposedPPZ -- 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

ValueMeaning
success / SUCCESSPayment completed successfully
failed / FAILED / failure / FAILUREPayment 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 statusBodyMeaning
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

  1. Collection scope -- which collection bodies Grab will route (zakat, donations, bill payments).
  2. Authentication -- a shared-secret header over TLS, plus an IP allowlist, is acceptable to Tulus; HMAC signing is Grab's choice.
  3. Status strings -- confirm GrabPay's values map onto success / failed.
  4. Test signal -- Tulus proposes the TEST- order prefix (above).
  5. Extra-data names -- confirm or substitute the data keys above.
  6. IP allowlisting -- Grab's egress IP ranges.
  7. 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