Webhooks push what happens in your workspace to your own service — a Slack alert for every would-review spend, a sync to your finance system, a trigger for your payment flow. Each delivery is signed with the endpoint’s secret.
Create an endpoint
In the console (Developers → Webhook endpoints), or with an admin key. The URL must be public HTTPS; redirects aren’t followed.
curl -X POST https://api.neltava.com/v1/webhooks \
-H "x-api-key: $NELTAVA_ADMIN_KEY" \
-H "content-type: application/json" \
-d '{ "url": "https://example.com/webhooks/neltava" }'{
"id": "wh_…",
"url": "https://example.com/webhooks/neltava",
"events": ["decision.created", "decision.review.recorded", "decision.label.recorded", "decision.outcome.recorded", …],
"secret": "whsec_…" // shown once — store it
}Leave out events to receive everything, or pass the ones you want (up to 10).
Events
| type | When |
|---|---|
decision.created | A decision was made. data is the decision, as the API returns it. |
decision.review.recorded | A person allowed or denied a REVIEW. |
decision.label.recorded | A person labeled a decision's purpose. |
decision.outcome.recorded | An outcome was reported (executed, failed, refunded…). |
A decision event carries mode: in Shadow Mode, decision is what Neltava would have done and effective_decision is ALLOW. Filter on decision to alert on what would have been stopped or sent to a person.
What arrives
POST /webhooks/neltava
content-type: application/json
x-neltava-id: evt_2c1f…
x-neltava-type: decision.created
x-neltava-signature: t=1790517000,v1=5f1c…
{
"id": "evt_2c1f…",
"type": "decision.created",
"created_at": "2026-09-27T14:03:00.000Z",
"data": {
"decision_id": "dec_7f3c…",
"decision": "REVIEW",
"effective_decision": "ALLOW",
"reason_code": "PURPOSE_MISALIGNED",
"mode": "SHADOW",
"agent": { "id": "agt_…", "slug": "growth-agent" },
"action": { "merchant": "Instagram Ads", "amount_minor": 18500, "currency": "USD", … },
…
}
}| Header | |
|---|---|
x-neltava-id | The event id — the same on every retry. Deduplicate on it. |
x-neltava-type | The event type. |
x-neltava-signature | t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">, keyed with the endpoint secret. |
Older integrations may also see the same values as x-purpose-* headers; they’re kept for compatibility.
Verify the signature
Always verify before acting on a delivery. Use the raw body exactly as it arrived — re-serialized JSON won’t match — and reject timestamps more than five minutes away, so an old delivery can’t be replayed.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string, not parsed JSON).
// header: the x-neltava-signature header, e.g. "t=1790517000,v1=5f1c…".
export function verifyNeltavaWebhook(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // replayed or stale
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length / 2 && timingSafeEqual(given, Buffer.from(expected, "hex"));
}import express from "express";
import { verifyNeltavaWebhook } from "./verify.js";
const app = express();
app.post("/webhooks/neltava", express.text({ type: "application/json" }), (req, res) => {
if (!verifyNeltavaWebhook(req.body, req.get("x-neltava-signature") ?? "", process.env.NELTAVA_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
// Deduplicate on event.id (also in x-neltava-id): a delivery can be retried.
queue.push(event); // do the work after answering
res.sendStatus(200);
});Retries and responses
- Answer with any 2xx within 10 seconds. Do slow work after answering.
- Anything else — or no answer — is retried up to 5 attempts, waiting 30 seconds and doubling each time, up to 5 minutes between attempts.
- Deliveries are at-least-once, and can arrive out of order: deduplicate on the event id, and use
created_atto order. - Every attempt, with its HTTP status, is listed in the console (Developers → Deliveries).
Manage endpoints
| Request | |
|---|---|
GET /v1/webhooks | Your endpoints (secrets are never shown again). |
DELETE /v1/webhooks/:id | Stops deliveries to it. |
GET /v1/webhooks/deliveries | Recent attempts and their results. |
Endpoints need an admin key; see keys and scopes. Webhooks are included in the Team plan.