AbsolutePayDocs

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

EventFires when
payment.succeededA payment (checkout or invoice) settled.
payment.failedA payment expired, closed, or errored.
charge.refundedA refund completed.
payout.settledA payout settled on-chain.
payout.partialA payout batch settled partially.
payout.failedA 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}, 200
package 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.id and ignore duplicates.
  • Respond fast — return 2xx quickly and do heavy work asynchronously. A non-2xx or 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.failed confirm.
  • Payoutspayout.settled / payout.partial / payout.failed in context.
  • Going live — test webhooks end to end before launch.
  • Errors and the API reference — retries, idempotency, and every endpoint.

On this page