Skip to content
On this page

Developers › Building on the API

Webhooks

How you find out that something happened, whether or not anyone is looking.

Verified 6 November 2026

View as Markdown

Register an endpoint

Nothing is sent until you tell us where. Add an endpoint in the dashboard under Developers → Webhooks, or from your code:

ts
const endpoint = await sp.webhookEndpoints.create({
  url: "https://example.com/webhooks/secure-processing",
  enabled_events: ["payment_intent.succeeded", "refund.succeeded"], // omit for every event
});

endpoint.secret; // "whsec_..." — store it now; it is never shown again
The signing secret is in the response that creates the endpoint and nowhere else. We keep only an encrypted copy and cannot show it to you again, in the dashboard or the API. If you lose it, rotate it (below). It is whsec_ followed by 32 letters and digits.
What to know
enabled_eventsDefaults to ["*"], every event. An endpoint added in the dashboard always gets every event.
urlMust be https for a live endpoint. A test endpoint may be plain http, so a tunnel to your own machine works.
ModeAn endpoint belongs to the mode of the key that created it. A test endpoint receives test events only, and a live one live events only.
Your responseAny 2xx within 10 seconds is a success. A redirect is a failure — we do not follow it — and so is a timeout.

Then prove the wiring before a real payment depends on it: sp.webhookEndpoints.sendTest(endpoint.id) sends a webhook_endpoint.test event to that endpoint and no other.

Verify every one

ts
const event = await sp.webhooks.constructEvent(
  rawBody,
  request.headers.get("Secure-Processing-Signature")!,
  process.env.SECURE_PROCESSING_WEBHOOK_SECRET!,
);
Pass the raw body, as a string, exactly as it arrived. Our bodies are usually not compact JSON — there is a space after every : and ,, and the keys are not in the order your JSON library would write them — so a body that has been parsed and re-serialised never has the same bytes, and the signature cannot match. This is the most common reason a first webhook integration fails.

The signature header looks like t=1740000000,v1=abc123…: a Unix timestamp and a hex HMAC-SHA256, keyed with your secret, of the timestamp, a full stop and the raw body. During a secret rotation there is one v1 value per secret still in force — two, or more if you rotate again inside the window — and a match on any of them is a pass. The timestamp is part of what was signed, so an old request cannot be replayed at you — constructEvent rejects anything more than five minutes from your clock. Change that with its fourth argument, { toleranceSeconds: 600 }, if your server’s clock is not kept in sync.

What arrives

A POST with a JSON body that is an event object, the same one GET /v1/events/:id returns:

json
{
  "id": "evt_4gH6jK8lM0nP2qR4sT6uV8wX",
  "object": "event",
  "type": "payment_intent.succeeded",
  "api_version": "2026-09-01",
  "account": "acct_1aB2cD3eF4gH5iJ6kL7mN8oP",
  "livemode": false,
  "created": 1761868800,
  "data": {
    "object": { "id": "pi_3KqL8xQpR2sT7vW9yZ1aB4cD", "object": "payment_intent", "status": "succeeded", "...": "..." }
  }
}

account is the account it happened on. data.object is the payment, refund, payout or other object the event is about, as it stood when the event happened; its own object field says which. id is the same on every retry and every resend of the event, which is what makes it the key to deduplicate on. The request also carries Secure-Processing-Event-Id, Secure-Processing-Event-Type and Secure-Processing-Delivery-Id headers, for routing before you parse anything — but verify the signature before you trust any of it.

The events

