> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sakawave.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Get started with webhooks

> Stand up a receiver that verifies, deduplicates, acknowledges fast, and does the work afterwards.

A correct receiver does four things in a fixed order. Getting the order wrong is the usual bug.

## 1. Configure the endpoint

In the dashboard, for the environment you are working in, set an HTTPS URL and copy the signing secret.

<Warning>
  The `whsec_…` secret is shown **once**, at creation and at explicit rotation. Sakawave stores a verifier, not the secret — a lost secret cannot be recovered, only rotated. Put it in a secrets manager before you close the dialog.
</Warning>

Sakawave will refuse a URL that is not HTTPS, carries credentials or a fragment, is longer than 2048 characters, or resolves to anything other than a public internet address. Localhost and private ranges are rejected, at configuration time and again at delivery.

```bash theme={null}
export SAKAWAVE_WEBHOOK_SECRET='whsec_replace_with_your_secret'
```

## 2. Write the receiver

The order matters: raw body, verify, deduplicate, acknowledge, then work.

```ts theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.SAKAWAVE_WEBHOOK_SECRET!;

// The raw-body route must be registered before any global express.json().
app.post('/webhooks/sakawave', express.raw({ type: 'application/json' }), async (req, res) => {
  const id = req.get('Sakawave-Webhook-Id');
  const timestamp = req.get('Sakawave-Webhook-Timestamp');
  const signature = req.get('Sakawave-Webhook-Signature');

  // 1. Headers must be present and well formed.
  if (!id || !timestamp || !signature?.startsWith('v1=')) return res.status(400).end();

  // 2. Reject anything outside a narrow window, before spending time on HMAC.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return res.status(400).end();

  // 3. Verify over the exact bytes received. Never re-serialise first.
  const expected = createHmac('sha256', secret)
    .update(`${id}.${timestamp}.`)
    .update(req.body)
    .digest('hex');
  const received = Buffer.from(signature.slice(3), 'hex');
  const digest = Buffer.from(expected, 'hex');
  if (digest.length !== received.length || !timingSafeEqual(digest, received)) {
    return res.status(401).end();
  }

  // 4. Deduplicate on the header id, atomically with recording receipt.
  const event = JSON.parse(req.body.toString('utf8'));
  const fresh = await recordOnce(id, event.event);   // unique index on id
  if (!fresh) return res.sendStatus(204);            // already seen; still 2xx

  // 5. Queue, then acknowledge. Do the work outside the request.
  await enqueue({ id, type: event.event, data: event.data });
  return res.sendStatus(204);
});
```

Five things that receiver gets right, each of which is a common failure:

1. **Raw bytes.** Parsing and re-serialising changes whitespace and key order, and the signature is over the exact bytes sent.
2. **Timestamp before HMAC.** A cheap check first.
3. **Constant-time comparison**, on equal-length buffers.
4. **Deduplication on the header id.** The body has no id at all.
5. **Acknowledge fast, work later.** You have ten seconds, and the work is not what you are being timed on.

## 3. Handle the event

```ts theme={null}
async function handle(event: { id: string; type: string; data: any }) {
  switch (event.type) {
    case 'fiat.withdrawal.completed': {
      // The event says look; the resource says what is true.
      const transfer = await sakawave.get(`/v1/transfers/${event.data.relatedOperation.id}`);
      return settle(transfer);
    }
    case 'account.updated':
      return syncAccount(event.data);          // resource events carry the object
    case 'bills_payment.completed':
      return deliverToken(event.data.relatedOperation.tokenData);
    default:
      return;                                  // unknown events are normal
  }
}
```

An unrecognised event type is not an error. You receive every event for your business, including ones added after you wrote this, so the `default` branch must be a no-op rather than a throw.

## 4. Test it

Send a test event from the dashboard. It uses the same signing and delivery path as every real event, so a receiver that accepts `endpoint.test` will accept the rest.

Then check the four failure modes deliberately:

* A tampered body → your handler returns `401`.
* A stale timestamp → `400`.
* The same event twice → `2xx`, and the work happens once.
* An unknown event type → `2xx`, and nothing happens.

## 5. Add the sweeper

Delivery is one attempt with no automatic retry. Before you ship anything that moves money, add a reconciliation job.

```ts theme={null}
// The webhook is the fast path. This is the correct one.
setInterval(async () => {
  const pending = await sakawave.get('/v1/transfers?status=pending&pageSize=100');
  for (const transfer of pending.items) {
    if (olderThan(transfer.createdAt, minutes(30))) await reconcile(transfer);
  }
}, minutes(5));
```

## Next

* [Verify signatures](/webhooks/verify-signatures) — the same handler in Python, and the details
* [Events](/webhooks/events) — what each event carries
* [Testing and resends](/webhooks/testing-and-resends) — the pre-launch checklist


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.