Skip to main content

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

JSON with Any-typed payload

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

  1. HTTPS + TLS 1.2+. MidasPay will not deliver to plain HTTP.
  2. Respond with HTTP 2xx and 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.
  3. Be idempotent. MidasPay guarantees at-least-once delivery — see Idempotency & deduplication.
  4. Verify the signature before trusting any field — see Verify signatures.
  5. 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.

HeaderDescriptionExample
Content-TypeAlways application/json; charset=utf-8
AuthorizationTXGW-SHA256-RSA2048 <parameters> — identifies the signing scheme; see Signature verification.TXGW-SHA256-RSA2048 auth_id="1900009191",auth_id_type=MERCHANT_ID,...
Txgw-TimestampUnix seconds when MidasPay signed the message.1554209980
Txgw-NonceRandom string chosen per delivery; mixed into the signed canonical string.c5ac7061fccab6bf3e254dcf98995b8c
Txgw-SignatureBase64-encoded RSA-SHA256 signature over the canonical string.CtcbzwtQ…
Txgw-SerialSerial number of the MidasPay platform certificate whose public key you must use to verify.5157F09EFDC096DE15EBE81A47057A7232F1B8E1
X-MPAY-WEBHOOK-TIMESDelivery attempt number (1 for the first delivery, increments on each retry). Use it to correlate logs.1
Don't trust only the body

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

FieldTypeDescription
idstringGlobally unique notification ID, use to deduplicate deliveries.
create_timestring (RFC 3339)When the event was first produced.
update_timestring (RFC 3339)When the underlying resource was last updated.
resourceobjectTyped-union object holding the event payload (see event types).
resource_typestringFully-qualified type URL identifying the payload schema.
resource_versionstringSchema version of the payload (currently "v1").
event_versionstringSchema version of this envelope (currently "v1").
event_typeintegerEnum value identifying what happened — see event types.
summarystringOptional human-readable summary (often empty).
tip
Decoding resource

resource.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 valueNameFires when
2PAYMENT_ORDER_PAIDA payment order reaches a terminal state (paid, failed, cancelled) — consult status in the payload.
3PAYMENT_ORDER_REFUNDEDA refund reaches a terminal state — consult refund_status. Replaces the deprecated name PAYMENT_ORDER_REFUND.
4PAYMENT_ORDER_DISPUTEDA chargeback / dispute is opened, updated or closed. Replaces the deprecated name PAYMENT_ORDER_DISPUTE.
5SUBSCRIPTION_CREATEDSubscription activated for the first time.
6SUBSCRIPTION_CANCELLEDSubscription terminated (by payer, merchant, or dunning).
7SUBSCRIPTION_RENEWA renewal cycle was charged (success or failure carried in renewal_event).
8PAYOUT_STATUS_CHANGEA payout order transitions state (submitted → processing → paid / failed).
9AUTHORIZATION_PAYMENT_CONTRACTA recurring-payment contract is signed, updated, or terminated.
10AUTHORIZATION_PAYMENTA charge made against an existing authorization-payment contract reaches a terminal state.
11REFUND_DETAILDetailed refund progress update (channel-level).
12DISPUTE_DETAILDetailed dispute progress update (channel-level).
Deprecated aliases

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.

Reserved values

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 */ }
}
  • status is the payment order status — e.g. COMPLETED, REFUNDED, FAILED, CANCELLED. See Order status for the full list.
  • status_detail is populated for failure statuses — see Error codes.
  • merchant_metadata echoes back the metadata you supplied on order creation, so you can correlate without looking up id / 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_status values include REFUND_SUCCEEDED, REFUND_FAILED, etc. Prefer refund_status over the deprecated status field.
  • origin_order_id / origin_reference_id let 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": { }
}
  • status values include ACTIVE, 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_event is present, selected by the inner event_type. Note: this inner event_type is a subscription-specific value (SUBSCRIPTION / UNSUBSCRIPTION / RENEWAL) — don't confuse it with the outer numeric event_type in 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.Body before c.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

  1. 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.
  2. Extract the public key from that certificate.
  3. Base64-decode Txgw-Signature.
  4. Verify with RSA-SHA256 (PKCS#1 v1.5) over the canonical string.
  5. (Recommended) Reject deliveries with |now - Txgw-Timestamp| > 300 s to 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.
note
Past versions of this page claimed a fixed 8-attempt / 24 h schedule. That schedule is not enforced by code — the webhook service reads times_interval from

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

SymptomLikely causeFix
Never receive any eventEndpoint not publicly reachable / firewall / DNScurl -XPOST <url> -d '{}' from the open internet; check merchant-console webhook log
signature mismatchBody was re-serialised by a framework, or platform cert rotatedVerify over the raw bytes; refresh platform cert store by serial number
unknown platform cert serialPlatform cert rotated since last syncFetch latest cert from console; always key your cert store by Txgw-Serial
timestamp outside windowClock drift on your server, or proxy added delayRun NTP; if you buffer webhooks for minutes, widen the window only after recording a replay-dedup guarantee
Same event processed twiceNot deduping on envelope idAdd a unique index on id and short-circuit on conflict
Some events never arriveAll delivery attempts returned non-2xx or {"processed": false}Webhook console → Failed → Replay
Headers missing (Txgw-Signature etc.)Upstream proxy / CDN stripped custom headersConfigure the proxy to forward Txgw-*, X-MPAY-*, or terminate TLS on your own server

See also