Pular para o conteúdo

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):

Shell
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_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 table.
JSON
{
  "message": "Evento inválido.",
  "errors": {
    "triggers.1": ["Evento inválido."]
  }
}

Events

EventWhen
customer.createdA customer was created
customer.updatedA customer was changed
payment.createdA charge or invoice was created
payment.updatedA payment changed (paid, declined, canceled, refunded, settled...)
payment.approvedA payment was approved
subscription.createdA subscription was created
subscription.updatedA subscription changed (activated, overdue, paused, resumed, canceled...)
allAll 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:

HeaderValue
X-Ninepay-TimestampThe time of the delivery, in seconds (Unix)
X-Ninepay-Signaturesha256= 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. 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.