EventWhen
payment_intent.createdAn intent exists. Nothing has been charged yet.
payment_intent.processingThe customer has been sent to the bank’s page.
payment_intent.requires_action3-D Secure has the cardholder. Wait, do not retry.
payment_intent.succeededThe money is yours. Fulfil here.
payment_intent.payment_failedThis attempt did not pay — the card was refused, or the bank's page expired before a card was tried. last_payment_error says which. The customer can try again.
payment_intent.amount_capturable_updatedA hold was placed and is waiting for capture.
payment_intent.canceledThe payment was abandoned: nobody paid and we closed it. Cancelling one yourself sends no event; the response to the cancel is the record.
checkout.session.completedA checkout session was paid.
checkout.session.expiredNobody paid within 24 hours.
refund.createdA refund was accepted and is being attempted at the bank.
refund.succeededMoney went back — including refunds made at the bank.
refund.failedThe bank refused the refund. No money moved.
customer.createdA customer record was created.
product.createdA product was added to your catalogue.
product.updatedA product was edited or archived. Its prices are unaffected.
price.createdA price was created for a product.
price.updatedA price was renamed, archived, or had its metadata changed. Never its amount.
payment_link.createdA payment link was created. Its URL is live from that moment.
payment_link.updatedA payment link was deactivated, or had its fields or metadata changed. Never its price.
balance.availableMoney finished its settlement delay and is now payable. One event per release, not one per payment.
balance.adjustedWe moved your balance by hand — a correction, a write-off, or a debt being recorded. The reason is on the event, and it is the one balance change worth reading rather than reconciling.
payout.createdA payout was cut for your available balance. The transfer is being arranged.
payout.updatedA payout moved along — today that means the transfer has been initiated.
payout.paidThe transfer left our account. bank_reference names it at the bank.
payout.canceledA payout was called off before the transfer. The money is back in your balance.
payout.failedThe bank refused or returned the transfer. The money is back in your balance.
payout.returnedThe transfer left and the bank sent it back, as well as payout.failed. The money is back in your balance.
account.external_account.updatedYour payout bank account changed, or a transfer showed it needs updating (status is new or errored).
tax_rate.createdA tax rate was created.
tax_rate.updatedA tax rate was renamed or archived. Never its percentage.
invoice.createdA draft invoice exists. Nothing is owed and the customer cannot see it.
invoice.updatedA draft was edited, or an open invoice’s memo, footer or metadata changed.
invoice.finalizedThe invoice has a number and its totals are fixed. It is now a document.
invoice.sentThe invoice was emailed to the customer.
invoice.payment_succeededA payment landed against an invoice. Fires for every payment: each partial, the one that settles the invoice, and an overpayment that arrives after it was already paid.
invoice.paidThe invoice became paid. Fires exactly once, on that change, alongside the settling payment’s invoice.payment_succeeded (same created, no order guaranteed between them) — never for a later overpayment. This is the one to fulfil on.
invoice.voidedThe invoice was cancelled. It keeps its number and stays on the record.
invoice.marked_uncollectibleThe invoice was owed and has been written off.
invoice.reminder_sentAn automatic reminder went to the customer about an unpaid invoice.
payment_method.attachedA customer saved a card.
payment_method.detachedA saved card was removed and will not be charged again.
webhook_endpoint.disabledWe switched an endpoint off after repeated failures.
webhook_endpoint.testYou sent a test event, with Send a test event or sendTest. Nothing happened to a payment.
account.updatedYour account’s standing changed — approved, restricted, or switched live.
application_fee.createdYour platform took a fee from a connected account's payment.
application_fee.refundedSome or all of that fee went back to the account.
capability.updatedOne capability changed. The object says which, its status and its previous status.
account.application.rejectedThe platform connected to this account ended that relationship.
review.openedA payment succeeded and a risk rule had asked for a human to look at it. Once per payment.
review.closedA review was approved or refunded. The object’s status says which.
risk_rule.createdA risk rule was created.
risk_rule.updatedA risk rule was renamed, switched on or off, or reprioritised. Never what it tests.
risk_list_item.createdSomething was added to your allow or block list.
risk_list_item.removedAn allow or block list entry was deleted.
application.submittedYour activation application was sent to us for review. A person reads it; it is not instant.
application.updatedWe decided: approved, declined, or we need more information. The outcome and the reason are in the payload, and an approval fires account.updated alongside it.
dispute.createdA cardholder disputed one of your payments. evidence_details.due_by is the deadline — this is the event to alert on.
dispute.updatedEvidence was saved or submitted, you accepted the dispute, or an inquiry became a chargeback. The status says which.
dispute.funds_withdrawnThe disputed amount left your available balance and is held in reserve. An inquiry does not fire this; escalating one does.
dispute.funds_reinstatedYou won: the held amount is back in your available balance.
dispute.closedThe bank answered. status is won, lost, or warning_closed for an inquiry.
file.createdSomeone on your team uploaded a document. No URL is included — retrieve the file for a link that works for five minutes.

