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

# Webhooks & signatures

> Subscribe to workspace events, verify that a delivery really came from Agentova, and handle retries

## Events

Three events exist in v1:

| Event | Fires when | What you receive |
| - | - | - |
| **Lead created** (`lead.created`) | A new lead is captured in your workspace: your website chatbot's contact form, a Lead Ads form, your phone agent, a prospect who sent their contact details after commenting a post, or an incoming CRM contact. Only on creation: a lead that is updated later (for example a chat form submitted twice in the same conversation) doesn't fire again. | The lead: name, email, phone, source, agent, custom fields |
| **Run completed** (`run.completed`) | One of your automations has just done something: replied to a message, a comment or an email, handled a Lead Ads form, processed a CRM contact. | The run: which automation, what triggered it, each action with its result (`success` or `failed`), and the overall result of the run (`success`, `partial` or `failed`) |
| **Automation status changed** (`automation.status_changed`) | An automation's status really changes, whether the change comes from the Agentova app or from the API: it's paused or reactivated, a CRM source is activated, a failure puts it in `error`, or it comes back `active` once its account is reconnected. | The automation, with its new status |

When a failure takes an automation away — revoked access to the account, a mailbox that refuses the sign-in, a channel that can't be reconnected — the automation leaves the API and a last **Automation status changed** with the `error` status is sent: by the time it arrives, looking the automation up by its ID answers `automation_not_found`. Deleting an automation or turning it off in the Agentova app sends nothing.

