> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vainona.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Receive webhooks

> Verify each delivery's signature, answer fast, and know how retries, failing endpoints and our egress address work.

export const webhookIps = [];

export const productName = "Vainona";

A webhook endpoint is an HTTPS URL of yours that {productName} POSTs events to: a document entering or leaving a subscription, a job finishing, judging pausing at a budget. This page covers what your endpoint receives, how to check it came from us, and what happens when your endpoint is slow or down. Every event is also in the [events feed](/guides/events-feed), for receivers that would rather pull.

## Set up an endpoint

Create an endpoint with its URL and the platform events it should receive, as patterns such as `job.*` or `namespace.budget_paused`. Subscriptions name their endpoint themselves, so an endpoint with no `events` receives only what its subscriptions send it.

<CodeGroup>
  ```ts TypeScript theme={null}
  const endpoint = await db.webhookEndpoints.create({
    url: "https://example.com/hooks/vainona",
    events: ["job.*", "namespace.budget_paused"],
    namespace_prefix: "acme/prod/",
  });
  // endpoint.secret is whsec_...: store it where your receiver reads it.
  await db.webhookEndpoints.test(endpoint.id);
  ```

  ```python Python theme={null}
  endpoint = db.webhook_endpoints.create(
      url="https://example.com/hooks/vainona",
      events=["job.*", "namespace.budget_paused"],
      namespace_prefix="acme/prod/",
  )
  # endpoint["secret"] is whsec_...: store it where your receiver reads it.
  db.webhook_endpoints.test(endpoint["id"])
  ```
</CodeGroup>

