Merchant Reports API
The ERP reports API: transactions, the daily balance statement, refunds and the report.daily_summary webhook.
Generated from merchant-reports-api.md
Machine access to the reports of the merchant dashboard: pay-in history, the daily balance statement, one day's movements, refunds, and the daily summary. It uses the same API key and HMAC signature as the payment API, so an ERP never needs a dashboard login.
A companion webhook, report.daily_summary, pushes each finished day's summary to your webhook URL.
No fee figures. No response, file or webhook contains a fee, fee rate, MDR or gross/net split. Every amount is the net effect on your balance.
#Hosts
| Environment | Base URL | Data |
|---|---|---|
| Production | https://api.<domain>/v1/merchant-reports/ | production data |
| Sandbox | https://sandbox-api.<domain>/v1/merchant-reports/ | sandbox data |
Your API key decides the merchant and the data set. A merchant_id in the query string is ignored.
#Authentication
Every request carries the same three headers as the payment API:
| Header | Value |
|---|---|
X-API-Key | your API key |
X-Timestamp | Unix time in seconds; it must be within 5 minutes of the server clock |
X-Signature | lowercase hex of HMAC-SHA256(secret_key, METHOD + "|" + PATH + "|" + X-Timestamp) |
PATHis the URL path only, without the host and without the query string:/v1/merchant-reports/statement.- A GET has no body, so there is no trailing
|. With a body, the payment API appends"|" + body. - Your whitelisted IPs apply here too.
Example. Here is a signed request for GET /v1/merchant-reports/daily-summary?date=2026-09-18 at X-Timestamp: 1789776900 with the secret sk_test_example:
string to sign : GET|/v1/merchant-reports/daily-summary|1789776900
X-Signature : b80b3ebf9bd996732d1cd7b65805446ceb57f775b4bed4b9c24ccf35fd61bfeeTS=$(date +%s); P=/v1/merchant-reports/daily-summary
SIG=$(printf 'GET|%s|%s' "$P" "$TS" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -s "https://api.<domain>$P?date=2026-09-18" -H "X-API-Key: $API_KEY" -H "X-Timestamp: $TS" -H "X-Signature: $SIG"Authentication errors. These return 401: MISSING_API_KEY, MISSING_SIGNATURE, MISSING_TIMESTAMP, INVALID_TIMESTAMP, TIMESTAMP_EXPIRED, INVALID_API_KEY and INVALID_SIGNATURE. These return 403: MERCHANT_INACTIVE and IP_NOT_WHITELISTED.
#Rate limit
Each merchant gets 60 requests per minute on /v1/merchant-reports/*. This bucket is separate from the payment API, so pulling reports never slows your payments.
When you go over the limit you get 429 RATE_LIMIT_EXCEEDED with Retry-After. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
#Conventions
- Dates:
from,toanddateareYYYY-MM-DDcalendar days in Asia/Bangkok. Ranges include both ends and can span at most 366 days. - Times: JSON timestamps are RFC 3339 in Asia/Bangkok, for example
2026-09-19T15:05:00+07:00. They are ordinary ISO-8601 instants — any standards-compliant parser reads them unchanged; only the offset differs from theZform used before 2026-09-20. - Money: JSON numbers with 2 decimals, in THB.
- Paging:
pagedefaults to 1.page_sizedefaults to 50 and is capped at 200. format:jsonis the default.csvis UTF-8 with a BOM. Times are BangkokYYYY-MM-DD HH:MM:SS.xlsxis an Excel workbook. Money cells are numeric (#,##0.00). Times are real Bangkok date-time cells, and dates are date cells.- CSV and XLSX contain every matching row, not one page. More than 100,000 rows returns 413
TOO_MANY_ROWS, so narrow the range. Files come back withContent-Disposition: attachment; filename="<kind>_<from>_<to>.<ext>".
- Errors:
{"code": "...", "message": "..."}. The codes areINVALID_RANGE,INVALID_PARAM,TOO_MANY_ROWS, the authentication codes above, andUPSTREAM_UNAVAILABLE(502, safe to retry).
#Endpoints
#GET /v1/merchant-reports/daily-summary?date=YYYY-MM-DD
This endpoint returns one finished Bangkok day. date must be before today in Bangkok, otherwise you get 400 INVALID_RANGE.
The six kinds are always present. Their amounts are signed net balance effects: pay-ins are positive, and payouts and settlements are negative. They always reconcile: Σ kinds.amount = closing_balance − opening_balance, and credits − debits = closing_balance − opening_balance.
{
"merchant_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "date": "2026-09-18",
"timezone": "Asia/Bangkok", "currency": "THB",
"opening_balance": 1000.00, "closing_balance": 920.33, "credits": 1785.00, "debits": 1864.67,
"kinds": {
"payin": { "count": 2, "amount": 1485.00 },
"payout": { "count": 1, "amount": -300.00 },
"settlement": { "count": 1, "amount": -1010.00 },
"withdraw_returned": { "count": 1, "amount": 300.00 },
"payin_reversed": { "count": 1, "amount": -500.00 },
"adjustment": { "count": 2, "amount": -54.67 }
},
"generated_at": "2026-09-19T00:15:02+07:00"
}A day without movements returns count 0 and amount 0 for every kind. On that day the opening and closing balances are both the last balance before the day.
format=csv|xlsx returns one row, named daily_summary_<date>.csv or daily_summary_<date>.xlsx. The columns are:
- Date, Opening balance, Money in, Money out
- a count column and an amount column for each of: Pay-ins, Payouts, Settlements, Withdrawals returned, Pay-ins reversed, Adjustments
- Closing balance
#GET /v1/merchant-reports/transactions?from&to[&status&q&page&page_size&format]
This endpoint returns pay-ins created in the range, newest first.
statusis one of pending, processing, pending_approval, completed, failed, expired, refunded, cancelled.qmatches a transaction id prefix, or part of your order id, the external reference or the payer name.
{
"data": [{
"id": "4f1c…", "order_id": "ORD-1", "amount": 1250.00, "status": "completed", "channel": "promptpay",
"payer_name": "Somchai", "payer_bank_code": "004", "payer_account_last4": "7890",
"created_at": "2026-09-19T15:05:00+07:00", "paid_at": "2026-09-19T15:06:00+07:00"
}],
"summary": { "total_count": 1, "completed_count": 1, "completed_amount": 1250.00, "pending_count": 0,
"failed_count": 0, "expired_count": 0, "refunded_count": 0,
"by_status": { "completed": 1, "pending": 0, "processing": 0, "pending_approval": 0,
"failed": 0, "expired": 0, "refunded": 0, "cancelled": 0 } },
"pagination": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 }
}summary covers the range and q but ignores status.
The CSV and XLSX columns are: Created at, Transaction ID, Order ID, Amount, Status, Channel, Payer name, Payer bank, Payer account (last 4), Paid at.
#GET /v1/merchant-reports/statement?from&to[&format]
This endpoint returns the daily balance statement: one row per Bangkok day that has movements, oldest first.
{
"days": [{ "date": "2026-09-18", "opening_balance": 1000.00, "credits": 1785.00, "debits": 1864.67,
"closing_balance": 920.33, "settlement_count": 1, "settlement_amount": 1010.00, "entry_count": 8 }],
"totals": { "opening_balance": 1000.00, "credits": 1785.00, "debits": 1864.67, "closing_balance": 920.33,
"settlement_count": 1, "settlement_amount": 1010.00 }
}settlement_amount is positive here, while in the daily summary the settlement amount is signed.
The CSV and XLSX columns are: Date, Opening balance, Money in, Money out, Settlements (count), Settlements (amount), Closing balance.
#GET /v1/merchant-reports/statement/entries?date[&format]
This endpoint returns one day's movements, oldest first. The kind values are payin, payout, settlement, withdraw_returned, payin_reversed and adjustment. reference is your order id for a pay-in or payout, the WDR… number for a settlement, and null otherwise.
{
"date": "2026-09-18", "opening_balance": 1000.00, "closing_balance": 920.33,
"entries": [{ "transaction_id": "…", "kind": "payin", "reference": "ORD-1", "amount": 985.00,
"balance_after": 1985.00, "created_at": "2026-09-18T00:10:00+07:00" }]
}The CSV and XLSX columns are: Time, Type, Reference, Transaction ID, Amount, Balance after. The XLSX file is named statement_entries_<date>.xlsx, and the CSV keeps statement_entries_<date>_<date>.csv.
#GET /v1/merchant-reports/refunds?from&to[&q&page&page_size&format]
This endpoint returns refunded pay-ins whose refund falls in the range, newest refund first.
{
"data": [{ "id": "…", "order_id": "ORD-2", "amount": 500.00, "reason": "customer request",
"paid_at": "2026-09-18T08:00:00+07:00", "refunded_at": "2026-09-18T10:00:00+07:00" }],
"summary": { "count": 1, "amount": 500.00 },
"pagination": { "page": 1, "page_size": 50, "total": 1, "total_pages": 1 }
}The CSV and XLSX columns are: Refunded at, Transaction ID, Order ID, Amount, Reason, Paid at.
#Webhook report.daily_summary
#Subscribing
Subscribe in the merchant dashboard under Settings › API, which needs a configured webhook URL. You can also add report.daily_summary to the events of your webhook configuration. The test button re-sends a chosen day at once, at most 5 times per minute. A test event's id ends in :test:<unix>.
#When it is sent
The summary is sent once per Bangkok day, for the previous day, at about 00:15 Asia/Bangkok. It is sent even when the day had no movements, so "no activity" is distinguishable from "not delivered".
If a send was missed, for example during maintenance, any of the last 3 days that was not sent yet goes out on the next run. The data set is your merchant's current environment.
#Delivery
Delivery uses the same mechanism as the transaction webhooks: an HTTPS POST to your webhook URL, signed with your API secret key.
- A delivery succeeds when you answer with any 2xx within 10 seconds.
- A failed delivery is retried every minute, up to 5 attempts.
- The same day never produces a second delivery.
- Use
id(daily_summary:<merchant_id>:<date>) as your idempotency key.
#Headers
Content-Type: application/json
X-Webhook-Event: report.daily_summary
X-Webhook-Timestamp: 1789828523
X-Webhook-Signature: a0f17280f569734ab64b5b33968c21933fcd737533cd7683f0785f74236532b2
X-Transaction-ID: d51ab35b-3111-51fd-a017-27d373a55d35X-Transaction-IDis the delivery key, a UUIDv5 ofid. It is the same on every attempt.timestampin the body is the time of the attempt, so it changes on each retry.
#Body
data is exactly the daily-summary response. Keys arrive in alphabetical order, and numbers arrive with 2 decimals.
{"data":{"closing_balance":920.33,"credits":1785.00,"currency":"THB","date":"2026-09-18","debits":1864.67,"generated_at":"2026-09-19T00:15:02+07:00","kinds":{"adjustment":{"amount":-54.67,"count":2},"payin":{"amount":1485.00,"count":2},"payin_reversed":{"amount":-500.00,"count":1},"payout":{"amount":-300.00,"count":1},"settlement":{"amount":-1010.00,"count":1},"withdraw_returned":{"amount":300.00,"count":1}},"merchant_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","opening_balance":1000.00,"timezone":"Asia/Bangkok"},"event":"report.daily_summary","id":"daily_summary:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:2026-09-18","merchant_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","timestamp":"2026-09-19T21:35:23+07:00"}#Verifying the signature
Compute hex(HMAC-SHA256(secret_key, X-Webhook-Timestamp + "|" + raw_body)) and compare it with X-Webhook-Signature in constant time.
- Use the raw body bytes, not re-serialised JSON.
- Reject timestamps that are too old for your policy.
import hmac, hashlib
def verify(secret_key: str, ts: str, raw_body: bytes, signature: str) -> bool:
mac = hmac.new(secret_key.encode(), ts.encode() + b"|" + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, signature)#Reconciliation guidance
- Chain the days. Each day's
opening_balancemust equal the previous day'sclosing_balance. A gap means a day is missing: fetch it withGET /v1/merchant-reports/daily-summary?date=. - Recover missed webhooks. If a webhook for yesterday has not arrived by about 01:00 Bangkok, pull the day through the API. The API and the webhook return the same numbers. Store by
dateand overwrite, so a late webhook for the same day is harmless. - Match line by line. Use
statement/entriesfor one day, ortransactionsandrefundswith your order ids (order_id/reference). - Fees. Amounts are already net of fees. Do not subtract fees again.

