Webhooks
A webhook endpoint is your URL that Tuppence POSTs events to: a payment succeeded, a
dispute opened, a payout reached the bank. Every delivery is signed and retried until you answer
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"]}'const endpoint = await tuppence.webhookEndpoints.create({ url: "https://example.com/tuppence", enabled_events: ["payment.succeeded", "refund.created"],});console.log(endpoint.secret); // whsec_…, shown onceendpoint = tuppence.webhook_endpoints.create( url="https://example.com/tuppence", enabled_events=["payment.succeeded", "refund.created"],)print(endpoint.secret) # whsec_…, shown onceThe response returns the signing secret (whsec_…) once, so store it. You can also add
an endpoint in the dashboard.
| Rule | |
|---|---|
| Scheme | Test mode may use http://; live must be https:// |
| Address | Never a private address |
| Events | A list of types, or ["*"] for all |
| How many | At most 16 endpoints per mode |
Verify every delivery
Section titled “Verify every delivery”Each request carries a Tuppence-Signature header. Verify the signature against the raw body
(re-serialised JSON won’t 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);});# Flaskfrom tuppence import SignatureVerificationError
@app.post("/tuppence")def webhook(): try: event = tuppence.webhooks.construct_event( request.get_data(), request.headers.get("Tuppence-Signature"), secret ) except SignatureVerificationError: return "", 400 # not from Tuppence: never act on it if event["type"] == "payment.succeeded": fulfil(event["data"]["object"]) return "", 200Without an SDK, the header is t=<unix seconds>,v1=<hex>[,v1=<hex>]:
| Part | What it is | Check |
|---|---|---|
t | When it was signed, Unix seconds | Within 300 s of your clock |
v1 | HMAC-SHA256, hex, of "<t>.<raw body>" keyed with your whsec_… secret | Any v1 matches, compared in constant time |
two v1s | Sent for 24 h after a secret rotation | Either may match |
| other schemes | Future versions | Ignore them |
| Your handler should | Because |
|---|---|
| Answer within 10 seconds, then do slow work | Slower counts as a failure and is retried |
Ignore an event id it has seen (also in Tuppence-Event-Id) | Deliveries can arrive more than once |
| Re-read the object if order matters | Deliveries can arrive out of order |
Try it without a server
Section titled “Try it without a server”In test mode, a webhook inbox is a Tuppence URL that stands in for your endpoint. It keeps the last 20 deliveries signed by your own endpoints, exactly as sent, for a day. 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);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: "usd" });await tuppence.request("POST", `/v1/payments/${payment.id}/confirm`, { body: { payment_method: "pm_card_us" },});
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);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: "eur" });await tuppence.request("POST", `/v1/payments/${payment.id}/confirm`, { body: { payment_method: "pm_card_de" },});
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);import osimport timefrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
inbox = tuppence.request("POST", "/v1/test_helpers/webhook_inboxes")endpoint = tuppence.webhook_endpoints.create(url=inbox["url"], enabled_events=["payment.succeeded"])
payment = tuppence.payments.create(amount=1500, currency="gbp")tuppence.payments.confirm(payment.id, payment_method="pm_card_gb")
delivery = Nonefor _ in range(60): time.sleep(0.5) seen = tuppence.request("GET", f"/v1/test_helpers/webhook_inboxes/{inbox['id']}") delivery = next((r for r in seen["requests"] if r["event_type"] == "payment.succeeded"), None) if delivery: break
event = tuppence.webhooks.construct_event(delivery["body"], delivery["signature"], endpoint.secret)print(event["type"], event["data"]["object"]["id"] == payment.id) # payment.succeeded Truetuppence.webhook_endpoints.delete(endpoint.id)import osimport timefrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
inbox = tuppence.request("POST", "/v1/test_helpers/webhook_inboxes")endpoint = tuppence.webhook_endpoints.create(url=inbox["url"], enabled_events=["payment.succeeded"])
payment = tuppence.payments.create(amount=1500, currency="usd")tuppence.payments.confirm(payment.id, payment_method="pm_card_us")
delivery = Nonefor _ in range(60): time.sleep(0.5) seen = tuppence.request("GET", f"/v1/test_helpers/webhook_inboxes/{inbox['id']}") delivery = next((r for r in seen["requests"] if r["event_type"] == "payment.succeeded"), None) if delivery: break
event = tuppence.webhooks.construct_event(delivery["body"], delivery["signature"], endpoint.secret)print(event["type"], event["data"]["object"]["id"] == payment.id) # payment.succeeded Truetuppence.webhook_endpoints.delete(endpoint.id)import osimport timefrom tuppence import Tuppence
tuppence = Tuppence(os.environ["TUPPENCE_SECRET_KEY"])
inbox = tuppence.request("POST", "/v1/test_helpers/webhook_inboxes")endpoint = tuppence.webhook_endpoints.create(url=inbox["url"], enabled_events=["payment.succeeded"])
payment = tuppence.payments.create(amount=1500, currency="eur")tuppence.payments.confirm(payment.id, payment_method="pm_card_de")
delivery = Nonefor _ in range(60): time.sleep(0.5) seen = tuppence.request("GET", f"/v1/test_helpers/webhook_inboxes/{inbox['id']}") delivery = next((r for r in seen["requests"] if r["event_type"] == "payment.succeeded"), None) if delivery: break
event = tuppence.webhooks.construct_event(delivery["body"], delivery["signature"], endpoint.secret)print(event["type"], event["data"]["object"]["id"] == payment.id) # payment.succeeded Truetuppence.webhook_endpoints.delete(endpoint.id)Retries
Section titled “Retries”A delivery without a 2xx is retried for 72 hours from the first attempt:
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8+ |
|---|---|---|---|---|---|---|---|---|
| After the last | at once | 1 min | 5 min | 30 min | 2 h | 5 h | 10 h | every 12 h |
An endpoint that accepts nothing for three days is disabled (disabled_reason: failing) and you’re
notified. The endpoint’s dashboard page shows every attempt. POST /v1/events/{id}/resend sends an
event again.
Rotating the secret
Section titled “Rotating the secret”POST /v1/webhook_endpoints/{id}/rotate_secret returns a new secret. For 24 hours, deliveries are
signed with both (two v1= values), so you can deploy the new one without dropping any. The SDKs
accept either. previous_secret_expires_at says when the old one stops working.
Manage endpoints in the dashboard
Section titled “Manage endpoints in the dashboard”Developers → Webhooks lists the endpoints for your current mode, with URL, description and status.
| Action | What it does |
|---|---|
| Add endpoint | URL, optional description, all events or chosen ones; shows the secret once. A platform can tick Listen to events on connected accounts for a Connect endpoint |
| Send test event | Sends a webhook_endpoint.test event |
| Rotate secret, Disable / Enable, Delete | Same as the API calls below |
| Deliveries | Every event sent, newest first, each attempt’s status code, error and time taken; Resend sends one again |
Viewing needs webhooks:read; changing anything needs webhooks:write. To change an endpoint’s
URL or events, use the API.
Manage endpoints with the API
Section titled “Manage endpoints with the API”| Call | Does | Scope |
|---|---|---|
POST /v1/webhook_endpoints | Create; answers with secret | webhooks:write |
GET /v1/webhook_endpoints | List, newest first, for the key’s mode; limit (1–100), starting_after, ending_before | webhooks:read |
GET /v1/webhook_endpoints/{id} | Retrieve | webhooks:read |
POST /v1/webhook_endpoints/{id} | Update only what you send: url, enabled_events (replaces the list), description (null clears), status | webhooks:write |
DELETE /v1/webhook_endpoints/{id} | Delete; 204, nothing more is sent and the id stops answering | webhooks:write |
POST /v1/webhook_endpoints/{id}/rotate_secret | New secret | webhooks:write |
POST /v1/webhook_endpoints/{id}/test | Sends webhook_endpoint.test; answers {"event": "evt_…"} | webhooks:write |
GET /v1/webhook_endpoints/{id}/deliveries | Its deliveries, with every attempt | webhooks:read |
{ "id": "we_…", "object": "webhook_endpoint", "livemode": false, "url": "https://example.com/tuppence", "description": "Order fulfilment", "enabled_events": ["payment.succeeded", "refund.created"], "status": "enabled", "disabled_reason": null, "connect": false, "created": 1791504000, "previous_secret_expires_at": null}status is enabled or disabled; disabled_reason is user (you switched it off) or
failing (nothing accepted for three days). Turning a failing endpoint back on restarts
deliveries. connect is fixed at creation. secret appears only on create and rotate.
curl https://api.tuppence.ai/v1/webhook_endpoints/we_… \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"enabled_events": ["payment.succeeded", "payment.failed", "refund.created"]}'
curl -X DELETE https://api.tuppence.ai/v1/webhook_endpoints/we_… \ -H "Authorization: Bearer $TUPPENCE_SECRET_KEY"import { Tuppence } from "@tuppence/node";
const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY);
// A test-mode inbox stands in for your server (see "Try it without a server").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 { data } = await tuppence.webhookEndpoints.list({ limit: 100 });console.log(data.some((e) => e.id === endpoint.id)); // true
const updated = await tuppence.webhookEndpoints.update(endpoint.id, { enabled_events: ["payment.succeeded", "refund.created"], description: "Order fulfilment",});console.log(updated.enabled_events); // [ 'payment.succeeded', 'refund.created' ]
const off = await tuppence.webhookEndpoints.update(endpoint.id, { status: "disabled" });console.log(off.status, off.disabled_reason); // disabled user
const again = await tuppence.webhookEndpoints.retrieve(endpoint.id);console.log(again.description); // Order fulfilment
await tuppence.webhookEndpoints.del(endpoint.id);endpoints = tuppence.webhook_endpoints.list(limit=100).data
updated = tuppence.webhook_endpoints.update( "we_…", enabled_events=["payment.succeeded", "refund.created"], description="Order fulfilment",)off = tuppence.webhook_endpoints.update("we_…", status="disabled")print(off.status, off.disabled_reason) # disabled user
tuppence.webhook_endpoints.delete("we_…")Connect: your connected accounts’ events
Section titled “Connect: your connected accounts’ events”A Connect endpoint, created with "connect": true, receives your connected accounts’ events,
each with account. It receives none of your own events, so keep an ordinary endpoint for
those.
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/connect", "connect": true, "enabled_events": ["payment.succeeded", "account.updated"]}'{ "id": "evt_…", "object": "event", "account": "acct_1…", "type": "payment.succeeded", "data": { "object": { "id": "pay_…", "object": "payment", "amount": 4500 } }}| To | Do |
|---|---|
| Read an account’s events | Send Tuppence-Account: acct_…, or GET /v1/events?account=acct_… with your own key |
| Resend one to your Connect endpoint | POST /v1/events/{id}/resend with {"webhook_endpoint": "we_…", "account": "acct_…"} |
Like any endpoint, a Connect endpoint has its own secret, retries and three-day switch-off.
account.* events carry the Account object; capability.updated carries
the Capability. Webhooks for platforms covers handling each once.
Events
Section titled “Events”GET /v1/event_types lists every type with a description. Most integrations need these:
| Area | Events |
|---|---|
| Payments | payment.succeeded, payment.failed, payment.processing (a Direct Debit, before it succeeds or fails), payment_link.completed |
| Refunds and disputes | refund.created, refund.updated, refund.failed (a debit’s refund), dispute.created, dispute.closed |
| Payouts | payout.paid, payout.failed, payout.held, payout.limited (a scheduled payout waited or was held back by a limit, and why), bank_debit.created, .paid, .failed (a negative balance collected from your bank) |
| Your account | account.charges_enabled, account.limits_updated (tier, exposure cap and daily payout limit, with previous values), account.settlement_seasoned (£1,000$1,000€1,000 of card payments cleared: new payments pay out after three days, not seven) |
| Prices | pricing.agreement_started, pricing.agreement_ending (in a week), pricing.agreement_ended (see prices agreed with us) |
| Prepaid credit | credits.low_balance |
| Your cards | card_dispute.created, .updated, .submitted, .funds_reinstated (provisional credit, or a win), .funds_rescinded (usually the provisional credit, when decided), .closed (won, lost or expired), each carrying the card dispute; issuing.limits_updated (card limits); card.details_retrieved, each time a card’s details are read through the API (never with the number) |
| Direct Debit | direct_debit_application.submitted, .info_requested, .approved, .declined, .revoked, .withdrawn, .limits_changed (each carries the application); direct_debit.activated, .paused, .resumed; payment_method.mandate_cancelled |
| Testing | webhook_endpoint.test |
A Connect endpoint gets all of these for your connected accounts, with account.
- Idempotency, retries and errors
- Testing: helpers for disputes, payouts and time.