Docs menu · Webhooks

Webhooks

Get decisions, reviews, labels and outcomes pushed to your service — signed, retried, and verifiable in a few lines.

Updated

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.

POST /v1/webhooks
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" }'
201
{
  "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

typeWhen
decision.createdA decision was made. data is the decision, as the API returns it.
decision.review.recordedA person allowed or denied a REVIEW.
decision.label.recordedA person labeled a decision's purpose.
decision.outcome.recordedAn 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

a delivery
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-idThe event id — the same on every retry. Deduplicate on it.
x-neltava-typeThe event type.
x-neltava-signaturet=<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.

verify.js (Node)
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"));
}
an Express receiver
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_at to order.
  • Every attempt, with its HTTP status, is listed in the console (Developers → Deliveries).

Manage endpoints

Request
GET /v1/webhooksYour endpoints (secrets are never shown again).
DELETE /v1/webhooks/:idStops deliveries to it.
GET /v1/webhooks/deliveriesRecent attempts and their results.

Endpoints need an admin key; see keys and scopes. Webhooks are included in the Team plan.