API reference
Webhooks
StablePay POSTs the result of every payment, refund, and payout to the notify_url you supplied when creating it. Notifications are signed, use one unified body, and are your source of truth.
Delivery
| Event | Sent to | When |
|---|---|---|
PAYMENT | notify_url from /payments/create or /payments/checkout | The payment reaches a final status. |
REFUND | notify_url from /refunds/create | The refund reaches a final status. |
PAYOUT | notify_url from /payouts/create | The payout reaches a final result. |
Each notification is an HTTP POST with a JSON body and the same four signature headers used everywhere else: X-MerchantID, X-Timestamp, X-Nonce, X-Sign. Your endpoint must be reachable over HTTPS from the public internet.
Verify the signature
The rule is identical to request signing:
merchantID + "\n" + timestamp + "\n" + nonce + "\n" + rawBody- Read the raw bodyTake the HTTP body bytes as-is. Do not let a JSON middleware parse it first.
- Read the headersX-MerchantID, X-Timestamp, X-Nonce, X-Sign.
- RecomputeHMAC-SHA256 the four-line payload with your merchant secret; hex-encode in lowercase.
- Compare, then processConstant-time compare with X-Sign. Only run business logic if it matches. Then reply success.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET_KEY = process.env.SP_SECRET_KEY;
// Read the body as raw bytes — never let a JSON parser touch it before verifying.
app.post("/callback/stablepay", express.raw({ type: "*/*" }), (req, res) => {
const rawBody = req.body.toString("utf8");
const merchantId = req.get("X-MerchantID");
const timestamp = req.get("X-Timestamp");
const nonce = req.get("X-Nonce");
const received = (req.get("X-Sign") || "").toLowerCase();
const payload = `${merchantId}\n${timestamp}\n${nonce}\n${rawBody}`;
const expected = crypto.createHmac("sha256", SECRET_KEY).update(payload, "utf8").digest("hex");
const ok =
expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!ok) return res.status(401).send("invalid signature");
const event = JSON.parse(rawBody); // { event, order_no, status, ... }
// Idempotent: if event.order_no is already processed, skip the work but still ack.
handle(event);
// Plain text, lowercase, no quotes, no JSON.
res.type("text/plain").send("success");
});Frameworks that parse JSON automatically break verification
Express
json(), Spring @RequestBody objects, Laravel's request parsing and similar helpers hand you a re-serialized object, not the original bytes. Register a raw-body handler for the notification route.Notification body
One format is used for all three events. Fields that do not apply to an event are omitted or empty.
| Field | Type | Description |
|---|---|---|
event | string | PAYMENT, REFUND or PAYOUT. |
order_no | string | StablePay number of the object: order_no, refund_no, or payout_no depending on event. |
merchant_order_no | string | Your number for the object: merchant_order_no, merchant_refund_no, or merchant_payout_no. |
psp_order_no | string | StablePay processing reference. |
payment_method | string | Payment method. |
trans_amount | object | Amount and currency. |
trade_info | object | Product information — usually only on PAYMENT events. |
status | string | Current status. |
status_reason | string | Failure reason, if any. |
metadata | string | Your passthrough value, unchanged. |
original_order_no | string | Original StablePay order number — REFUND events only. |
original_merchant_order_no | string | Original merchant order number — REFUND events only. |
finished_at | string | Completion time. |
created_at | string | Creation time. |
Examples
Payment
{
"event": "PAYMENT",
"order_no": "O202606240001",
"merchant_order_no": "M202606240001",
"psp_order_no": "SPR_123456",
"payment_method": "CARD",
"trans_amount": {
"currency": "USD",
"value": "99.99"
},
"trade_info": {
"goods_name": "VIP Membership",
"description": "Monthly subscription"
},
"status": "SUCCESS",
"status_reason": "",
"metadata": "biz=member&uid=10001",
"finished_at": "2026-06-24T10:01:23Z",
"created_at": "2026-06-24T10:00:00Z"
}Refund
Refund events carry original_order_no and original_merchant_order_no so you can link back to the payment.
{
"event": "REFUND",
"order_no": "R202606240001",
"merchant_order_no": "MR202606240001",
"psp_order_no": "SPR_R_001",
"payment_method": "CARD",
"trans_amount": {
"currency": "USD",
"value": "10.00"
},
"status": "SUCCESS",
"status_reason": "",
"metadata": "refund=manual",
"original_order_no": "O202606240001",
"original_merchant_order_no": "M202606240001",
"finished_at": "2026-06-24T11:00:05Z",
"created_at": "2026-06-24T11:00:00Z"
}Payout
{
"event": "PAYOUT",
"order_no": "P202606240001",
"merchant_order_no": "MP202606240001",
"psp_order_no": "SPR_P_001",
"payment_method": "CASH_APP",
"trans_amount": {
"currency": "USD",
"value": "88.50"
},
"status": "SUCCESS",
"status_reason": "",
"metadata": "batch=20260624",
"finished_at": "2026-06-24T12:00:08Z",
"created_at": "2026-06-24T12:00:00Z"
}Respond with success
After processing a notification, return HTTP 200 with exactly this body:
success- Plain text only — no JSON wrapper.
- No quotes.
- Lowercase
success.
HTTP/1.1 200 OK
Content-Type: text/plain
successReliability
- Be idempotent. The same notification may be delivered more than once. Key your handling on
order_no(or your own reference) and, if the object is already processed, skip the work but still returnsuccess. - Acknowledge fast. Verify, persist, respond — then do slow work (emails, fulfilment) asynchronously.
- Notifications are final; queries are fallback. If a notification does not arrive in the window you expect, use the query endpoints to reconcile rather than polling them as your primary signal.
- Keep handling even on synchronous success. A direct payment can return
SUCCESSimmediately; the notification still follows and should be the record you reconcile against.