# Webhooks (/docs/guides/webhooks)

Creddot sends an HTTPS `POST` to your webhook URL when something changes. Set the URL and get its signing secret in the dashboard under **Settings, API Keys and Webhooks**. Test mode and live mode have separate URLs and secrets.

## Event format [#event-format]

```json
{
  "id": "cmv9x1...",
  "type": "transaction.success",
  "createdAt": "2026-11-02T10:15:00.000Z",
  "data": {
    "id": "cmv9w8...",
    "reference": "pl_cmv8l2..._6dc9...",
    "status": "SUCCESS",
    "type": "COLLECTION",
    "amountMinor": "250000",
    "currency": "NGN",
    "environment": "SANDBOX",
    "checkoutSessionId": "cmv8l2...",
    "merchantReference": "ORDER-10042"
  }
}
```

`checkoutSessionId` and `merchantReference` appear on payments made through a [checkout session](/docs/guides/accept-payments).

## Event types [#event-types]

| Type | When |
| --- | --- |
| `transaction.success` | A payment, payout or bill payment succeeded |
| `transaction.failed` | It failed |
| `transaction.pending`, `transaction.processing` | It started or is waiting on the provider |
| `transaction.reversed`, `transaction.cancelled` | It was reversed or cancelled |
| `transaction.reconciliation_required` | The provider's answer was unclear; Creddot is checking. Don't act yet. |
| `payout.queued` | A payout was accepted and is waiting to be sent |
| `subscription.activated`, `subscription.renewed` | A subscriber paid their first or a renewal cycle |
| `subscription.overdue`, `subscription.lapsed`, `subscription.cancelled` | A subscriber missed a payment, ran out of grace period, or cancelled |
| `refund.requested` | A refund was requested |

Always switch on `type` and ignore types you don't handle; new types may be added.

## Verify the signature [#verify-the-signature]

Every request has these headers:

| Header | Value |
| --- | --- |
| `x-creddot-event-id` | The event `id`; use it to ignore duplicates |
| `x-creddot-timestamp` | Unix time in **seconds** |
| `x-creddot-signature` | `sha256=` followed by the hex HMAC-SHA256 of `{timestamp}.{raw body}`, keyed with your webhook secret |

Verify against the **raw** request body, before parsing it, and reject old timestamps.

<Tabs items={['Node.js', 'PHP']}>
<Tab value="Node.js">

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyCreddotWebhook(rawBody, headers, secret) {
  const timestamp = headers['x-creddot-timestamp'];
  const signature = headers['x-creddot-signature'] ?? '';
  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express: keep the raw body for this route
app.post('/webhooks/creddot', express.raw({ type: 'application/json' }), async (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verifyCreddotWebhook(raw, req.headers, process.env.CREDDOT_WEBHOOK_SECRET)) return res.sendStatus(400);
  const event = JSON.parse(raw);
  if (await alreadyHandled(event.id)) return res.sendStatus(200);
  if (event.type === 'transaction.success' && event.data.merchantReference) {
    await markOrderPaid(event.data.merchantReference, event.data.id);
  }
  res.sendStatus(200);
});
```

</Tab>
<Tab value="PHP">

```php
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_CREDDOT_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_CREDDOT_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, getenv('CREDDOT_WEBHOOK_SECRET'));

if (!$timestamp || abs(time() - (int) $timestamp) > 300 || !hash_equals($expected, $signature)) {
  http_response_code(400);
  exit;
}
$event = json_decode($raw, true);
if ($event['type'] === 'transaction.success' && !empty($event['data']['merchantReference'])) {
  mark_order_paid($event['data']['merchantReference'], $event['data']['id']);
}
http_response_code(200);
```

</Tab>
</Tabs>

## Delivery and retries [#delivery-and-retries]

- Respond with any `2xx` within **10 seconds**. Do slow work after responding.
- Anything else is retried, up to **5 attempts**, waiting 1, 2, 4 and 8 minutes between them (at most an hour).
- The same event can arrive more than once. Use `x-creddot-event-id` to process it once.
- Events can arrive out of order. When the order matters, read the current state with `GET /transactions/{id}`.
- You can see each transaction's delivery history and resend events from the transaction in the dashboard.