* **The secret** comes back when you create the endpoint and when you rotate it, and a `read_write` key that manages the endpoint can read it again with a get. Lists and `PATCH` responses never show it, and we keep it encrypted.
* **`namespace_prefix`** limits the endpoint to events of namespaces under it, such as `acme/staging/` for a staging receiver. It takes a key scope's forms: `*`, a prefix such as `acme/staging/`, `acme/*`, or one namespace. It defaults to your key's scope, and one outside it is `forbidden`. A key scoped to a prefix sees and manages only the endpoints inside its scope.
* **`events`** are platform event types or patterns, such as `job.*`. A pattern that matches only subscription events or `webhook.test` is refused: a subscription names its endpoint itself.
* **`description`** is up to 256 characters, and **`max_per_second`** caps the delivery rate (see [retries](#retries)).
* **A get** shows the endpoint's `status` (`active`, `failing` or `disabled`), `failing_since`, `previous_secret_expires_at` during a rotation, and `stats`: `deliveries_24h`, the deliveries whose latest attempt was in the last day, `success_rate_24h`, and `last_delivery_at`.
* **Deleting an endpoint** removes its pending deliveries and its log. Subscriptions that name it keep sending their events to the [events feed](/guides/events-feed) only.
* **Creating an endpoint again** with the same `Idempotency-Key` returns the endpoint the first request made, with its secret, so a retried create is safe.
* **A test send** delivers `webhook.test`, or with `type` a sample of that event type, to this endpoint only. Both carry `"test": true`, and the feed never lists them.
* **How many.** Developer has 2 endpoints, Team 20 and Scale 100 ([pricing](/pricing)). One more is refused with `plan_required`, whose `details` name the plan that allows more. After a downgrade, endpoints over the new limit keep delivering. Deliveries are never billed.
* **`unavailable`** (`503`): a create or a rotation can't make a secret right now. Nothing was changed; retry it.

## In the dashboard

Everything on this page can also be done in the dashboard. Admins and owners make changes; every member can look.

* **Webhooks** lists your endpoints with their status, namespaces, last delivery and the last day's deliveries, and how many your plan allows. Creating an endpoint shows its secret once, with a copy button: copy it then, because the dashboard never shows it again. At your plan's limit, the page offers the plan with more.
* **An endpoint's page** rotates the secret (choosing how long the old one keeps signing), sends a test event of any type, disables and enables the endpoint, and deletes it. Its delivery log shows each delivery's status, attempts, response code, latency and next retry, filtered by status, with **Redeliver** on each row. **Recover since…** queues again everything that failed or was skipped since a time.
* **A namespace's Subscriptions tab** lists its subscriptions and creates them. On the Documents tab, **Subscribe to this filter** opens the create form with the query's filter filled in, or says why the filter can't be subscribed to. A template's page lists and creates the template's subscriptions.
* **Events** is the feed, filtered by type and namespace prefix and paged oldest first, and opens each event's JSON and its deliveries.

## What your endpoint receives

Each delivery is one event as JSON, signed with your endpoint's secret. The headers follow the [Standard Webhooks](https://www.standardwebhooks.com) spec:

```http theme={null}
POST /hooks/vainona HTTP/1.1
content-type: application/json
webhook-id: evt_01j9zk3x7t6v0q2m8n5r4w1b3c
webhook-timestamp: 1790596803
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"id": "evt_01j9zk3x7t6v0q2m8n5r4w1b3c", "type": "job.completed", "timestamp": "2026-09-28T12:00:03Z", "data": {...}}
```

* `webhook-id` is the event's id. Every retry, redelivery and replay of an event repeats it, so use it to drop duplicates.
* `webhook-timestamp` is when this attempt was signed, in Unix seconds.
* `webhook-signature` is `v1,` and a base64 HMAC-SHA256. During a secret rotation it holds two, separated by a space.
* `user-agent` names us and links to this page.
* The body is at most 64 KB. When an event would be larger, its `attributes` and `answers` are left out, `truncated` is `true`, and `url` fetches the rest with your own key.

## Verify the signature

Check every delivery before you trust it. The SDKs do it in one call: `verifyWebhook(payload, headers, secret)` in TypeScript and `verify_webhook(payload, headers, secret)` in Python check the signature and the timestamp, accept either signature during a rotation, compare in constant time, and return the event, typed by its `type`. They need nothing beyond the SDK in Python and WebCrypto in TypeScript, so they also run on edge runtimes.

<CodeGroup>
  ```ts TypeScript theme={null}
  import { verifyWebhook, WebhookVerificationError } from "vainona";

  const secret = process.env.WEBHOOK_SECRET;
  if (!secret) throw new Error("WEBHOOK_SECRET is not set");

  export async function POST(request: Request) {
    // The raw bytes, before any JSON parsing: re-serialized JSON won't match.
    const body = new Uint8Array(await request.arrayBuffer());
    try {
      const event = await verifyWebhook(body, request.headers, secret);
      await enqueue(event);
    } catch (error) {
      if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 });
      throw error;
    }
    return new Response(null, { status: 204 });
  }
  ```

  ```python Python theme={null}
  from flask import Flask, request
  from vainona import WebhookVerificationError, verify_webhook

  app = Flask(__name__)


  @app.post("/hooks/vainona")
  def receive():
      try:
          # The raw bytes, before any JSON parsing: re-serialized JSON won't match.
          event = verify_webhook(request.get_data(), request.headers, WEBHOOK_SECRET)
      except WebhookVerificationError:
          return "", 400
      enqueue(event)
      return "", 204
  ```
</CodeGroup>

A request that fails a check raises `WebhookVerificationError`, whose `reason` is `headers`, `timestamp` or `signature`: answer it with `400`. `secret` can also be a list, and the timestamp's tolerance can be changed (`tolerance` in Python, `toleranceSeconds` in TypeScript). A malformed secret is a bug on your side, not a bad request, so it raises an ordinary error rather than `WebhookVerificationError`. [SDKs](/sdks#verifying-webhooks) has Express and FastAPI versions, and how to narrow the event on its `type`.

**Without the SDK.** The check is the [Standard Webhooks](https://www.standardwebhooks.com) one, so any Standard Webhooks library verifies our deliveries too. To write it yourself:

1. Read the body as raw bytes, before any JSON parsing.
2. Refuse a `webhook-timestamp` more than 5 minutes from your clock, so a captured request can't be replayed later.
3. Compute the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`, keyed by the secret's bytes after `whsec_`, base64-decoded.
4. Accept the request if the result equals any `v1,` entry in `webhook-signature`, which holds one entry per signing secret, separated by spaces. Compare in constant time.

### Rotating the secret

Rotating an endpoint's secret returns the new one, and the old one keeps signing alongside it for `previous_valid_for`: 24 hours unless you choose otherwise, at most 7 days, and `0s` to end it at once. While both are valid every delivery carries both signatures, so deploy the new secret to your receiver at any point in that window and nothing is refused. At most two secrets sign at once: rotating again inside the window ends the older one at once.

## Answer 2xx, fast

A delivery succeeds when your endpoint answers any 2xx within **15 seconds**, including at most 5 seconds to connect. Anything else is a failure and is retried: a timeout, a refused connection, a TLS error, and any other status, 3xx included, because redirects aren't followed.

* **Queue the work, then answer.** Store the event (or push it onto your own queue) and return `200` before doing anything slow. A receiver that does its work inline is the usual cause of timeouts and duplicate deliveries.
* **Deduplicate on `webhook-id`.** Delivery is at least once. A 2xx whose response is lost, for example to a timeout, is sent again.
* **Don't rely on order.** A retried event can arrive after a later one. Subscription events carry `sequence`, which grows with each change in the namespace: for one subscription and document, keep the highest you've seen.

We keep the first 1 KB of your response in the endpoint's delivery log, with the status code, the latency and, on a failure, why it failed (`timeout`, `dns`, `tls`, `connect`, `blocked_address` or `http_status`).

## Retries

A failed delivery is retried on this schedule, each wait moved by up to 20% either way so retries from one outage don't arrive together:

| Attempt | Waits after the one before |
| ------- | -------------------------- |
| 1       | none                       |
| 2       | 5 seconds                  |
| 3       | 5 minutes                  |
| 4       | 30 minutes                 |
| 5       | 2 hours                    |
| 6       | 6 hours                    |
| 7       | 12 hours                   |
| 8       | 24 hours                   |
| 9       | 24 hours                   |

That's 9 attempts over about 2.8 days. After the ninth the delivery is `failed`. It stays in the delivery log for 30 days, and you can send it again from the dashboard or the API:

* **The delivery log.** `GET /webhook-endpoints/{id}/deliveries`, newest first, 100 to a page, and filtered by `status` (`pending`, `succeeded`, `failed` or `skipped`), shows each delivery with its event's id and type, its number of `attempts`, `next_attempt_at` while it waits for a retry, and its latest attempt's time, status code, latency, error and first 1 KB of response. Only the latest attempt of each delivery is kept.
* **Redeliver one.** `POST /events/{id}/redeliver` with the `endpoint` queues a new delivery of that event, with the same `webhook-id`, whatever became of the earlier ones. The endpoint must cover the event's namespace, and a test send goes again only to the endpoint it tested.
* **Recover since a time.** `POST /webhook-endpoints/{id}/recover` with `since` queues again every `failed` or `skipped` delivery to the endpoint since then, at most 30 days back, each with its full retry schedule, and says how many it queued. Each is reset in place, so its earlier attempts leave the log. Calling it again queues nothing twice.
* **A disabled endpoint** refuses both with `conflict`: enable it first.

**Slowing us down.** Answer `429` or `503` with a `Retry-After` (seconds, or an HTTP date) and we pause all deliveries to that endpoint for that long, up to 5 minutes, and wait at least that long before retrying the delivery, up to its next step in the schedule. Without `Retry-After` the pause is 5 seconds. We never have more than 16 deliveries in flight to one endpoint, and an endpoint can set `max_per_second` to cap its rate: any positive number, such as `0.5` for one delivery every 2 seconds, or `null` to remove it.

## Failing and disabled endpoints

Both are counted from the first failed attempt after the endpoint's last success, and checked at each attempt.

* **Failing.** When nothing has reached an endpoint for 24 hours and at least 10 attempts have failed, it is marked `failing`, and your organization's owners and admins get an email. Deliveries and retries continue.
* **Disabled.** After 5 days with no successful delivery it is `disabled`, whether or not it was `failing` first, and they get an email. Nothing more is sent to it, and its pending deliveries become `skipped`. New events are still recorded, and the [events feed](/guides/events-feed) lists them, so nothing is lost.
* **Active again.** Any successful delivery returns a failing endpoint to `active`. For a disabled one, fix the receiver, re-enable it with `PATCH /webhook-endpoints/{id}` and `{"enabled": true}`, which also clears its failure count, then recover what it missed since it started failing. You can disable an endpoint yourself with `{"enabled": false}`.

## Our egress IP

Every delivery comes from a fixed address, so you can allow-list it in a firewall:

{webhookIps.length > 0 ? (
<ul>
  {webhookIps.map((ip) => (
    <li key={ip}>
      <code>{ip}</code>
    </li>
  ))}
</ul>
) : (
<p>The address is listed here once production is live.</p>
)}

It is published as a list so that adding an address, for a new region, isn't a breaking change: allow every address listed here.

## HTTPS only

Endpoint URLs must be `https://`, with a certificate a public CA issued, and no user or password in the URL. Before every new connection we resolve the host and refuse to send (`blocked_address`, which counts as a failure) when any of its addresses isn't public: loopback, RFC 1918, carrier-grade NAT, link-local, multicast, the other reserved ranges, their IPv6 equivalents, and IPv6 addresses that embed a private IPv4 one. We then connect to the address we checked, so an endpoint can't point us at your internal network, or ours, through DNS.

## Pull instead of push

Every event is also in the [events feed](/guides/events-feed), `GET /events`, in the order it was recorded, for 30 days. A receiver that was down can read the feed from the last event it handled instead of waiting for retries: an event's `id`, which is the `webhook-id` of its deliveries, works as a cursor. A receiver that can't accept inbound requests can poll the feed and need no endpoint at all.
