Skip to content

Webhooks

A webhook endpoint is a URL of yours that we POST events to: a payment succeeded, a dispute opened, a payout reached the bank. Every delivery is signed, so you can prove it came from us, and retried until your server says 2xx.

Terminal window
curl https://api.tuppence.ai/v1/webhook_endpoints \
-H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/tuppence", "enabled_events": ["payment.succeeded", "refund.created"]}'

The response carries the endpoint’s signing secret (whsec_…) — once. Store it. Test-mode endpoints may use http://; live ones must be https://, and may not point at a private address.

Each request carries Tuppence-Signature: t=<unix seconds>,v1=<hex>, an HMAC-SHA256 of "<t>.<raw body>" with your secret. Verify it against the raw body — re-serialised JSON will not match — and refuse anything older than five minutes. The SDKs do both:

// Express
app.post("/tuppence", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = tuppence.webhooks.constructEvent(req.body, req.headers["tuppence-signature"], secret);
} catch {
return res.sendStatus(400); // not from Tuppence: never act on it
}
if (event.type === "payment.succeeded") fulfil(event.data.object);
res.sendStatus(200);
});
# Flask
@app.post("/tuppence")
def webhook():
try:
event = tuppence.webhooks.construct_event(
request.get_data(), request.headers.get("Tuppence-Signature"), secret
)
except SignatureVerificationError:
return "", 400
if event["type"] == "payment.succeeded":
fulfil(event["data"]["object"])
return "", 200

Answer quickly (within ten seconds) and do slow work afterwards. Deliveries can arrive more than once and out of order: use the event’s id (also in Tuppence-Event-Id) to ignore repeats, and re-read the object if order matters.

In test mode, a webhook inbox is a URL on our side you can point an endpoint at. It keeps the last 20 deliveries for a day, exactly as sent — and only deliveries signed by your own endpoints. This example takes a payment and verifies the webhook it produced:

import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
const inbox = await tuppence.request("POST", "/v1/test_helpers/webhook_inboxes");
const endpoint = await tuppence.webhookEndpoints.create({
url: inbox.url,
enabled_events: ["payment.succeeded"],
});
const payment = await tuppence.payments.create({ amount: 1500, currency: "gbp" });
await tuppence.request("POST", `/v1/payments/${payment.id}/confirm`, {
body: { payment_method: "pm_card_gb" },
});
let delivery;
for (let i = 0; !delivery && i < 60; i += 1) {
await new Promise((r) => setTimeout(r, 500));
const seen = await tuppence.request("GET", `/v1/test_helpers/webhook_inboxes/${inbox.id}`);
delivery = seen.requests.find((r) => r.event_type === "payment.succeeded");
}
const event = tuppence.webhooks.constructEvent(delivery.body, delivery.signature, endpoint.secret);
console.log(event.type, event.data.object.id === payment.id); // payment.succeeded true
await tuppence.webhookEndpoints.del(endpoint.id);

A delivery that does not get a 2xx is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, then every 12 hours — for 72 hours from the first attempt. An endpoint that has accepted nothing for three days is disabled, and you are told. Every attempt is on the endpoint’s page in the dashboard, and POST /v1/events/{id}/resend sends one again.

POST /v1/webhook_endpoints/{id}/rotate_secret returns a new secret. For 24 hours we sign with both — two v1= values in the header — so you can deploy the new one without dropping a delivery. The SDKs accept either.

GET /v1/event_types lists them all, with a description of each. The ones most integrations need: payment.succeeded, payment.failed, refund.created, dispute.created, dispute.closed, payout.paid, payout.failed, payment_link.completed, account.charges_enabled and, for prepaid credit, credits.low_balance.