# Webhooks

A webhook is the way [Events](/en/products/events/) delivers to a server you run: every event that matches a subscription arrives at your HTTPS endpoint as a signed `POST`. This guide covers creating a webhook subscription, the request your endpoint receives, verifying its signature, how retries and dead letters work, and rotating the signing secret without dropping a delivery.

## What a webhook subscription is

A subscription says which events a receiver wants and how they reach it. A **webhook subscription** has a list of type patterns and a delivery block with `"mode": "webhook"` and your `url`. From then on, each event published in the workspace whose `type` matches one of the patterns is posted to that URL, once per subscription.

Delivery is **at least once**. Under normal operation each event arrives once; a retry, a slow answer or a lost connection can make it arrive again. Your receiver is therefore built to accept a duplicate harmlessly, keyed on the event id.

The URL must be `https`, on port 443, with no user name or password in it, no fragment, and a public host: private, loopback, link-local and local-use names are refused when the subscription is created and checked again at every delivery. Redirects are never followed.

## Create one

Call `POST https://events.inovacc.dev/v1/workspaces/{workspace}/subscriptions` with a secret key that has the subscription write permission:

```json
{"types": ["order.*"], "delivery": {"mode": "webhook", "url": "https://hooks.example.com/inovacc"}}
```

The answer is `201`:

```json
{"subscription_id": "sub_...", "types": ["order.*"],
 "delivery": {"mode": "webhook", "url": "https://hooks.example.com/inovacc", "secret_ref": "managed"},
 "created_at": "2026-10-06T12:00:00.000Z",
 "signing_secret": "whsec_..."}
```

`signing_secret` is the key your endpoint uses to verify deliveries. **It is shown once, in this answer, and never again**: listings show `"secret_ref": "managed"` and nothing more. Store it in your secret manager at once. If it is lost, rotate it (below). A workspace holds at most 50 subscriptions, each with 1 to 20 patterns.

## The delivery request

Each delivery is:

```http
POST https://hooks.example.com/inovacc
content-type: application/json
x-event-id: evt_01K...
x-event-type: order.paid
x-event-timestamp: 1791201600
x-event-signature: v1=<64 lowercase hex characters>

{"event_id":"evt_01K...","type":"order.paid", ... }
```

`x-event-timestamp` is the Unix time in seconds at which the delivery was signed. During a secret rotation, `x-event-signature` carries one entry per live secret, newest first, separated by commas: `v1=<new>,v1=<old>`.

Answer with any `2xx` status to confirm the delivery. Anything else (a `4xx`, a `5xx`, no answer within 10 seconds, a network error) is a failed attempt and will be retried.

## The event envelope

The body is the event envelope, exactly as subscribers of every mode receive it:

| Field | Meaning |
|---|---|
| `event_id` | `evt_` followed by 26 characters, assigned by Inovacc when the event was published. Unique per event. |
| `type` | The event's type, such as `order.paid`: dotted lowercase words, up to 128 bytes. |
| `source` | The application that published the event, taken from its credential. |
| `time` | When it was published, RFC 3339 UTC with milliseconds. |
| `tenant` | The organization, account, workspace and, when known, application the event belongs to. |
| `idempotency_key` | The publisher's key; when it sent none, the `event_id`. |
| `data` | The publisher's JSON payload: what happened, usually ids rather than whole objects. |

Read only the fields you need, and ignore fields you do not know.

## Verify the signature

Verify every delivery **before** you parse or act on it:

1. Read `x-event-timestamp` and the **raw body bytes**, exactly as received, before any JSON parsing.
2. Compute `HMAC-SHA256(secret, timestamp + "." + body)`, where `secret` is the whole `whsec_...` string as UTF-8, and write it as lowercase hex.
3. Split `x-event-signature` on commas and accept the delivery when **any** `v1=` entry equals your value, comparing in constant time.
4. Reject a timestamp more than five minutes away from your clock, so an old delivery cannot be replayed at you.

**Worked example.** With the secret `example-key-0001`, the timestamp `1791201600` and the body `{"hello":"world"}`, the signed text is `1791201600.{"hello":"world"}` and the signature is the 64 hex characters below, printed here in two halves that you join without a space:

