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.
Create an endpoint
Section titled “Create an endpoint”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.
Verify every delivery
Section titled “Verify every delivery”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:
// Expressapp.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 "", 200Answer 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.
Try it without a server
Section titled “Try it without a server”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 trueawait tuppence.webhookEndpoints.del(endpoint.id);Retries
Section titled “Retries”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.
Rotating the secret
Section titled “Rotating the secret”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.
Events
Section titled “Events”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.