Add an endpoint in Settings → Developers in your dashboard and we POST a JSON event to it as things happen. Each event wraps the same object the API returns, in data, so one parser handles both.
{
"id": "evt_3f9a1c2b7d4e6f8a0b1c2d3e4f5a6b7c",
"type": "payment.succeeded",
"created": "2026-10-14T16:02:16.000Z",
"data": {
"object": "payment",
"id": "pay_7Hq2LmX9cR4tVb",
"status": "paid",
"amountCents": 12500,
"amountRefundedCents": 0,
"netCents": 12088,
"currency": "usd",
"description": "Consulting — October",
"source": {
"type": "payment_link",
"id": "pl_4kTz8QwN2xYb6RcD1mPa"
},
"customer": {
"name": "Jordan Lee",
"email": "jordan@example.com",
"country": "US"
},
"card": {
"brand": "visa",
"last4": "4242"
},
"paymentMethod": {
"type": "card",
"label": "Visa •••• 4242"
},
"clientReference": "order_1042",
"metadata": {
"campaign": "fall-launch"
},
"amountDetails": {
"subtotalCents": 12500,
"discountCents": 0,
"shippingCents": 0,
"taxCents": 0,
"customerFeeCents": 0
},
"dispute": null,
"failureMessage": null,
"createdAt": "2026-10-14T16:02:11.000Z",
"paidAt": "2026-10-14T16:02:15.000Z"
}
}Add an endpoint
- In Settings → Developers, choose Add endpoint.
- Enter an
https://URL on a public domain name (not an IP address orlocalhost). - Choose the events to receive, or every event, including types added later.
- Copy the endpoint's signing secret (
whsec_…), shown when you add the endpoint, into a server-side environment variable such asANYTIME_WEBHOOK_SECRET. Owners can Reveal it again later. - Choose Send test event to check your endpoint answers
2xx.
A business can have up to 10 endpoints, each with its own secret and event selection. Owners and admins manage them, and can replace an endpoint's secret at any time: events signed with the old secret stop verifying immediately, so deploy the new one straight away.
Event types
| Type | data is a | When |
|---|---|---|
payment.succeeded | payment | A card payment went through. Sent once per payment. |
payment.failed | payment | A card payment was declined or blocked. |
payment.refunded | payment | All or part of a payment was refunded. Sent for each refund. |
dispute.opened | payment | A customer disputed a payment with their bank. |
dispute.closed | payment | A dispute was decided: won, lost or closed. |
invoice.paid | invoice | An invoice was paid in full by card. |
subscription.created | subscription | A customer subscribed to one of your plans. |
subscription.canceled | subscription | A subscription ended: canceled, expired or completed. |
webhook.test | test | Sent by “Send test event”, whatever the endpoint subscribes to. |
Payment and dispute events carry the payment object, invoice.paid the invoice object, and the subscription events a subscription: id, status, plan (id, title, amountCents, currency, interval), customer, currentPeriodEnd, cancelAtPeriodEnd, canceledAt and createdAt.
Delivery and retries
- Respond with any
2xxstatus within 10 seconds to acknowledge an event; do slow work after responding. - Anything else (another status, a redirect, a timeout) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then marked failed.
- Every attempt and response is in the endpoint's delivery log, where you can Resend any event.
- If several events in a row fail every retry, we turn the endpoint off and email your owners and admins.
- Delivery is at least once and events can arrive out of order: use
id(also in theAnytime-Event-Idheader) to skip events you've handled, and re-read the object from the API when order matters.
| Header | Value |
|---|---|
Anytime-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Anytime-Event-Id | The event's id. |
Anytime-Event-Type | The event's type. |
Content-Type | application/json |
Verifying signatures
Every event carries an Anytime-Signature header such as t=1760630536,v1=5257a869…. v1 is the hex HMAC-SHA256, keyed with your endpoint's signing secret (the whole whsec_… string), of the timestamp, a period and the raw request body. Compute it yourself, compare in constant time, and reject timestamps more than 5 minutes old so a captured request can't be replayed.
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.ANYTIME_WEBHOOK_SECRET; // whsec_…
// Read the raw body: the signature covers the exact bytes we sent.
app.post("/webhooks/anytime", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Anytime-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const body = req.body.toString("utf8");
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${body}`)
.digest("hex");
const signed =
typeof parts.v1 === "string" &&
parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
if (!signed || !fresh) return res.status(400).send("Invalid signature");
const event = JSON.parse(body);
// The same event can arrive more than once: skip ids you've already handled.
switch (event.type) {
case "payment.succeeded":
// Fulfil the order for event.data.id
break;
case "payment.refunded":
// event.data.amountRefundedCents is the total refunded so far
break;
}
res.sendStatus(200);
});// app/api/webhooks/anytime/route.ts (Next.js App Router)
import crypto from "node:crypto";
export async function POST(request: Request) {
const secret = process.env.ANYTIME_WEBHOOK_SECRET!; // whsec_…
const body = await request.text(); // the raw body, exactly as sent
const header = request.headers.get("Anytime-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${body}`).digest("hex");
const signed =
typeof parts.v1 === "string" &&
parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
if (!signed || !fresh) return new Response("Invalid signature", { status: 400 });
const event = JSON.parse(body);
// Skip event.id if you've handled it before, then act on event.type.
if (event.type === "payment.succeeded") {
// Mark the order in event.data.metadata (or event.data.clientReference) paid.
}
return new Response(null, { status: 200 });
}# Flask
import hashlib, hmac, json, os, time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["ANYTIME_WEBHOOK_SECRET"] # whsec_…
@app.post("/webhooks/anytime")
def anytime_webhook():
body = request.get_data() # the raw bytes, exactly as sent
parts = dict(p.split("=", 1) for p in request.headers.get("Anytime-Signature", "").split(",") if "=" in p)
timestamp = parts.get("t", "")
expected = hmac.new(SECRET.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
abort(400)
event = json.loads(body)
# Skip event["id"] if you've handled it before, then act on event["type"].
return "", 200Handling events well
- Record each
idyou process and skip repeats. - Fulfil orders on
payment.succeeded, never on the customer reaching your success page: a bank account payment can take days, and a customer can close the tab. - Match a payment to your order with
data.metadata(from the link) ordata.clientReference(from?ref=). - On
payment.refunded,data.amountRefundedCentsis the total refunded so far, not this refund alone. - On
dispute.opened, respond from Disputes in the dashboard before the deadline.