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

# Webhooks

> Verify and de-duplicate signed webhook deliveries, and credit a customer only off a verified disbursement.completed event.

Credit a customer **only** from a verified `disbursement.completed` event —
never from a client callback. Delivery is at-least-once and signed
(`Pulse-Signature`, HMAC-SHA256 over the **raw** request body); a re-serialized
JSON object will not match the signature.

```ts theme={null}
import express from 'express';
import { SignatureVerificationError } from '@pulsebyshiga/node';

// Note: express.raw — the SDK signs the exact bytes, not a parsed object.
app.post('/webhooks/pulse', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const event = await pulse.webhooks.verifyOnce(
      req.body, // raw Buffer
      req.header('Pulse-Signature') ?? '',
    );

    // verifyOnce de-dupes at-least-once redeliveries: null means "already seen".
    if (event === null) return res.sendStatus(200);

    switch (event.type) {
      case 'disbursement.completed':
        await creditCustomer(event.data.order_id, event.data.disbursement);
        break;
      case 'collection.order.failed':
        await markFailed(event.data.order_id, event.data.reason);
        break;
      case 'collection.amount.underpaid':
        await handleShortfall(event.data); // { expected, received, asset, network, tx_hash }
        break;
      case 'collection.deposit.wrong_chain':
        // funds arrived on the wrong network; the order is still payable on the right one
        await flagForSupport(event.data); // { order_id, deposit, expected_network }
        break;
      // collection.order.created | collection.deposit.detected | .confirmed |
      // collection.amount.overpaid | collection.order.expired —
      // handle the ones you care about.
    }

    return res.sendStatus(200);
  } catch (error) {
    // A bad signature is a 400, not a 500 — do not retry-storm the sender.
    if (error instanceof SignatureVerificationError) return res.sendStatus(400);
    throw error;
  }
});
```

## De-duplication

`verifyOnce` verifies the signature, checks replay tolerance, and de-duplicates
on `event.id` using an in-memory store by default — it returns `null` for a
redelivery, which you should acknowledge with a `200` rather than treat as an
error.

On multiple instances, back de-duplication with a shared store (Redis, a
database table) that implements the `ProcessedEventStore` contract (`has` /
`add` on event id), passed to the `Pulse` constructor:

```ts theme={null}
const pulse = new Pulse(useApiKey(process.env.PULSE_API_KEY!), {
  webhookSecret: process.env.PULSE_WEBHOOK_SECRET,
  webhookEventStore: myRedisStore, // { has(eventId), add(eventId) }
});
```

<Warning>
  **Credit a customer only after a verified `disbursement.completed` webhook.**
  Client-side callbacks such as `onSuccess` are UX signals — they can be spoofed.
  The signed webhook is the only source of truth for crediting a user.
</Warning>

See the [webhook event catalog](/reference/webhook-events) for every event and
payload.