See [Concepts](https://docs.agentova.ai/en/guides/concepts.md) for the lead sources and the runs produced by each channel.

## Subscribing

`POST /webhooks` with a `url` and the `events` you want (at least one). The response includes a `secret` — shown **once**, at creation. Store it: it's what you use to verify every delivery to this subscription. Each subscription has its own secret.

The `url` must:

* use **HTTPS**;
* point to a public address — local, private and cloud-metadata addresses are refused, and the host must resolve;
* carry no username or password.

A refused URL returns `400 invalid_request` with `details.parameter: "url"` and a `details.reason` (`https_required`, `private_address`, `local_address`, `credentials_in_url`, `unresolvable_host`…).

A workspace can have **20 active subscriptions**. `GET /webhooks` returns them all at once, without pagination — together with the disabled ones, until they're deleted (see [below](#when-a-subscription-is-disabled)). It returns the full `url` only for the subscriptions created with the key you call it with; for the others, `url` is reduced to its origin (for example `https://hooks.zapier.com`), since the path of a hook URL carries the connector's secret token.

Subscriptions belong to the **workspace**: any key of the workspace lists them and can delete them. Revoking a key deletes the subscriptions created with it, unless the admin chooses to keep them — see [Revoking a key](https://docs.agentova.ai/en/security.md#revoking-a-key). Workspace admins can see and delete every subscription in the **Abonnements webhook** table of **Paramètres → API** (Settings → API).

![The Abonnements webhook table: destination, events, key of origin, state and last delivery](https://mintcdn.com/agentova-2c667f5d/Xl6jEAYNYyd4kO2m/images/agentova/webhook-subscriptions.jpg?fit=max\&auto=format\&n=Xl6jEAYNYyd4kO2m\&q=85\&s=2c24bc24faf009d74bf5c85168a50085)

Abonnements webhook: one row per subscription, with the key that created it

## The delivery

Every event is delivered as a `POST` to your subscribed URL, with these headers:

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `Agentova-Webhooks/1.0` |
| `X-Agentova-Signature` | The signature — see [below](#verifying-the-signature) |

The body is a JSON envelope:

```json theme={null}
{
  "id": "evt_example345",
  "type": "lead.created",
  "created_at": "2026-10-01T09:20:01Z",
  "data": {
    "id": "lea_example678",
    "source": "lead_ads",
    "agent_id": "agt_example456",
    "name": "Jane Doe",
    "email": "jane.doe@example.com",
    "phone": "+33 6 12 34 56 78",
    "custom_fields": { "budget": "5000-10000" },
    "created_at": "2026-10-01T09:20:00Z"
  }
}
```

`data`'s shape depends on `type`: a lead for `lead.created`, a run for `run.completed`, an automation for `automation.status_changed` — the same objects as `GET /leads`, `GET /runs` and `GET /automations` return.

* **At least once.** The same event can be delivered more than once, after a retry for example. Deduplicate on `id`: it's unique per event, identical on every attempt, and the same for every subscription that receives the event. Keep the IDs you've seen for **at least 48 hours**: retries are always scheduled within 36 hours of the event's creation.
* **In no guaranteed order.** Two events can arrive in any order — a pause and the reactivation that follows it, for example. To order them, use the resource's own timestamp — `updated_at` for an automation, `created_at` for a lead, `occurred_at` for a run — not the order of arrival.

## Verifying the signature

Every delivery carries an `X-Agentova-Signature` header:

```
X-Agentova-Signature: t=1790856000,v1=cc0aeaeeb19b6d50d7c200ece4653adfba91c3a9f6cb2223ca61cd3965bf55ee
```

* `t` — when this delivery attempt was signed, as a Unix timestamp (seconds)
* `v1` — the HMAC-SHA256 of the string `{t}.{raw body}`, in lowercase hexadecimal

The HMAC key is your subscription's secret **as a whole string, `whsec_` prefix included**. Some webhook libraries — those that follow the Standard Webhooks convention — strip a `whsec_` prefix and decode what follows: don't use them as is. With Agentova, the prefix is part of the key.

The signature is what authenticates a delivery. Don't filter deliveries by IP address or by `User-Agent`: Agentova doesn't publish a list of source addresses, and anyone can send that `User-Agent`.

To verify a delivery:

1. Read the **raw** body, exactly as received, before any JSON parsing — a body parsed and serialized again doesn't produce the same bytes.
2. Reject it if `t` is more than **5 minutes** away from your current time. This stops replayed requests.
3. Compute the HMAC-SHA256 of `{t}.{raw body}` with the full secret, and compare it to `v1` in **constant time** — never with `==` or `===`, which leak timing information.

```js Node.js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

// rawBody: the request body exactly as received (Buffer or string), before any
//          JSON parsing. With Express: express.raw({ type: "application/json" }).
// header:  the value of the X-Agentova-Signature header.
// secret:  the subscription's full secret, "whsec_" prefix included.
export function isValidAgentovaSignature(rawBody, header, secret, now = Date.now() / 1000) {
  if (typeof header !== "string") return false;
  const parts = {};
  for (const item of header.split(",")) {
    const [key, ...value] = item.trim().split("=");
    parts[key] = value.join("=");
  }
  const { t, v1 } = parts;
  if (!/^\d+$/.test(t ?? "") || !/^[0-9a-f]{64}$/.test(v1 ?? "")) return false;
  if (Math.abs(now - Number(t)) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(v1, "hex"));
}
```

```python Python theme={null}
import hashlib
import hmac
import re
import time

TOLERANCE_SECONDS = 5 * 60


def is_valid_agentova_signature(raw_body, header, secret, now=None):
    """raw_body: the request body exactly as received (bytes), before any JSON
    parsing. With Flask: request.get_data(); with Django: request.body.
    header: the value of the X-Agentova-Signature header.
    secret: the subscription's full secret, "whsec_" prefix included."""
    if not header:
        return False
    parts = dict(item.strip().split("=", 1) for item in header.split(",") if "=" in item)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not re.fullmatch(r"[0-9]+", t) or not re.fullmatch(r"[0-9a-f]{64}", v1):
        return False
    if abs((time.time() if now is None else now) - int(t)) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

```php PHP theme={null}
<?php

const AGENTOVA_TOLERANCE_SECONDS = 300;

/**
 * $rawBody: the request body exactly as received: file_get_contents('php://input'),
 *           or $request->getContent() with Laravel.
 * $header:  the X-Agentova-Signature header: $_SERVER['HTTP_X_AGENTOVA_SIGNATURE'] ?? null.
 * $secret:  the subscription's full secret, "whsec_" prefix included.
 */
function is_valid_agentova_signature(string $rawBody, ?string $header, string $secret, ?int $now = null): bool
{
    if ($header === null) {
        return false;
    }
    $parts = [];
    foreach (explode(',', $header) as $item) {
        $pair = explode('=', trim($item), 2);
        if (count($pair) === 2) {
            $parts[$pair[0]] = $pair[1];
        }
    }
    $t = $parts['t'] ?? '';
    $v1 = $parts['v1'] ?? '';
    if (!ctype_digit($t) || strlen($v1) !== 64 || !ctype_xdigit($v1)) {
        return false;
    }
    if (abs(($now ?? time()) - (int) $t) > AGENTOVA_TOLERANCE_SECONDS) {
        return false;
    }
    return hash_equals(hash_hmac('sha256', $t . '.' . $rawBody, $secret), $v1);
}
```

> [!NOTE]
>
> The official connectors verify signatures for you: with the n8n node or the Zapier app, you don't need this section. The Zapier app accepts signatures up to one hour old, because Zapier can process a notification with a delay and its notification address is already secret. For your own endpoint, keep 5 minutes.

### Test vector

Check your implementation against these values before going live:

| Input | Value |
| - | - |
| Secret | `whsec_0123456789abcdef0123456789abcdef` |
| Timestamp `t` | `1790856000` |
| Raw body | below: 333 bytes of UTF-8, on a single line, with no newline at the end |

```json Raw body theme={null}
{"id":"evt_example0001","type":"automation.status_changed","created_at":"2026-10-01T12:00:00Z","data":{"id":"aut_example123","name":"Comment replies — Support Agent","type":"social_comments","provider":"instagram_page","agent_id":"agt_example456","agent_name":"Support Agent","status":"paused","updated_at":"2026-10-01T12:00:00Z"}}
```

Expected header:

```
X-Agentova-Signature: t=1790856000,v1=cc0aeaeeb19b6d50d7c200ece4653adfba91c3a9f6cb2223ca61cd3965bf55ee
```

With these inputs and the current time set to `1790856000`, your function must return `true`. It must return `false` when one character of the body changes, when the current time is more than 300 seconds away from `t`, and when the secret is used without its `whsec_` prefix.

| If you compute `v1` = | You probably |
| - | - |
| `2150ccec9089a1d677a2d02d01376a6df64ca89af12b341ba35695d33e2fc261` | removed the `whsec_` prefix from the secret |
| `5c093661d42f63c53b0fb8ec6b2ec76d873cd350c59a9ea436493f72ca477adb` | added a newline at the end of the body |

> [accordion] Reproduce the vector from a terminal
>
> ```bash theme={null}
> printf '%s.%s' 1790856000 '{"id":"evt_example0001","type":"automation.status_changed","created_at":"2026-10-01T12:00:00Z","data":{"id":"aut_example123","name":"Comment replies — Support Agent","type":"social_comments","provider":"instagram_page","agent_id":"agt_example456","agent_name":"Support Agent","status":"paused","updated_at":"2026-10-01T12:00:00Z"}}' \
>   | openssl dgst -sha256 -hmac 'whsec_0123456789abcdef0123456789abcdef'
> ```
>
> The output ends with `cc0aeaeeb19b6d50d7c200ece4653adfba91c3a9f6cb2223ca61cd3965bf55ee`.

## Responding to a delivery

Agentova counts a delivery as successful when your endpoint answers with a **2xx status within 10 seconds** — the whole exchange, not only the first byte. The body of your response is ignored.

Anything else is a failed attempt: another status, a timeout, a connection error — and any **3xx** too: redirects are never followed, so subscribe the final URL.

Two answers have a special meaning:

| Your endpoint answers | What Agentova does |
| - | - |
| `410 Gone` | Deletes the subscription immediately, without notifying anyone. Use it to say “this address is gone for good”. |
| `429` or `503` with a `Retry-After` header | Waits at least that long before the next attempt, up to 12 hours — unless that would take the next attempt more than 36 hours after the event was created: the delivery is then abandoned. |

## Retries

A failed delivery is retried, for **8 attempts** in all over about 27 and a half hours:

| Attempt | Sent |
| - | - |
| 1 | Right away |
| 2 | 5 seconds after the first |
| 3 | 5 minutes after the second |
| 4 | 30 minutes after the third |
| 5 | 2 hours after the fourth |
| 6 | 5 hours after the fifth |
| 7 | 10 hours after the sixth |
| 8 | 10 hours after the seventh |

Every attempt carries the **same event `id`**, with a fresh signature (a new `t`). After the 8th failed attempt, the delivery is abandoned. A `Retry-After` can push an attempt later than this schedule, but no attempt is ever scheduled more than **36 hours** after the event was created: when the next one would be, the delivery is abandoned.

## When a subscription is disabled

When a delivery is abandoned, Agentova **disables** the subscription if both are true:

* the subscription has at least **3 abandoned deliveries in a row**;
* it has been failing **without interruption for at least 72 hours**.

A successful delivery ends the streak.

A subscription is also disabled **at once, without any delivery**, when its URL no longer passes Agentova's address checks — for example a URL with an IP address that has become forbidden.

Once a subscription is disabled, nothing more is sent to it, `disabled_at` is set on it in `GET /webhooks`, and the workspace admins are notified in the Agentova app and by a push notification.

A disabled subscription can't be reactivated: **delete it and create a new one**. With the official connectors, that means turning the Zap off and on again (Zapier), or deactivating and reactivating the workflow (n8n). A disabled subscription is deleted automatically 30 days after it was disabled.

## While the API is switched off

Agentova can switch the API off temporarily: every call then gets `503 service_unavailable`. During such a break:

* **no delivery is created** for the events that happen in the meantime — they will never be sent;
* the deliveries that were already in progress are **suspended**, and resume when the API is back.

When the API answers again, catch up on what you missed with the list endpoints: `GET /leads`, `GET /runs` and `GET /automations`.

## Handling a delivery

1. Verify the signature (above), on the raw body, before parsing it.
2. Answer `2xx` within 10 seconds, and do the heavy work afterwards — in a queue or a background job.
3. Deduplicate on `id`: keep the IDs you already processed for at least 48 hours (retries are always scheduled within 36 hours of the event's creation), and answer `2xx` to a duplicate without processing it again.
4. Don't rely on the order of arrival: compare `updated_at` (automation), `created_at` (lead) or `occurred_at` (run).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.