Skip to content

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.

TuppenceYour endpointpayment.succeeded happensPOST event + Tuppence-Signatureverify signature, raw body2xx within 10 sno 2xx? retry for 72 h
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 returns the signing secret (whsec_…) once, so store it. You can also add an endpoint in the dashboard.

Rule
SchemeTest mode may use http://; live must be https://
AddressNever a private address
EventsA list of types, or ["*"] for all
How manyAt most 16 endpoints per mode

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:

// 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);
});

Without an SDK, the header is t=<unix seconds>,v1=<hex>[,v1=<hex>]:

PartWhat it isCheck
tWhen it was signed, Unix secondsWithin 300 s of your clock
v1HMAC-SHA256, hex, of "<t>.<raw body>" keyed with your whsec_… secretAny v1 matches, compared in constant time
two v1sSent for 24 h after a secret rotationEither may match
other schemesFuture versionsIgnore them
Your handler shouldBecause
Answer within 10 seconds, then do slow workSlower 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 mattersDeliveries can arrive out of order

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 true
await 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 true
await 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 true
await tuppence.webhookEndpoints.del(endpoint.id);

A delivery without a 2xx is retried for 72 hours from the first attempt:

Attempt12345678+
After the lastat once1 min5 min30 min2 h5 h10 hevery 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.

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.

Developers → Webhooks lists the endpoints for your current mode, with URL, description and status.

ActionWhat it does
Add endpointURL, 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 eventSends a webhook_endpoint.test event
Rotate secret, Disable / Enable, DeleteSame as the API calls below
DeliveriesEvery 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.

CallDoesScope
POST /v1/webhook_endpointsCreate; answers with secretwebhooks:write
GET /v1/webhook_endpointsList, newest first, for the key’s mode; limit (1–100), starting_after, ending_beforewebhooks:read
GET /v1/webhook_endpoints/{id}Retrievewebhooks:read
POST /v1/webhook_endpoints/{id}Update only what you send: url, enabled_events (replaces the list), description (null clears), statuswebhooks:write
DELETE /v1/webhook_endpoints/{id}Delete; 204, nothing more is sent and the id stops answeringwebhooks:write
POST /v1/webhook_endpoints/{id}/rotate_secretNew secretwebhooks:write
POST /v1/webhook_endpoints/{id}/testSends webhook_endpoint.test; answers {"event": "evt_…"}webhooks:write
GET /v1/webhook_endpoints/{id}/deliveriesIts deliveries, with every attemptwebhooks: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.

Terminal window
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"

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.

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/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 } }
}
ToDo
Read an account’s eventsSend Tuppence-Account: acct_…, or GET /v1/events?account=acct_… with your own key
Resend one to your Connect endpointPOST /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.

GET /v1/event_types lists every type with a description. Most integrations need these:

AreaEvents
Paymentspayment.succeeded, payment.failed, payment.processing (a Direct Debit, before it succeeds or fails), payment_link.completed
Refunds and disputesrefund.created, refund.updated, refund.failed (a debit’s refund), dispute.created, dispute.closed
Payoutspayout.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 accountaccount.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)
Pricespricing.agreement_started, pricing.agreement_ending (in a week), pricing.agreement_ended (see prices agreed with us)
Prepaid creditcredits.low_balance
Your cardscard_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 Debitdirect_debit_application.submitted, .info_requested, .approved, .declined, .revoked, .withdrawn, .limits_changed (each carries the application); direct_debit.activated, .paused, .resumed; payment_method.mandate_cancelled
Testingwebhook_endpoint.test

A Connect endpoint gets all of these for your connected accounts, with account.

Docs assistant

Open as a page
Loading the assistant…