Webhooks
Get notified the moment a payment or payout reaches a terminal state — and verify every delivery.
Webhooks are how you learn — reliably and in real time — that something settled. Set a webhook URL on an app, verify each delivery, and act idempotently.
Set a webhook URL
Each app can have one webhook URL (Developers → Apps, or PATCH /v1/apps/:id). When you set it, the
app gets a whsec_… webhook signing secret (shown once — store it with your other secrets) that
signs every delivery. We POST a JSON event to that URL on each relevant lifecycle change.
Deliveries require an approved app
Webhooks deliver only to approved apps. Editing app details — including the webhook URL — resubmits the app for review, and it can't authenticate or receive deliveries until re-approved (rotating the token does not trigger re-review). In the sandbox, apps auto-approve. Plan your production cutover accordingly — see Going live.
Which app receives a payment callback
If a checkout/invoice was created via an app's API key, its payment.succeeded callback is delivered
to that app only — not fanned out to your other apps. Checkouts created from the dashboard (no
creating app) fan out to every approved app on the workspace, as before. So an app that creates a payment
gets its own callback and won't see payments created by a sibling app. (The hosted checkout page also shows
via <app name> when created through an app.)
Events
| Event | Fires when |
|---|---|
payment.succeeded | A payment (checkout or invoice) settled. |
payment.failed | A payment expired, closed, or errored. |
charge.refunded | A refund completed. |
payout.settled | A payout settled on-chain. |
payout.partial | A payout batch settled partially. |
payout.failed | A payout failed. |
Every delivery is a JSON object:
{
"id": "evt_1f3c9a8b2d4e6f7081a2b3c4",
"type": "payment.succeeded",
"data": {
"id": "evt_1f3c9a8b2d4e6f7081a2b3c4",
"createdAt": "2026-06-18T01:35:42.697Z",
"orderRef": "order-1024",
"invoiceId": "inv-9f3a2b-c4d5",
"status": "PAY_SUCCESS",
"amount": "0.500000",
"currency": "USDT",
"txHash": "0xd612",
"chain": "MATIC"
}
}data.orderRef is the identifier you already know — the reference you set on the checkout/invoice
(merchantTradeNo) for payments, merchantBatchNo for payouts — so you correlate the event straight
back to your order. For pay-ins, data.invoiceId additionally carries our internal payment id (present
only on payments). The top-level id is unique per event: key your processing on event.id so
retries never double-credit.
Verify every delivery
Each request carries X-AbsolutePay-Timestamp (epoch milliseconds) and X-AbsolutePay-Signature
(hex). The signature is HMAC-SHA512 over `${timestamp}.${rawBody}` using the app's whsec_…
secret. Verify the raw body before trusting it — the SDKs' constructEvent / construct_event /
ConstructEvent does the verification, freshness check, and parse in one call.
import { constructEvent } from "absolutepay";
// Verifies the signature AND enforces a freshness window (default 5 min); throws on failure.
const event = constructEvent(rawBody, req.headers, process.env.ABSOLUTEPAY_WEBHOOK_SECRET!);
if (event.type === "payment.succeeded") {
// fulfill — event.data.orderRef, event.data.amount, …
}from absolutepay import construct_event
# Verifies the signature AND enforces a freshness window (default 5 min); raises on failure.
event = construct_event(raw_body, request.headers, os.environ["ABSOLUTEPAY_WEBHOOK_SECRET"])
if event["type"] == "payment.succeeded":
... # fulfill — event["data"]["orderRef"], event["data"]["amount"], …// Verifies the signature AND enforces a freshness window (default 5 min); errors on failure.
event, err := absolutepay.ConstructEvent(rawBody, r.Header, os.Getenv("ABSOLUTEPAY_WEBHOOK_SECRET"))
if err != nil {
// reject with 400
}
if event.Type == "payment.succeeded" {
// fulfill — json.Unmarshal(event.Data, &yourType)
}import crypto from "node:crypto";
function verify(rawBody: string, timestamp: string, signature: string, secret: string): boolean {
const expected = crypto.createHmac("sha512", secret).update(`${timestamp}.${rawBody}`).digest("hex");
return expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
// verify(rawBody, headers["x-absolutepay-timestamp"], headers["x-absolutepay-signature"], secret)Verify on the raw body
Verify the exact bytes you received, before any JSON parse or re-serialization — reformatting changes
the bytes and breaks verification. Reject deliveries whose timestamp is more than a few minutes old
to stop replays. The SDK helpers enforce a 5-minute window by default; opt out with toleranceMs: 0
(Node), tolerance_ms=0 (Python), or absolutepay.WithTolerance(0) (Go) — e.g. when replaying
stored events in tests.
A complete handler
import express from "express";
import { constructEvent } from "absolutepay";
const app = express();
// Use the RAW body — not express.json() — so the bytes match the signature.
app.post("/webhooks/absolutepay", express.raw({ type: "application/json" }), async (req, res) => {
// 1. Verify (signature + freshness) on the raw bytes.
let event;
try {
event = constructEvent(req.body.toString("utf8"), req.headers, process.env.ABSOLUTEPAY_WEBHOOK_SECRET!);
} catch {
return res.status(400).send("bad signature");
}
// 2. De-dupe on event.id — deliveries are at-least-once. In production, back this
// with a unique index (INSERT … ON CONFLICT DO NOTHING) instead of memory.
if (!(await markProcessedOnce(event.id))) {
return res.status(200).json({ ok: true, duplicate: true });
}
// 3. Ack fast, then do heavy work async (a job queue beats inline work here).
res.status(200).json({ ok: true });
switch (event.type) {
case "payment.succeeded": /* fulfill order event.data.orderRef */ break;
case "payment.failed": /* release cart / notify customer */ break;
case "charge.refunded": /* mark refunded */ break;
case "payout.settled": /* mark batch paid */ break;
case "payout.partial": /* reconcile per-item results */ break;
case "payout.failed": /* alert + retry payout flow */ break;
}
});import os
from flask import Flask, request
from absolutepay import construct_event, WebhookSignatureError
app = Flask(__name__)
@app.post("/webhooks/absolutepay")
def absolutepay_webhook():
# 1. Verify (signature + freshness) on the RAW body (request.get_data()).
try:
event = construct_event(request.get_data(), dict(request.headers), os.environ["ABSOLUTEPAY_WEBHOOK_SECRET"])
except WebhookSignatureError:
return "bad signature", 400
# 2. De-dupe on event["id"] — deliveries are at-least-once. In production, back
# this with a unique index (INSERT … ON CONFLICT DO NOTHING) instead of memory.
if not mark_processed_once(event["id"]):
return {"ok": True, "duplicate": True}, 200
# 3. Handle. Return 200 fast — push heavy work to a job queue.
kind = event["type"]
if kind == "payment.succeeded":
... # fulfill order event["data"]["orderRef"]
elif kind == "payment.failed":
... # release cart / notify customer
elif kind == "charge.refunded":
... # mark refunded
elif kind in ("payout.settled", "payout.partial", "payout.failed"):
... # reconcile the batch — event["data"]["orderRef"] is your merchantBatchNo
return {"ok": True}, 200package main
import (
"io"
"net/http"
"os"
absolutepay "github.com/AbsolutePay/absolutepay-go"
)
func main() {
secret := os.Getenv("ABSOLUTEPAY_WEBHOOK_SECRET")
http.HandleFunc("/webhooks/absolutepay", func(w http.ResponseWriter, r *http.Request) {
// 1. Verify (signature + freshness) on the RAW bytes — do not re-serialize.
raw, _ := io.ReadAll(r.Body)
event, err := absolutepay.ConstructEvent(raw, r.Header, secret)
if err != nil { // errors.Is(err, absolutepay.ErrInvalidSignature)
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
// 2. De-dupe on event.ID — deliveries are at-least-once. In production, back
// this with a unique index (INSERT … ON CONFLICT DO NOTHING) instead of memory.
if !markProcessedOnce(event.ID) {
w.WriteHeader(http.StatusOK)
return
}
// 3. Ack fast, then do heavy work async (event.Data is json.RawMessage — unmarshal per type).
w.WriteHeader(http.StatusOK)
switch event.Type {
case "payment.succeeded": // fulfill order — json.Unmarshal(event.Data, &…)
case "payment.failed": // release cart / notify customer
case "charge.refunded": // mark refunded
case "payout.settled", "payout.partial", "payout.failed": // reconcile the batch
}
})
http.ListenAndServe(":4242", nil)
}Be idempotent, respond fast, don't rely on webhooks alone
- Idempotent — the same event may arrive more than once (at-least-once delivery). Key on
event.idand ignore duplicates. - Respond fast — return
2xxquickly and do heavy work asynchronously. A non-2xxor timeout is treated as a failed delivery and retried with backoff. - Poll as a fallback — receivers go down. Treat webhooks as the fast path and poll the order/batch as backup (see Accept payments and Payouts).
Delivery records & local testing
- Delivery log — open an app under Developers → Apps → Deliveries to see event id, type, URL, status, HTTP code, and attempt count. Failed deliveries are retried.
- Request/response bodies — expand a delivery to see the exact signed payload we POSTed and your
server's response (captured on success and failure, so you can debug a rejected delivery; the response
is capped at 16KB). Reflects the latest attempt. Also available via
GET /v1/apps/:id/webhooks/:deliveryId. - Test locally — expose your local receiver with a tunnel (e.g.
ngrok http 4455) and set that URL as the app's webhook URL in the sandbox, then trigger a test payment. See Going live.
Next
- Accept payments — the flow
payment.succeeded/payment.failedconfirm. - Payouts —
payout.settled/payout.partial/payout.failedin context. - Going live — test webhooks end to end before launch.
- Errors and the API reference — retries, idempotency, and every endpoint.