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

# Webhooks

> Get a POST when something happens

Register a URL, store the secret, verify `X-Signature` on every delivery.

## Register

HTTPS only. The response includes a secret (`weh_…`).

```bash theme={"theme":{"light":"min-light","dark":"poimandres"}}
curl -X POST https://api.puplar.com/webhooks \
  -H "Authorization: Bearer sk_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://yourdomain.com/webhooks/puplar" }'
```

## Envelope

Every delivery is `{ id, type, timestamp, payload }`. `id` is the event id. `payload` depends on `type`. Amounts are minor units (kobo/cents). Confirm with `GET /transactions/{id}/verify` (or the matching retrieve endpoint) before fulfilling.

## Events

| Event                     | When                         | Sample                             |
| ------------------------- | ---------------------------- | ---------------------------------- |
| `virtual_account.deposit` | Bank deposit received        | [below](#payload)                  |
| `crypto.deposit`          | Token deposit received       | [Crypto](/guides/crypto)           |
| `payment.completed`       | Payment succeeded            | [Payments](/guides/payments)       |
| `payment.failed`          | Payment failed               | [Payments](/guides/payments)       |
| `payment.expired`         | Payment expired              | [Payments](/guides/payments)       |
| `payment.canceled`        | Payment canceled             | [Payments](/guides/payments)       |
| `collection.completed`    | Collection payment succeeded | [Collections](/guides/collections) |
| `collection.canceled`     | Collection canceled          | [Collections](/guides/collections) |
| `refund.initiated`        | Refund created               | [Refunds](/guides/refunds)         |
| `refund.completed`        | Refund succeeded             | [Refunds](/guides/refunds)         |
| `refund.failed`           | Refund failed                | [Refunds](/guides/refunds)         |
| `payout.initiated`        | Payout created               | [Payouts](/guides/payouts)         |
| `payout.completed`        | Payout arrived               | [Payouts](/guides/payouts)         |
| `payout.failed`           | Payout failed                | [Payouts](/guides/payouts)         |
| `payout.canceled`         | Payout canceled              | [Payouts](/guides/payouts)         |
| `invoice.finalized`       | Invoice opened for payment   | [Invoices](/guides/invoices)       |
| `invoice.sent`            | Invoice emailed              | [Invoices](/guides/invoices)       |
| `invoice.paid`            | Invoice paid                 | [Invoices](/guides/invoices)       |
| `invoice.voided`          | Invoice voided               | [Invoices](/guides/invoices)       |

## Payload

Example: `virtual_account.deposit`. `payload.id` is the transaction id. `source` is the sender’s bank details when present. `payload.status` is always `completed` on this event.

```json theme={"theme":{"light":"min-light","dark":"poimandres"}}
{
  "id": "64f1a2b3c4d5e6f7a8b9c0d1",
  "type": "virtual_account.deposit",
  "timestamp": "2026-07-26T12:00:00.000Z",
  "payload": {
    "id": "64f9b3c4d5e6f7a8b9c0d1e2",
    "status": "completed",
    "method": "bank_transfer",
    "amount": 50000,
    "livemode": true,
    "direction": "credit",
    "currency": "ngn",
    "metadata": {},
    "source": {
      "account_name": "Jane Doe",
      "account_number": "0123456789",
      "bank_name": "Access Bank"
    },
    "customer": "64aa11b2c3d4e5f6a7b8c9d0"
  }
}
```

Other events use the same envelope. Their `payload` objects are on the feature guides in the table above.

## Verify the signature

Header: `X-Signature: sha256=<hmac>`. HMAC-SHA256 of the **raw body** with your webhook secret.

<CodeGroup>
  ```typescript Node.js theme={"theme":{"light":"min-light","dark":"poimandres"}}
  import crypto from 'crypto';

  function verifyWebhook(rawBody: Buffer, signature: string, secret: string): boolean {
    const expected = 'sha256=' + crypto
      .createHmac('sha256', secret)
      .update(rawBody)
      .digest('hex');

    const a = Buffer.from(expected);
    const b = Buffer.from(signature);
    if (a.length !== b.length) return false;

    return crypto.timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={"theme":{"light":"min-light","dark":"poimandres"}}
  import hmac
  import hashlib

  def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
      expected = 'sha256=' + hmac.new(
          secret.encode(), raw_body, hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(expected, signature)
  ```
</CodeGroup>

Hash the raw request bytes. Do not parse JSON and re-stringify — key order and spacing will break the signature.

Non-`2xx` or timeout → up to 3 retries. Inspect with `GET /webhooks/events`.

```bash theme={"theme":{"light":"min-light","dark":"poimandres"}}
curl -X POST https://api.puplar.com/webhooks/events/resend \
  -H "Authorization: Bearer sk_live_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "event_id": "64f1a2b3c4d5e6f7a8b9c0d1",
    "webhook_id": "64f9b3c4d5e6f7a8b9c0d1e2"
  }'
```

| Action       | Endpoint                       |
| ------------ | ------------------------------ |
| List         | `GET /webhooks`                |
| Update       | `PUT /webhooks/{id}`           |
| Delete       | `DELETE /webhooks/{id}`        |
| Delivery log | `GET /webhooks/events`         |
| One delivery | `GET /webhooks/events/{id}`    |
| Resend       | `POST /webhooks/events/resend` |
