# Set up and verify webhooks

:::summary Quick answer
Flownally sends a signed `POST` to your endpoint when contacts, conversations or messages change. To set it up, get the signing key, save your `https` URL and event types, then verify the endpoint. For each delivery, verify the signature on the raw body, skip events you've already processed, and return a `2xx` within 15 seconds.

**Flownally delivers events only while the webhook is enabled and verified.**
:::

Webhooks send an HTTP `POST` to your server when contacts, conversations or messages change, so you don't have to poll the API.

Each workspace has one webhook endpoint, and every delivery is signed with [Standard Webhooks](https://www.standardwebhooks.com/) headers.

Webhooks are for code you run. To build WhatsApp replies and follow-ups in the app instead, see the [WhatsApp automation tool](https://www.flownally.com/uses/whatsapp-automation-tool).

## Which events can I subscribe to?

| Event | Sent when |
| --- | --- |
| `contact.created` | A contact is created. |
| `contact.updated` | A contact changes. |
| `contact.archived` | A contact is archived. |
| `conversation.started` | A session starts in a conversation. |
| `conversation.updated` | An open session's status or owner changes. |
| `conversation.closed` | A session is closed. |
| `message.created` | A message is sent or received. |
| `message.updated` | A message changes, for example its delivery status or reactions. |

`GET /webhooks` returns the event types your workspace can subscribe to in `availableEventTypes`. For each event's payload, see the [Webhooks reference](/api/reference/webhooks#receive-webhook-event).

## Set up the endpoint

Your endpoint must use `https`. Setting it up takes three requests.

### 1. Get the signing key

You need the signing key to verify every delivery, and later calls return metadata only. The first call in a workspace creates the key and returns it once in `plaintextKey`, in the form `whsec_…`.

Store it in your secret manager.

:::code
```bash
curl "https://api.flownally.com/v1/webhooks/signing-keys" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN"
```

```js
const response = await fetch("https://api.flownally.com/v1/webhooks/signing-keys", {
  headers: { Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}` },
});

const { plaintextKey } = await response.json();
```

```python
import os

import requests

response = requests.get(
    "https://api.flownally.com/v1/webhooks/signing-keys",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
)
response.raise_for_status()
plaintext_key = response.json()["plaintextKey"]
```

```php
$client = new GuzzleHttp\Client();

$response = $client->get('https://api.flownally.com/v1/webhooks/signing-keys', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
]);
$plaintextKey = json_decode((string) $response->getBody(), true)['plaintextKey'];
```
:::

### 2. Save the URL and events

:::code
```bash
curl -X PATCH "https://api.flownally.com/v1/webhooks" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "url": "https://example.com/flownally/webhooks",
    "eventTypes": ["contact.created", "contact.updated", "message.created"]
  }'
```

```js
await fetch("https://api.flownally.com/v1/webhooks", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    enabled: true,
    url: "https://example.com/flownally/webhooks",
    eventTypes: ["contact.created", "contact.updated", "message.created"],
  }),
});
```

```python
import os

import requests

response = requests.patch(
    "https://api.flownally.com/v1/webhooks",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
    json={
        "enabled": True,
        "url": "https://example.com/flownally/webhooks",
        "eventTypes": ["contact.created", "contact.updated", "message.created"],
    },
)
response.raise_for_status()
```

```php
$client = new GuzzleHttp\Client();

$client->patch('https://api.flownally.com/v1/webhooks', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
    'json' => [
        'enabled' => true,
        'url' => 'https://example.com/flownally/webhooks',
        'eventTypes' => ['contact.created', 'contact.updated', 'message.created'],
    ],
]);
```
:::

### 3. Verify the endpoint

Flownally sends a signed `webhook.test` event to the saved URL. When your endpoint answers with a `2xx` status, the response has `"delivered": true` and the webhook is marked `verified`.

:::code
```bash
curl -X POST "https://api.flownally.com/v1/webhooks:verify" \
  -H "Authorization: Bearer $FLOWNALLY_API_TOKEN"
```

```js
const response = await fetch("https://api.flownally.com/v1/webhooks:verify", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}` },
});

const { delivered } = await response.json();
```

