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):
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): 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_urland the webhook stays as it was. - Event not accepted in
triggers: the API returns the error inerrors.triggers.N, whereNis the position of the event in the list, starting at 0, and nothing is saved. Use the events from the Events table.
{
"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:
{
"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"
}ididentifies the delivery: it is the same on every attempt. Use it to avoid processing the same event twice.typeis the event.datais the resource in the same format as the API (GET /customers/{id},GET /payments/{id}orGET /subscriptions/{id}), without the API'sdataenvelope.
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:
- Read the raw body and the two headers.
- Reject it if the timestamp is more than 5 minutes off from your clock. This prevents someone from replaying a captured delivery.
- Compute
sha256=+ HMAC-SHA256 of"{timestamp}.{body}"with the secret. - Compare it with the
X-Ninepay-Signatureheader 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.
// 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: 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. 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.