Webhooks — event notifications
MidasPay pushes signed HTTPS POST requests to an endpoint you register in the merchant console, whenever one of a small set of resources changes state. Webhooks let your back-office (fulfilment, accounting, subscription engine, payout reconciliation) react to state changes without polling.
Contract in one picture
The notification envelope is serialised JSON with snake_case field names. The event-specific payload is wrapped in a typed-union object ({ "type_url": ..., "value": <base64> }) so the same envelope can carry any resource type.
Endpoint requirements
- HTTPS + TLS 1.2+. MidasPay will not deliver to plain HTTP.
- Respond with HTTP
2xxand a body of{"processed": true}. Any other status code, a body of{"processed": false}, or a timeout is treated as a failure and triggers a retry. - Be idempotent. MidasPay guarantees at-least-once delivery — see Idempotency & deduplication.
- Verify the signature before trusting any field — see Verify signatures.
- Read the raw body bytes you received to compute the signature — never re-serialise JSON before verifying.
Event delivery headers
Every delivery carries the same header set used by regular MidasPay API responses, plus one webhook-specific header.
| Header | Description | Example |
|---|---|---|
Content-Type | Always application/json; charset=utf-8 | — |
Authorization | TXGW-SHA256-RSA2048 <parameters> — identifies the signing scheme; see Signature verification. | TXGW-SHA256-RSA2048 auth_id="1900009191",auth_id_type=MERCHANT_ID,... |
Txgw-Timestamp | Unix seconds when MidasPay signed the message. | 1554209980 |
Txgw-Nonce | Random string chosen per delivery; mixed into the signed canonical string. | c5ac7061fccab6bf3e254dcf98995b8c |
Txgw-Signature | Base64-encoded RSA-SHA256 signature over the canonical string. | CtcbzwtQ… |
Txgw-Serial | Serial number of the MidasPay platform certificate whose public key you must use to verify. | 5157F09EFDC096DE15EBE81A47057A7232F1B8E1 |
X-MPAY-WEBHOOK-TIMES | Delivery attempt number (1 for the first delivery, increments on each retry). Use it to correlate logs. | 1 |
Proxies and CDNs sometimes strip custom headers. If Txgw-Signature is
missing you cannot safely process the event — respond with a non-2xx so
MidasPay retries, and fix your proxy configuration.
Event payload envelope
Every webhook body is a JSON envelope. The resource-specific payload is
carried inside resource, a typed-union object shaped as
{ "type_url": ..., "value": <base64> }.
{
"id": "20241113091300SB14170181",
"create_time": "2024-11-13T09:13:00Z",
"update_time": "2024-11-13T09:13:00Z",
"resource": {
"type_url": "type.apis.com/mpay.apis.event.PaymentNotification",
"value": "ChQyMDI0LTExLTEzVDA5OjEyOjU1WhIUMjAyNC0xMS4..."
},
"resource_type": "type.apis.com/mpay.apis.event.PaymentNotification",
"resource_version": "v1",
"event_version": "v1",
"event_type": 2,
"summary": ""
}
Envelope fields
| Field | Type | Description |
|---|---|---|
id | string | Globally unique notification ID, use to deduplicate deliveries. |
create_time | string (RFC 3339) | When the event was first produced. |
update_time | string (RFC 3339) | When the underlying resource was last updated. |
resource | object | Typed-union object holding the event payload (see event types). |
resource_type | string | Fully-qualified type URL identifying the payload schema. |
resource_version | string | Schema version of the payload (currently "v1"). |
event_version | string | Schema version of this envelope (currently "v1"). |
event_type | integer | Enum value identifying what happened — see event types. |
summary | string | Optional human-readable summary (often empty). |
resourceresource.value is a base64-encoded payload whose schema is selected by
resource_type. MidasPay publishes generated client stubs in the major
SDK languages (Go, Java, Node.js, Python, PHP, C#) that decode these
values for you — see the SDK downloads in the Merchant Portal. If you
need to parse them manually, the field list for each resource is in the
Payload schemas section below.
Event types
event_type is an enumerated integer. Only the values below are currently
delivered; new types may be added in minor releases (see
Versioning).
| Enum value | Name | Fires when |
|---|---|---|
2 | PAYMENT_ORDER_PAID | A payment order reaches a terminal state (paid, failed, cancelled) — consult status in the payload. |
3 | PAYMENT_ORDER_REFUNDED | A refund reaches a terminal state — consult refund_status. Replaces the deprecated name PAYMENT_ORDER_REFUND. |
4 | PAYMENT_ORDER_DISPUTED | A chargeback / dispute is opened, updated or closed. Replaces the deprecated name PAYMENT_ORDER_DISPUTE. |
5 | SUBSCRIPTION_CREATED | Subscription activated for the first time. |
6 | SUBSCRIPTION_CANCELLED | Subscription terminated (by payer, merchant, or dunning). |
7 | SUBSCRIPTION_RENEW | A renewal cycle was charged (success or failure carried in renewal_event). |
8 | PAYOUT_STATUS_CHANGE | A payout order transitions state (submitted → processing → paid / failed). |
9 | AUTHORIZATION_PAYMENT_CONTRACT | A recurring-payment contract is signed, updated, or terminated. |
10 | AUTHORIZATION_PAYMENT | A charge made against an existing authorization-payment contract reaches a terminal state. |
11 | REFUND_DETAIL | Detailed refund progress update (channel-level). |
12 | DISPUTE_DETAIL | Detailed dispute progress update (channel-level). |
PAYMENT_ORDER_REFUND (3) and PAYMENT_ORDER_DISPUTE (4) are kept as
deprecated aliases for the same numeric values as their *_REFUNDED /
*_DISPUTED successors; clients generated against older schemas will
still decode correctly.
Enum values 13 (PAYOUT_RFI), 14 (SUBSCRIPTION_SUSPENDED) and
15 (SUBSCRIPTION_RESUMED) are reserved but are not delivered
today. Do not design business logic around them until they are announced.
Payload schemas
The schemas below describe the decoded content of resource.value for each
event type. Field names are as serialised on the wire (snake_case).
Optional fields may be omitted entirely.
Payment notification (event_type 2, 10, 4)
{
"create_time": "2024-11-13T09:12:55Z",
"update_time": "2024-11-13T09:12:59Z",
"merchant_id": "sb73503325",
"id": "20241113091255SB19933667",
"status": "COMPLETED",
"status_detail": { /* error info object when status is a failure */ },
"purchase_units": [ { /* purchase unit */ } ],
"reference_id": "SG241113D59U9W93UEXX5E",
"payment_channel": "CREDIT_CARD",
"amount": { "currency_code": "SGD", "value": "1.35" },
"payer_id": "145961149415000000000002",
"card_info": { /* tokenised card metadata */ },
"card_addition_data": { /* 3DS / AVS / BIN attributes */ },
"metadata": { /* map<string,string> — MidasPay-added tags */ },
"merchant_metadata": { /* map<string,string> — metadata you sent on create */ }
}
statusis the payment order status — e.g.COMPLETED,REFUNDED,FAILED,CANCELLED. See Order status for the full list.status_detailis populated for failure statuses — see Error codes.merchant_metadataechoes back themetadatayou supplied on order creation, so you can correlate without looking upid/reference_id.
Refund notification (event_type 3)
{
"create_time": "2024-11-13T10:00:00Z",
"update_time": "2024-11-13T10:00:04Z",
"merchant_id": "sb73503325",
"id": "RF241113A00001",
"reference_id": "merchant-refund-ref-001",
"origin_order_id": "20241113091255SB19933667",
"origin_reference_id": "SG241113D59U9W93UEXX5E",
"refund_units": [ { /* RefundUnit */ } ],
"refund_status": "REFUND_SUCCEEDED",
"amount": {
"total_amount": { "currency_code": "SGD", "value": "1.35" },
"refund_amount": { "currency_code": "SGD", "value": "1.35" },
"payer_total_amount": { "currency_code": "SGD", "value": "1.35" },
"payer_refund_amount": { "currency_code": "SGD", "value": "1.35" }
},
"reason": "customer_request",
"payer_id": "145961149415000000000002",
"metadata": { }
}
refund_statusvalues includeREFUND_SUCCEEDED,REFUND_FAILED, etc. Preferrefund_statusover the deprecatedstatusfield.origin_order_id/origin_reference_idlet you join back to the original payment.
Contract notification (event_type 9)
{
"create_time": "2024-11-13T09:00:00Z",
"update_time": "2024-11-13T09:00:01Z",
"merchant_id": "sb73503325",
"authorization_id":"AUTH-20241113-0001",
"status": "ACTIVE",
"purchase_units": [ { /* purchase unit */ } ],
"reference_id": "merchant-contract-ref-001",
"payment_channel": "CREDIT_CARD",
"amount": { "currency_code": "SGD", "value": "0.00" },
"payer_id": "145961149415000000000002",
"card_info": { /* tokenised card */ },
"metadata": { }
}
statusvalues includeACTIVE,TERMINATED, etc.
Subscription notification (event_type 5, 6, 7)
{
"create_time": "2024-11-13T09:00:00Z",
"update_time": "2024-11-13T09:00:01Z",
"merchant_id": "sb73503325",
"id": "SUB-20241113-0001",
"reference_id": "merchant-sub-ref-001",
"subscription_status": "ACTIVE",
"event_type": "RENEWAL",
"renewal_event": {
"order_id": "20241213091255SB19933999",
"amount": { "currency_code": "SGD", "value": "1.35" },
"currency": "SGD",
"order_status": "COMPLETED"
},
"payer_id": "145961149415000000000002"
}
- Exactly one of
subscription_event/unsubscription_event/renewal_eventis present, selected by the innerevent_type. Note: this innerevent_typeis a subscription-specific value (SUBSCRIPTION/UNSUBSCRIPTION/RENEWAL) — don't confuse it with the outer numericevent_typein the envelope.
Verify signatures
MidasPay signs webhooks exactly the same way it signs API responses — so you can reuse the code you wrote to verify responses. The full spec with language samples lives in Signature Verification; the short version below is specific to webhooks.
Canonical string
<Txgw-Timestamp>\n
<Txgw-Nonce>\n
<raw request body>\n
- Use the raw body bytes exactly as received. Frameworks (Express, Gin,
Spring) often re-serialise JSON; reach for the pre-parse hook
(
app.use(express.raw({type:'application/json'}))in Express,c.Request.Bodybeforec.BindJSON()in Gin,HttpServletRequest.getInputStream()in Spring). - Each line, including the last, ends with
\n(0x0A). - If the body is empty, the third line is a lone
\n.
Algorithm
- Look up the MidasPay platform certificate whose serial number equals
Txgw-Serial. Store certificates by serial number in advance — MidasPay rotates certificates and publishes both old and new during the overlap window. - Extract the public key from that certificate.
- Base64-decode
Txgw-Signature. - Verify with RSA-SHA256 (PKCS#1 v1.5) over the canonical string.
- (Recommended) Reject deliveries with
|now - Txgw-Timestamp| > 300 sto limit replay exposure.
Sample — Node.js
const crypto = require('crypto');
function verifyWebhook(req, rawBody, platformCertStore) {
const ts = req.headers['txgw-timestamp'];
const nonce = req.headers['txgw-nonce'];
const serial = req.headers['txgw-serial'];
const sigB64 = req.headers['txgw-signature'];
if (!ts || !nonce || !serial || !sigB64) {
throw new Error('missing MidasPay signature headers');
}
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
throw new Error('timestamp outside ±5 min window');
}
const publicKeyPem = platformCertStore.get(serial);
if (!publicKeyPem) throw new Error('unknown platform cert serial: ' + serial);
const canonical = `${ts}\n${nonce}\n${rawBody.toString('utf8')}\n`;
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(canonical);
verifier.end();
if (!verifier.verify(publicKeyPem, sigB64, 'base64')) {
throw new Error('signature mismatch');
}
}
Sample — Go
func VerifyWebhook(headers http.Header, rawBody []byte, certs map[string]*rsa.PublicKey) error {
ts := headers.Get("Txgw-Timestamp")
nonce := headers.Get("Txgw-Nonce")
serial := headers.Get("Txgw-Serial")
sigB64 := headers.Get("Txgw-Signature")
if ts == "" || nonce == "" || serial == "" || sigB64 == "" {
return errors.New("missing MidasPay signature headers")
}
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil { return err }
if math.Abs(float64(time.Now().Unix()-tsInt)) > 300 {
return errors.New("timestamp outside ±5 min window")
}
pub, ok := certs[serial]
if !ok { return fmt.Errorf("unknown platform cert serial: %s", serial) }
sig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil { return err }
canonical := []byte(ts + "\n" + nonce + "\n" + string(rawBody) + "\n")
h := sha256.Sum256(canonical)
return rsa.VerifyPKCS1v15(pub, crypto.SHA256, h[:], sig)
}
See signature examples for Java, Python, PHP, and C# variants — the webhook signing scheme is identical to the response-signing scheme.
Retry policy
MidasPay considers a delivery successful only when the merchant responds
with HTTP 2xx and a JSON body of {"processed": true}. Any other
outcome (non-2xx, {"processed": false}, network timeout, TLS failure) is
retried.
- Retry back-off is per-service-configuration, not hard-coded in the envelope. Check the Webhook console in the Merchant Portal or ask your MidasPay contact for the exact schedule and maximum attempt count for your environment.
- The current attempt number is sent as
X-MPAY-WEBHOOK-TIMES: N— useful for logs and for short-circuiting expensive processing on later retries. - After the last scheduled attempt an event is dead-lettered. You can manually replay it from the Webhook console.
times_interval fromconfiguration. Treat the concrete numbers in the Webhook console as authoritative.
Idempotency & deduplication
MidasPay guarantees at-least-once delivery. The same event may be
delivered more than once — for instance when your endpoint answers 2xx
just after MidasPay's read timeout fires.
Dedupe on the envelope id (not on resource.id / order_id):
-- one-time migration
CREATE UNIQUE INDEX ux_webhook_id ON webhook_events (notification_id);
// in your handler, after signature verification:
if _, err := db.ExecContext(ctx,
`INSERT INTO webhook_events (notification_id, received_at) VALUES (?, ?)`,
env.Id, time.Now()); err != nil {
if isUniqueViolation(err) {
// already processed — still return 2xx + processed:true so
// MidasPay stops retrying.
return reply(w, 200, `{"processed":true}`)
}
return reply(w, 500, `{"processed":false}`)
}
// ... business logic ...
return reply(w, 200, `{"processed":true}`)
Do not dedupe on resource.id or reference_id — a single order can
produce multiple events (PAYMENT_ORDER_PAID, later
PAYMENT_ORDER_REFUNDED, later PAYMENT_ORDER_DISPUTED).
Ordering: MidasPay serialises deliveries per order (a distributed lock prevents two deliveries for the same order from being in-flight simultaneously). There is no global ordering guarantee across different orders.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Never receive any event | Endpoint not publicly reachable / firewall / DNS | curl -XPOST <url> -d '{}' from the open internet; check merchant-console webhook log |
signature mismatch | Body was re-serialised by a framework, or platform cert rotated | Verify over the raw bytes; refresh platform cert store by serial number |
unknown platform cert serial | Platform cert rotated since last sync | Fetch latest cert from console; always key your cert store by Txgw-Serial |
timestamp outside window | Clock drift on your server, or proxy added delay | Run NTP; if you buffer webhooks for minutes, widen the window only after recording a replay-dedup guarantee |
| Same event processed twice | Not deduping on envelope id | Add a unique index on id and short-circuit on conflict |
| Some events never arrive | All delivery attempts returned non-2xx or {"processed": false} | Webhook console → Failed → Replay |
Headers missing (Txgw-Signature etc.) | Upstream proxy / CDN stripped custom headers | Configure the proxy to forward Txgw-*, X-MPAY-*, or terminate TLS on your own server |