# Webhooks and events

> Signed webhook deliveries: registering an endpoint, verifying the x-wc-signature header, the retry schedule, and every event type Soca emits with what to do when it fires.

Part of the Soca documentation (https://soca.finance/docs). Human version: https://soca.finance/docs/developers/webhooks
Webhooks are how Soca tells your backend that money moved. Register an HTTPS endpoint in **Dashboard → Developers → Webhooks** and you receive a signing secret starting with `whsec_`. From then on, every event is POSTed to your endpoint as JSON:

**Delivery body**

```json
{
  "id": "evt_...",
  "type": "invoice.paid",
  "created_at": "2026-07-21T09:30:00.000Z",
  "data": { ... }
}
```

- `x-wc-signature: t=<unix-seconds>,v1=<hex hmac>`: the signature to verify (below).
- `wc-event-type: <event type>`: the event type, duplicated as a header for cheap routing.

## Verifying signatures

The signature is HMAC-SHA256 over `"<t>.<raw request body>"` with your `whsec_` secret, compared timing-safe against `v1`. Reject deliveries whose `t` is more than **5 minutes** old; that is the replay window. Always verify against the **raw** body, before any JSON parsing or re-serialization.

With the [SDK](/docs/developers/sdk) it is one call:

```typescript
import { verifyWebhookSignature } from '@usesoca/sdk';

const ok = verifyWebhookSignature(process.env.SOCA_WEBHOOK_SECRET, rawBody, req.headers['x-wc-signature']);
if (!ok) return res.status(400).end();
```

Without the SDK, the same check in plain Node:

**Manual verification**

```typescript
import crypto from 'node:crypto';

export function verifyWebhook(secret, rawBody, header) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1 || '', 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

## Retries

Respond with any 2xx status to acknowledge a delivery. On a non-2xx response or a network error, Soca retries with backoff. The next attempts come **1, 5, 30, and 120 minutes** after each failure, for **5 attempts** in total. The delivery is then marked failed and recorded for inspection. Deliveries can arrive late or, rarely, more than once, so key your fulfillment on the event `id` and make duplicates a no-op.

## Event types

Payments and subscriptions emit exactly these events:

| Event | When it fires | What to do |
| --- | --- | --- |
| `invoice.paid` | A one-time invoice was paid. | Fulfill the order. This is the fulfillment signal for one-time payments. |
| `refund.succeeded` | A refund completed on-chain. | Reverse the fulfillment or record the credit in your books. |
| `subscription.active` | The first successful subscription charge. | Start access. This is the fulfillment signal for subscriptions. |
| `subscription.renewed` | A renewal charge succeeded. | Extend access for another period; usually nothing else. |
| `subscription.payment_failed` | A renewal charge failed. Dunning (automatic retries) begins. | Optionally notify the customer to top up their wallet. Do not revoke access yet. |
| `subscription.dunning_exhausted` | All renewal retries failed; the subscription is lost. | End access and treat the subscription as churned. |
| `subscription.revoked` | The customer revoked their on-chain authorization. | End access immediately; no further charges are possible. |
| `subscription.expired` | The subscription reached its scheduled end. | End access. |

Usage-billing actions taken through the API append to the same event feed and ride the same signed, retried delivery path:

| Event | When it fires |
| --- | --- |
| `credit.granted` | A credit grant was applied via `POST /v1/credit_grants`. |
| `credit.debited` | Credits were drawn down via `POST /v1/credit_debits`. |
| `entitlement.granted` | A feature was granted via `POST /v1/entitlement_grants`. |

> **There is no payment.confirmed event**: Do not wait for one. Fulfill one-time purchases on `invoice.paid` and start subscription access on `subscription.active`; revoke on `subscription.revoked`, `subscription.dunning_exhausted`, or `subscription.expired`.

## Reconciliation

The same events are queryable at [`GET /v1/events`](/docs/developers/api-reference). If your endpoint was down past the retry window, page through the feed and process anything you missed. Webhooks are the push; the events list is the pull.
