---
agentTools:
  projectIndex: https://docs.ninepay.com.br/llms.txt
  projectSearch: https://docs.ninepay.com.br/agentSearch?query=term1%20term2
---

# Webhooks

Webhooks notify your system when a customer, a payment or a subscription changes. They, not the checkout return in the browser, are what should grant or suspend access for paying customers.

## Setting up

Register the webhook in the dashboard, under **Integrações → Webhooks**, or through the API (`/v1/automations`):

```bash
curl -X POST https://payments.cefis.com.br/api/v1/automations \
  -H "Authorization: Bearer $NINEPAY_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "webhook",
    "name": "Meu sistema",
    "webhook_url": "https://meusistema.com.br/webhooks/ninepay",
    "triggers": ["payment.approved", "payment.updated", "subscription.updated"],
    "status": "active"
  }'
```

The `webhook_url` must be a public address (`http` or `https`, outside internal networks); otherwise the API responds with 422. Use `https`.

To check the address before waiting for a real event, the webhook form in the dashboard has a test delivery for each event. You can send up to 10 test deliveries per minute; above that, the response is 429 and you just wait a minute. The limit also applies to the test delivery through the API (`POST /v1/automations/test`).

> 📘 A webhook with a secret can only be edited in the dashboard
>
> The API still creates, lists and edits webhooks without a secret, but it responds with 422 in two cases:
>
> - **Webhook managed in Integrações**, such as one that already has a secret (see [Validating](#validating)): it can only be changed in the dashboard, even if it is just the name or the status. The API returns the error in `errors.webhook_url` and the webhook stays as it was.
> - **Event not accepted in `triggers`**: the API returns the error in `errors.triggers.N`, where `N` is the position of the event in the list, starting at 0, and nothing is saved. Use the events from the [Events](#events) table.
>
> ```json
> {
>   "message": "Evento inválido.",
>   "errors": {
>     "triggers.1": ["Evento inválido."]
>   }
> }
> ```

## Events

| Event | When |
| --- | --- |
| `customer.created` | A customer was created |
| `customer.updated` | A customer was changed |
| `payment.created` | A charge or invoice was created |
| `payment.updated` | A payment changed (paid, declined, canceled, refunded, settled...) |
| `payment.approved` | A payment was approved |
| `subscription.created` | A subscription was created |
| `subscription.updated` | A subscription changed (activated, overdue, paused, resumed, canceled...) |
| `all` | All of the events above |

## What you receive

A `POST` with `Content-Type: application/json`:

```json
{
  "id": 987654,
  "type": "payment.approved",
  "data": {
    "id": 1470491,
    "uuid": "6f1c1a52-1d7e-4f43-a7a4-0a5a2b1c9e10",
    "status": "paid",
    "method": "credit",
    "value": 600
  },
  "created_at": "2026-10-22 14:05:12"
}
```

- `id` identifies the delivery: it is the same on every attempt. Use it to avoid processing the same event twice.
- `type` is the event.
- `data` is the resource in the same format as the API (`GET /customers/{id}`, `GET /payments/{id}` or `GET /subscriptions/{id}`), without the API's `data` envelope.

## Responding

- Respond with a **2xx** status within 10 seconds. Do heavy processing after responding (in a queue, for example).
- Any other status, or no response, counts as a failure, and the delivery is retried: up to 10 attempts, at increasing intervals (1 s, 30 s, 1 min, 5 min, 10 min, 30 min and then every hour).
- Since an event can arrive more than once, and out of order, treat each event as a signal to fetch the resource, not as the final state.

## Validating

Every delivery of a webhook with a secret is signed, so you can be sure it was NinePay that sent it. Two headers come with the `POST`:

| Header | Value |
| --- | --- |
| `X-Ninepay-Timestamp` | The time of the delivery, in seconds (Unix) |
| `X-Ninepay-Signature` | `sha256=` followed by the HMAC-SHA256, in hexadecimal, of `{timestamp}.{body}` |

The HMAC uses the webhook secret (`nep_whs_…`) as the key, and `{body}` is the raw request body, exactly as it arrived, before any JSON parsing. If you re-serialize the JSON, the bytes change and the computation does not match.

To validate:

1. Read the raw body and the two headers.
2. Reject it if the timestamp is more than 5 minutes off from your clock. This prevents someone from replaying a captured delivery.
3. Compute `sha256=` + HMAC-SHA256 of `"{timestamp}.{body}"` with the secret.
4. Compare it with the `X-Ninepay-Signature` header in constant time (do not use `==`). If it does not match, respond with 401.

On each attempt the timestamp is new, so the signature changes from one attempt to the next, even with the same body. Validate each one.

```javascript
// Node.js (Express): the body must arrive raw
// Mount this route before any global app.use(express.json()): otherwise
// req.body arrives already converted to an object and the computation does not
// match. Or apply express.json() only to the other routes and keep express.raw
// only on this one.
const crypto = require('crypto');

app.post('/webhooks/ninepay', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.header('X-Ninepay-Timestamp') || '';
  const signature = req.header('X-Ninepay-Signature') || '';

  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.sendStatus(401);
  }

  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.NINEPAY_WEBHOOK_SECRET)
    .update(`${timestamp}.`)
    .update(req.body) // Buffer with the raw body
    .digest('hex');

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // queue the processing and respond right away
  res.sendStatus(200);
});
```

```php
<?php
// PHP: use the raw body, not $_POST nor json_decode before the computation
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_NINEPAY_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_NINEPAY_SIGNATURE'] ?? '';

if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit;
}

$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, getenv('NINEPAY_WEBHOOK_SECRET'));

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
// queue the processing and respond right away
http_response_code(200);
```

### The secret

- The secret is shown only once: when you create the webhook or when you click **Gerar novo segredo**. Copy it and store it somewhere safe, such as an environment variable.
- When you generate a new secret, the previous one stops working immediately. The following deliveries are already signed with the new one, so update your system before generating it, or validate both for a while.
- Webhooks created before signing have no secret and stay unsigned until someone clicks **Gerar segredo**. Without the headers, there is nothing to validate.

> 🚧 The signature does not replace the API lookup
>
> Even with a valid signature, before granting anything, confirm the state in the API with your token: when you receive `payment.approved` for payment `1470491`, call `GET /payments/1470491` and grant access only if the `status` is `paid`.

That way, a forged or replayed `POST` to your address grants nothing.

## Error e-mail

If your address keeps failing, NinePay warns you by e-mail, sent to the e-mail registered on the webhook. The e-mail links straight to that webhook's [logs](#logs). The notice goes out when the webhook accumulates 10 or more consecutive failures and has been failing for 6 hours or more.

- At most 1 e-mail per day per webhook.
- With no e-mail registered, no notice is sent. Webhooks created through the API (`/v1/automations`) have no e-mail: register one in the dashboard, under **Integrações → Webhooks**.
- The webhook is never paused: new events keep going out and start arriving again as soon as your address responds 2xx. An event that exhausted its 10 attempts, however, is not resent. To lose nothing, check in the API what changed while the address was down.

## Logs

To see what NinePay sent and what your server answered, open the dashboard under **Integrações → Logs de Webhooks**. The whole Integrações page is restricted to the account owner: anyone else is redirected.

The list shows your company's webhook deliveries from the last 14 days. Webhook events are stored for 14 days; after that, the logs are deleted. Deleted webhooks keep their logs visible within that window.

Use the filters to find the delivery you are looking for:

- **Evento** (event) and **Webhook**.
- **Status**: **Todos** (all), **Qualquer erro** (any error), or one of the codes 200, 400, 401, 404, 500, 502, 503 and 504.
- **Período** (period): **Hoje** (today), **Últimos 7 dias** (last 7 days), **Últimos 14 dias** (last 14 days), or **Personalizado** (custom), always within the 14 days.

Click a delivery and the **Detalhes do log** (log details) show the URL, the event, the date, the status and code, the duration, the number of attempts, the payload sent and the body of your server's response.

- The screen does not resend events. An event that exhausted its attempts does not go out again; check in the API what changed.
