# Webhooks

> Signed callbacks for new Instagram comments and postback button taps.

Canonical: <https://www.tryadeli.com/docs/api/webhooks>

Register a URL and Adeli POSTs a signed JSON event to it when someone comments
on a connected Instagram account's post, or taps a postback button you sent with
[Send Instagram message](https://www.tryadeli.com/docs/api/messages/send-instagram-message). Together
they are everything a comment-to-DM automation needs.

Webhooks belong to one profile, and cover its Instagram accounts. An account
connected before Unified Login through a Facebook Page lacks the comments
permission, so it sends no events until it is reconnected.

## Endpoints

- `GET /api/v1/webhooks` — [List webhooks](https://www.tryadeli.com/docs/api/webhooks/list-webhooks.md)
- `POST /api/v1/webhooks` — [Create webhook](https://www.tryadeli.com/docs/api/webhooks/create-webhook.md)
- `DELETE /api/v1/webhooks/{webhookId}` — [Delete webhook](https://www.tryadeli.com/docs/api/webhooks/delete-webhook.md)

## Events

Every event has the same envelope:

```json
{
  "id": "evt_3f1c2b9a8d7e6f5a4b3c2d1e0f9a8b7c",
  "type": "comment.created",
  "createdAt": "2026-10-27T21:08:59.000Z",
  "profileId": "00000000-0000-4000-8000-000000000001",
  "platform": "instagram",
  "accountId": "00000000-0000-0000-0000-000000000000",
  "data": {}
}
```

| `type`             | When                                                               | `data`                                                                                                                                                                                                                                                                |
| ------------------ | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `comment.created`  | Someone comments on, or replies under, one of the account's posts. | `commentId`, `mediaId`, `parentId` (null for a top-level comment), `text`, `from: { id, username }`, and `isSelf`, true when the account commented itself. `from.id` is the commenter's Instagram-scoped id: send it a private reply with `recipient: { commentId }`. |
| `message.postback` | Someone taps a postback button the account sent.                   | `senderId` (their Instagram-scoped id, for `recipient: { id }`), `payload` (the button's payload), `title` (the button's title), `messageId`.                                                                                                                         |

Events arrive for every new comment, whether or not anyone has opened the post in
Adeli.

## Verifying signatures

Each request carries three headers:

| Header             | Value                                   |
| ------------------ | --------------------------------------- |
| `Adeli-Signature`  | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |
| `Adeli-Event-Id`   | The event's `id`                        |
| `Adeli-Event-Type` | The event's `type`                      |

`v1` is the HMAC-SHA256 of `<t>.<raw request body>`, keyed with the webhook's
signing secret, which [Create webhook](https://www.tryadeli.com/docs/api/webhooks/create-webhook) returns
once. Compute it over the raw body before parsing JSON, compare in constant
time, and reject a `t` more than five minutes from your clock, so a captured
request cannot be replayed:

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

export function verify(rawBody: string, header: string | null, secret: string) {
  const parts = new Map((header ?? "").split(",").map((part) => part.split("=", 2) as [string, string]));
  const t = Number(parts.get("t"));
  const v1 = parts.get("v1") ?? "";
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300 || !/^[0-9a-f]{64}$/.test(v1)) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return timingSafeEqual(expected, Buffer.from(v1, "hex"));
}
```

## Delivery and retries

Answer with any `2xx` within 10 seconds; do slow work afterwards. Any other
status, a timeout or a network error is retried after 1 minute, 5 minutes,
30 minutes, 2 hours and 6 hours, six attempts in all. Redirects are not followed.
A `410 Gone` disables the webhook; create it again to resume.

Delivery is **at least once**, and Instagram itself sometimes sends the same
comment twice. The same event always has the same `id`, so record the ids you
have handled and ignore repeats.

To rotate a secret, delete the webhook and create it again.

## Local development

A webhook URL must be `https`, except `http://localhost` and
`http://127.0.0.1`, which are allowed so a local Adeli dev server can call an app
running on the same machine.
