ADB Payment Webhook API Documentation
Overview
AEON Bank (ADB) sends Tulus the zakat payments its customers make in the AEON Bank app, through one webhook. Tulus files each payment under the right zakat board and shows it in the board's reports.
Each call carries a JSON object with an array of transactions. Every transaction is handled on its own: in a call of three, two can be saved and one not.
Updated 12 November 2026 to match how the server actually behaves. If you built against the earlier version of this page, note in particular:
- You do not choose the agency. Tulus files every payment itself from the order number and
data; theagencyfield is ignored (see How Tulus files a payment). - A wrong or missing secret answers 400, not 401.
- There is no schema validation, so most mistakes are accepted with 200. Treat only
{"status":"ok"}as accepted, and check the payment appears in the reports (see Response Format). - Sending the same
ordernoagain with a different status or amount replaces the stored payment.
Endpoint Details
| Staging (testing) | Production (live) | |
|---|---|---|
| Webhook | POST https://staging.tulus.my/api/adb/webhook | POST https://pay.tulus.my/api/adb/webhook |
| Sync (credentials check) | POST https://staging.tulus.my/api/adb/sync | POST https://pay.tulus.my/api/adb/sync |
| Reports | reports-staging.tulus.my | reports.tulus.my |
Every request needs Content-Type: application/json. Each environment has its own two secrets and its own reports login; Tulus sends them to you separately, never in a document. Test on Staging first. Staging's data is never settled.
Authentication
| Endpoint | Header | Secret |
|---|---|---|
| Webhook | X-API-Password (X-Notification-Secret is also accepted) | Webhook secret |
| Sync | X-Notification-Secret only | Sync secret, a different value from the webhook's |
A wrong or missing secret, or the wrong header for the endpoint, answers 400 {"status":"invalid secret"}. The sync endpoint only checks the secret and stores nothing, so it is a safe way to confirm your credentials:
curl -s -w ' %{http_code}\n' -X POST https://staging.tulus.my/api/adb/sync \
-H "Content-Type: application/json" \
-H "X-Notification-Secret: YOUR_SYNC_SECRET" -d '{}'
# {"status":"ok"} 200
Request Body Schema
The body is {"transactions": [ ... ]}. Field names are matched without regard to case; order_no (with an underscore) is not the same as orderno and is ignored.
Transaction Object
| Field | Type | Required | Description |
|---|---|---|---|
orderno | string | Yes | Your unique order number. It identifies the payment: sending it again updates the same payment. It can also decide the zakat board (see below). Without it the payment is not stored, although the answer is still 200. |
amount | number or numeric string | Yes | Amount in RM, stored as sent. Without it the payment is not stored, although the answer is still 200. |
status | string | Yes | success or failed, in lower case (SUCCESS, failure and FAILURE are also understood). Any other value is stored as pending, including pending and mixed case such as Success or Failed. |
time | string | Yes | When the payment was made, ISO 8601 with the Malaysia offset, e.g. 2026-11-12T18:02:00+08:00 (seconds optional). Always send +08:00; other offsets are not supported. A missing or unreadable time is not refused; the time Tulus received the call is used instead. |
name | string | Yes | Payer's name, as shown to the zakat board. |
email | string | Yes | Payer's email. |
data | object | Yes | Zakat details (see Data Object). zakat_type and zakat_state decide where the payment is filed. |
category | string | No | Stored as the product description, e.g. zakat. |
agency | string | No | Ignored. Send ADB. Tulus files the payment itself; sending ADBLZS or ADBPPZ here changes nothing. |
Data Object
Stored with the payment and shown to the zakat board. Tulus reads three things from it: the zakat type, the state, and the payer's IC.
| Field | Type | Description |
|---|---|---|
zakat_type | string | Decides fitrah or not: a value containing fitrah (e.g. fitrah, zakat_fitrah) is zakat fitrah; anything else is another zakat (harta). Values in use are listed below. |
zakat_state | string | Used to choose Selangor or Wilayah Persekutuan when the order number does not name the board (see below). |
zakat_ic_no | string | Payer's MyKad number, 12 digits. A payment without an IC is still stored, with a missing-IC warning for the board. |
zakat_name | string | Name for the zakat payment. |
email | string | Email for the zakat payment. |
zakat_year | string | Year of the zakat, 4 digits, e.g. "2026". |
zakat_phone | string | Phone number, digits only. |
zakat_address, zakat_postcode, zakat_city | string | Payer's address. |
zakat_amount | string | Zakat amount, e.g. "14.00". |
zakat_rate, zakat_dependant | string | Zakat fitrah only: rice price per person and number of people (tanggungan). amount should equal rate × dependants. |
zakat_type values in use: fitrah, zakat_fitrah, zakat_emas, zakat_kwsp, zakat_padi, zakat_pendapatan, zakat_perak, zakat_perniagaan, zakat_pertanian, zakat_qada, zakat_rikaz, zakat_saham, zakat_simpanan, zakat_ternakan.
zakat_state values in use: johor, kedah, kelantan, melaka, negeri_sembilan, pahang, pulau_pinang, perak, perlis, sabah, sarawak, selangor, terengganu, kuala_lumpur, labuan, putrajaya.
How Tulus files a payment
First match wins:
- Fitrah or not:
data.zakat_typecontainingfitrah. - Board:
ADBPNGorADBJHRanywhere in theorderno(any case): Pulau Pinang or Johor. This is the only way to file a payment under Pulau Pinang or Johor;zakat_stateis not used for them.LZSorPPZanywhere in theorderno: Selangor or Wilayah Persekutuan.data.zakat_state:selangor; orkuala_lumpur,putrajaya,labuan(Wilayah Persekutuan).- None of these: the payment is held under
ADBfor Tulus to review.
| Board | Zakat fitrah | Other zakat |
|---|---|---|
| Selangor (LZS) | ADBFLZS | ADBLZS |
| Wilayah Persekutuan (PPZ) | ADBFPPZ | ADBPPZ |
| Pulau Pinang | ADBFPNG | ADBPNG |
| Johor | ADBFJHR | ADBJHR |
Put the board the payer chose into the orderno (ADBLZS, ADBPPZ, ADBPNG, ADBJHR). zakat_state is the payer's home state, which can differ from the board they pay: a Selangor resident paying Pulau Pinang must be filed under Pulau Pinang. Matching is by substring, so do not let those letters appear in an order number by accident.
Status and updates
- The
ordernoidentifies the payment. Sending it again with the same status and amount changes nothing, so retrying is safe. - Sending it again with a different status or amount replaces the stored payment. Use this to report a pending payment's final result; avoid it otherwise, since even a success can be overwritten.
Example Request
The examples use Staging and made-up payers. For Production, use https://pay.tulus.my and your Production secret.
Single Transaction (other zakat, Selangor)
curl -s -w ' %{http_code}\n' -X POST https://staging.tulus.my/api/adb/webhook \
-H "Content-Type: application/json" \
-H "X-API-Password: YOUR_WEBHOOK_SECRET" \
-d '{
"transactions": [
{
"agency": "ADB",
"orderno": "TEST-ADBLZS-0001",
"amount": 112.34,
"name": "Ujian Pembayar",
"email": "[email protected]",
"time": "2026-11-12T10:10:10+08:00",
"status": "success",
"category": "zakat",
"data": {
"zakat_type": "zakat_pendapatan",
"zakat_state": "selangor",
"zakat_ic_no": "000000000001",
"zakat_name": "Ujian Pembayar",
"zakat_year": "2026",
"zakat_amount": "112.34"
}
}
]
}'
# {"status":"ok"} 200 -> filed under ADBLZS
Multiple Transactions (fitrah to PPZ, and Pulau Pinang)
curl -s -w ' %{http_code}\n' -X POST https://staging.tulus.my/api/adb/webhook \
-H "Content-Type: application/json" \
-H "X-API-Password: YOUR_WEBHOOK_SECRET" \
-d '{
"transactions": [
{
"agency": "ADB",
"orderno": "TEST-ADBPPZ-0002",
"amount": 14,
"name": "Ujian Pembayar Dua",
"email": "[email protected]",
"time": "2026-11-12T11:10:11+08:00",
"status": "success",
"category": "zakat",
"data": {
"zakat_type": "zakat_fitrah",
"zakat_state": "kuala_lumpur",
"zakat_ic_no": "000000000002",
"zakat_rate": "7",
"zakat_dependant": "2",
"zakat_amount": "14.00"
}
},
{
"agency": "ADB",
"orderno": "TEST-ADBPNG-0003",
"amount": 12.34,
"name": "Ujian Pembayar Tiga",
"email": "[email protected]",
"time": "2026-11-12T11:15:30+08:00",
"status": "success",
"category": "zakat",
"data": {
"zakat_type": "zakat_simpanan",
"zakat_state": "selangor",
"zakat_ic_no": "000000000003"
}
}
]
}'
# {"status":"ok"} 200 -> ADBFPPZ and ADBPNG (the order number beats the Selangor state)
Pending, then the final result
# 1. pending
... "orderno": "TEST-ADBLZS-0004", "status": "pending", ...
# 2. later, the same orderno with the result: replaces the pending payment
... "orderno": "TEST-ADBLZS-0004", "status": "success", ...
Response Format
| HTTP | Body | Meaning | What to do |
|---|---|---|---|
| 200 | {"status":"ok"} | Every transaction was passed on to the reports. | Done. A transaction missing orderno or amount also gets this answer but is not stored, so always send both. |
| 200 | Error saving session (plain text, not JSON) | The body could not be read (bad JSON, missing Content-Type, empty transactions), or at least one transaction could not be saved. | Check the body, not just the status. Fix the request if it was malformed; otherwise retry later with the same body (safe, see above). For a well-formed body Tulus also keeps a copy and retries within about 10 minutes. |
| 400 | {"status":"invalid secret"} | Wrong or missing secret, or the wrong header for this endpoint. | Check the header name and which secret you used. |
There is no 401, no 409, and no 400 for a bad payload.
Important Notes
- Send the board in the order number, especially for Pulau Pinang and Johor (see above).
- Unique order numbers: reusing an
ordernofor a different payment replaces the first one. - Status: lower case.
SuccessorFailedwith a capital letter is stored as pending. - Time: send
+08:00; a missing or unreadable time becomes the time Tulus received the call. - Amounts: there is no minimum and no decimal-place check; what you send is what the board sees.
- No validation: email, IC, phone and postcode formats are not checked. Wrong values are stored as sent and reach the zakat board.
Testing
- On Staging, start every
ordernowithTEST-and use made-up payers (example.testemails, all-zero ICs as above).TEST-rows are highlighted in the reports and no receipt email is sent for them. - On Production, a live test must not be mixed with real collections: use a payer email at
@aeonbank.com.mywhose name containstest(e.g.[email protected]). Tulus files it under a separate test code (X+ the board's code). Make test amounts end in12.34so anyone reconciling can spot them. - Check each test payment in the reports with the login Tulus gave you: set the date range to the day you sent it and look for the order number.
Support
For technical support or credentials, contact the Tulus development team at [email protected].
Schema Reference
A JSON schema describing what to send is at https://developers.tulus.my/schema/adb.json. It documents the contract; the server does not validate requests against it.