Browse docs
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

EventSent toWhen
PAYMENTnotify_url from /payments/create or /payments/checkoutThe payment reaches a final status.
REFUNDnotify_url from /refunds/createThe refund reaches a final status.
PAYOUTnotify_url from /payouts/createThe 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
  1. Read the raw bodyTake the HTTP body bytes as-is. Do not let a JSON middleware parse it first.
  2. Read the headersX-MerchantID, X-Timestamp, X-Nonce, X-Sign.
  3. RecomputeHMAC-SHA256 the four-line payload with your merchant secret; hex-encode in lowercase.
  4. 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.

FieldTypeDescription
eventstringPAYMENT, REFUND or PAYOUT.
order_nostringStablePay number of the object: order_no, refund_no, or payout_no depending on event.
merchant_order_nostringYour number for the object: merchant_order_no, merchant_refund_no, or merchant_payout_no.
psp_order_nostringStablePay processing reference.
payment_methodstringPayment method.
trans_amountobjectAmount and currency.
trade_infoobjectProduct information — usually only on PAYMENT events.
statusstringCurrent status.
status_reasonstringFailure reason, if any.
metadatastringYour passthrough value, unchanged.
original_order_nostringOriginal StablePay order number — REFUND events only.
original_merchant_order_nostringOriginal merchant order number — REFUND events only.
finished_atstringCompletion time.
created_atstringCreation time.

Examples

Payment

event: 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
{
  "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
{
  "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:

response body
success
  • Plain text only — no JSON wrapper.
  • No quotes.
  • Lowercase success.
HTTP/1.1 200 OK
Content-Type: text/plain

success

Reliability

  • 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 return success.
  • 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 SUCCESS immediately; the notification still follows and should be the record you reconcile against.