---
id: docs.webhooks
title: "Webhooks"
canonical: https://docs.secureprocessing.app/webhooks
last_verified: 2026-11-06
---

# Webhooks

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

## 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
```

> **Warning:** 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_events | Defaults to `["*"]`, every event. An endpoint added in the dashboard always gets every event. |
| url | Must be https for a live endpoint. A test endpoint may be plain http, so a tunnel to your own machine works. |
| Mode | An 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 response | Any 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!,
);
```

> **Warning:** 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

| Event | When |
|---|---|
| payment_intent.created | An intent exists. Nothing has been charged yet. |
| payment_intent.processing | The customer has been sent to the bank’s page. |
| payment_intent.requires_action | 3-D Secure has the cardholder. Wait, do not retry. |
| payment_intent.succeeded | The money is yours. Fulfil here. |
| payment_intent.payment_failed | This 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_updated | A hold was placed and is waiting for capture. |
| payment_intent.canceled | The 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.completed | A checkout session was paid. |
| checkout.session.expired | Nobody paid within 24 hours. |
| refund.created | A refund was accepted and is being attempted at the bank. |
| refund.succeeded | Money went back — including refunds made at the bank. |
| refund.failed | The bank refused the refund. No money moved. |
| customer.created | A customer record was created. |
| product.created | A product was added to your catalogue. |
| product.updated | A product was edited or archived. Its prices are unaffected. |
| price.created | A price was created for a product. |
| price.updated | A price was renamed, archived, or had its metadata changed. Never its amount. |
| payment_link.created | A payment link was created. Its URL is live from that moment. |
| payment_link.updated | A payment link was deactivated, or had its fields or metadata changed. Never its price. |
| balance.available | Money finished its settlement delay and is now payable. One event per release, not one per payment. |
| balance.adjusted | We 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.created | A payout was cut for your available balance. The transfer is being arranged. |
| payout.updated | A payout moved along — today that means the transfer has been initiated. |
| payout.paid | The transfer left our account. bank_reference names it at the bank. |
| payout.canceled | A payout was called off before the transfer. The money is back in your balance. |
| payout.failed | The bank refused or returned the transfer. The money is back in your balance. |
| payout.returned | The transfer left and the bank sent it back, as well as `payout.failed`. The money is back in your balance. |
| account.external_account.updated | Your payout bank account changed, or a transfer showed it needs updating (`status` is `new` or `errored`). |
| tax_rate.created | A tax rate was created. |
| tax_rate.updated | A tax rate was renamed or archived. Never its percentage. |
| invoice.created | A draft invoice exists. Nothing is owed and the customer cannot see it. |
| invoice.updated | A draft was edited, or an open invoice’s memo, footer or metadata changed. |
| invoice.finalized | The invoice has a number and its totals are fixed. It is now a document. |
| invoice.sent | The invoice was emailed to the customer. |
| invoice.payment_succeeded | A 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.paid | The 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.voided | The invoice was cancelled. It keeps its number and stays on the record. |
| invoice.marked_uncollectible | The invoice was owed and has been written off. |
| invoice.reminder_sent | An automatic reminder went to the customer about an unpaid invoice. |
| payment_method.attached | A customer saved a card. |
| payment_method.detached | A saved card was removed and will not be charged again. |
| webhook_endpoint.disabled | We switched an endpoint off after repeated failures. |
| webhook_endpoint.test | You sent a test event, with Send a test event or `sendTest`. Nothing happened to a payment. |
| account.updated | Your account’s standing changed — approved, restricted, or switched live. |
| application_fee.created | Your platform took a fee from a connected account's payment. |
| application_fee.refunded | Some or all of that fee went back to the account. |
| capability.updated | One capability changed. The object says which, its status and its previous status. |
| account.application.rejected | The platform connected to this account ended that relationship. |
| review.opened | A payment succeeded and a risk rule had asked for a human to look at it. Once per payment. |
| review.closed | A review was approved or refunded. The object’s status says which. |
| risk_rule.created | A risk rule was created. |
| risk_rule.updated | A risk rule was renamed, switched on or off, or reprioritised. Never what it tests. |
| risk_list_item.created | Something was added to your allow or block list. |
| risk_list_item.removed | An allow or block list entry was deleted. |
| application.submitted | Your activation application was sent to us for review. A person reads it; it is not instant. |
| application.updated | We 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.created | A cardholder disputed one of your payments. evidence_details.due_by is the deadline — this is the event to alert on. |
| dispute.updated | Evidence was saved or submitted, you accepted the dispute, or an inquiry became a chargeback. The status says which. |
| dispute.funds_withdrawn | The disputed amount left your available balance and is held in reserve. An inquiry does not fire this; escalating one does. |
| dispute.funds_reinstated | You won: the held amount is back in your available balance. |
| dispute.closed | The bank answered. status is won, lost, or warning_closed for an inquiry. |
| file.created | Someone 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);
```