A platform's Connect endpoint is told about its connected accounts only while it may still reach them. An event waiting to be sent is dropped if the account is rejected or moves to another platform first, and sending one again is refused. While a platform is paused its events wait, and they go once it is active again. It also reads less than the account does: an application's decision, an adjustment's reason, a payout's failure reason and a dispute's outcome note stay with the account.

Retries

Any response outside the 2xx range is a failure and we try again: after 30 seconds, then 2 minutes, 10 minutes, 30 minutes, an hour, and on out to about 35 hours in total. In test mode the retries stop after about five hours, at the 3-hour step. Answer 200 as soon as you have stored the event and do the work afterwards — a slow handler becomes a timeout, and a timeout becomes a retry.

Delivery is at-least-once, so handle events idempotently: key on event.id and ignore one you have already processed. Events can also arrive out of order, so when the order matters, retrieve the object by id rather than assuming a sequence. Events caused by the same change carry the same created and have no order between them at all — the payment that settles an invoice fires invoice.payment_succeeded and invoice.paid alongside each other, and either may come first, in the events list or at your endpoint.

An endpoint whose deliveries keep failing until their retries run out — twenty in a row — is switched off, and you get webhook_endpoint.disabled at any other endpoint you have. Disabling one yourself is DELETE /v1/webhook_endpoints/:id, or sp.webhookEndpoints.disable(id): despite the method, the endpoint is not deleted. It stops receiving events, stays in the list with status: "disabled", and its delivery history stays readable.

Switch a disabled endpoint back on — whichever of us disabled it — with Enable endpoint on its dashboard page, or:

bash
curl https://api.secureprocessing.app/v1/webhook_endpoints/we_6mN8pQ0rS2tU4vW6xY8zA0bC \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "disabled": false }'

It keeps its id and its secret, and its failure count starts again from zero. Nothing is resent by itself, and what you have to do depends on when: events that happened while it was off were never queued for it — catch up from the events list, as below. A retry that came due while it was off was abandoned; it is in the delivery log, and Send again resends it. A retry scheduled for after you switch it back on is still delivered.

Rotating a secret

Rotating gives you a new secret and keeps the old one working for a window you choose: up to 24 hours, 12 if you leave it out, or 0 to retire the old one at once. We sign with both during the overlap, so you can deploy the change without dropping events.

ts
const endpoint = await sp.webhookEndpoints.rotateSecret(id, {
  expires_in_hours: 12,
});
// endpoint.secret is the new one, shown once

If you miss some

Every event is readable from the API, and an event you missed is the same event a retry would have delivered. After an outage, collect everything newer than the last event you processed and handle it oldest first, rather than waiting for retries. The list is newest first, so that means paging back from the newest until you reach the one you had:

ts
async function eventsSince(lastHandledId: string) {
  const missed = [];
  let cursor: string | undefined;
  for (;;) {
    const page = await sp.events.list({ limit: 100, starting_after: cursor });
    for (const event of page.data) {
      if (event.id === lastHandledId) return missed.reverse(); // oldest first
      missed.push(event);
    }
    if (!page.has_more) return missed.reverse();
    cursor = page.data[page.data.length - 1].id;
  }
}

for (const event of await eventsSince(lastHandledId)) await handle(event);