```python
import os

import requests

response = requests.post(
    "https://api.flownally.com/v1/webhooks:verify",
    headers={"Authorization": f"Bearer {os.environ['FLOWNALLY_API_TOKEN']}"},
)
response.raise_for_status()
delivered = response.json()["delivered"]
```

```php
$client = new GuzzleHttp\Client();

$response = $client->post('https://api.flownally.com/v1/webhooks:verify', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('FLOWNALLY_API_TOKEN')],
]);
$delivered = json_decode((string) $response->getBody(), true)['delivered'];
```
:::

:::callout Verify again after every URL change
Changing `url` clears `verified`. Events that happen before verification succeeds aren't delivered later.
:::

## Handle a delivery

Every delivery has the same envelope:

```json
{
  "id": "whevt_…",
  "type": "contact.created",
  "timestamp": "2026-04-30T09:20:00Z",
  "data": { "contact": { "id": "con_…", "name": "Ada Lovelace" } }
}
```

The same event can arrive more than once, and an attempt that times out is retried. Handle each delivery in this order:

**Verify → Skip duplicates → Respond → Process**

1. Verify the signature against the raw request body, before you parse it.
2. Skip the event if you've already processed its `webhook-id`. The header equals the payload `id` and stays the same on every retry.
3. Return any `2xx` status within 15 seconds. Do slow work after you respond, for example from a queue.

Deliveries can arrive out of order, even within one conversation. For `message.updated`, keep the version with the highest `message.revision`.

## Verify signatures

Each delivery has three headers:

- `webhook-id`: the event ID.
- `webhook-timestamp`: the Unix time of this attempt, in seconds.
- `webhook-signature`: one or more signatures in the form `v1,<base64>`, separated by spaces.

The signature is an HMAC-SHA256 of `webhook-id.webhook-timestamp.body`, keyed with the base64-decoded part of the signing key after `whsec_`.

Use a [Standard Webhooks library](https://github.com/standard-webhooks/standard-webhooks) rather than writing the check yourself. The libraries also reject stale timestamps, which protects you from replayed requests.

During a key rotation, the header carries two signatures. Accept the delivery if any signature in the header matches.

For code in Node.js, Python and PHP, see [Verify webhook signatures](/api/webhook-signatures).

## Retries

Flownally makes up to 4 attempts per event: the first, then retries about 30 seconds, 5 minutes and 30 minutes later, with up to 20% random jitter.

- **What gets retried.** Timeouts, connection errors, `3xx`, `408`, `429` and `5xx` responses are retried. Redirects aren't followed.
- **What stops retries.** `410` and any other `4xx` response stop retries for that event.
- **`Retry-After` is honored after a `429`.** A value in seconds delays the next attempt. If that's more than 30 minutes after the first attempt, the event isn't retried.
- **The last attempt is final.** An event that fails its last attempt isn't sent again. Flownally never disables your endpoint automatically.

To debug deliveries, list the attempts from the last 7 days with `GET /webhooks/attempts`. Filter by `eventType` or `status`.

## Rotate the signing key

Call `POST /webhooks/signing-keys:rotate`. The response returns the new key once in `plaintextKey`.

Rotation has an overlap, so your receiver keeps working while you deploy. For 24 hours after rotation, deliveries carry signatures from both the old and the new key.

**Deploy the new key to your receiver within that window.** The old key then expires.

## Stop deliveries

To pause deliveries, send `PATCH /webhooks` with `"enabled": false`. To remove the webhook, call `DELETE /webhooks`.

Delivery attempts stay listed for the rest of their 7 days.

## FAQ

### Why aren't events arriving?

Check that the webhook is enabled and verified. Any change to `url` resets verification, so call `POST /webhooks:verify` again; events from before that call are never sent.

### Can the same event arrive twice?

Yes. Retries carry the same `webhook-id`, which equals the payload `id`. Skip events whose `webhook-id` you've already processed.

### Why do deliveries arrive out of order?

Flownally doesn't promise an order, not even within one conversation. Compare `message.revision` and keep the highest one you've seen.

### Will Flownally disable my endpoint after failures?

No. Flownally never disables your endpoint automatically. An event that fails its last attempt isn't sent again, so list recent attempts with `GET /webhooks/attempts` to find what you missed.