```text
05080ab29a6abfb44033966c7837c2a1
889fbf1a909f9cbd12b9dd5d12bb2bf5
```

so the header reads `v1=` followed by both halves. Run your verifier on this example before you deploy it.

**TypeScript** (Node.js 18 or later):

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyInovaccWebhook(
  secret: string,
  timestamp: string,
  signatureHeader: string,
  rawBody: Buffer,
  nowSeconds: number = Math.floor(Date.now() / 1000),
): boolean {
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(nowSeconds - ts) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();
  return signatureHeader.split(",").some((entry) => {
    const part = entry.trim();
    if (!part.startsWith("v1=")) return false;
    const given = Buffer.from(part.slice(3), "hex");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

**Python** (3.10 or later):

```python
import hashlib
import hmac
import time


def verify_inovacc_webhook(secret: str, timestamp: str, signature_header: str,
                           raw_body: bytes, now: int | None = None) -> bool:
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    now = int(time.time()) if now is None else now
    if abs(now - ts) > 300:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body,
                        hashlib.sha256).hexdigest()
    return any(
        part.strip().startswith("v1=")
        and hmac.compare_digest(part.strip()[3:], expected)
        for part in signature_header.split(",")
    )
```

Both functions take the raw body: configure your web framework to hand you the bytes, not a parsed object, on this route.

## Retries and timeouts

Your endpoint has **10 seconds** to answer. A failed attempt is retried after 10 seconds, and the delay doubles on each further failure up to 10 minutes. When the retry cap of 5 is reached, the event moves to your workspace's dead letters. A failure affects only the delivery that failed: other events, and other subscriptions of the same event, keep flowing.

Because a delivery can be repeated, a `2xx` that arrives after your endpoint already processed the event is harmless as long as you deduplicate by `x-event-id`.

## Rotate the secret

`POST /v1/workspaces/{workspace}/subscriptions/{id}/rotate-secret` returns a new secret, once:

```json
{"subscription_id": "sub_...", "signing_secret": "whsec_...", "previous_valid_until": "2026-10-07T12:00:00.000Z"}
```

Until `previous_valid_until` (24 hours by default), **both secrets sign**: each delivery carries `v1=<new>,v1=<old>`. Deploy the new secret to your receiver within that window; because the verifier accepts any matching entry, nothing is dropped while you switch. Rotating again inside the window keeps every unexpired secret. Rotation applies to webhook subscriptions only (`409 wrong_delivery_mode` otherwise).

## Dead letters and replay

`GET /v1/workspaces/{workspace}/dead?limit=50` lists dead letters, newest first, with the event, `attempts`, `dead_at`, `replay_count` and `last_error`:

| `last_error` | Meaning |
|---|---|
| `webhook_status_<code>` | Your endpoint answered that status, for example `webhook_status_500`. |
| `webhook_timeout` | No answer within 10 seconds. |
| `webhook_network` | The connection failed. |
| `url_destination_blocked` | The URL now points somewhere deliveries may not go. |
| `secret_missing` | The subscription's signing secret could not be used. |

Fix the cause, then `POST /v1/workspaces/{workspace}/dead/{event_id}/replay`. The answer is `202`, and the same envelope, with the same `event_id`, is delivered again to the subscriptions that never received it. Dead letters are kept 30 days.

## Best practices

- **Verify before parsing**: compute the signature over the raw bytes, then parse.
- **Make the receiver idempotent**: store each processed `x-event-id` and answer `2xx` at once for one you have seen.
- **Answer fast**: confirm with a `2xx` and do slow work after, from your own job.
- **Reject stale timestamps** (more than five minutes off) and keep your server clock synchronised.
- **Keep the secret in a secret manager** and rotate it when someone who knew it leaves, or on a schedule.
- **Monitor dead letters** and `dlq_inflow` in `GET /stats`; replay once the endpoint is healthy.
- **Return `2xx` only when you have the event safely**: a `2xx` ends the retries.

## See also

- [Events](/en/products/events/): publishing, subscriptions, pull and stream
- [Errors](/en/guides/errors/), [Authentication](/en/guides/authentication/), [Limits](/en/guides/limits/)
