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. 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
/api/v1/webhooksA profile's webhooks, without their secrets.POSTCreate webhook/api/v1/webhooksRegister a URL for events and get its signing secret, once.DELETEDelete webhook/api/v1/webhooks/{webhookId}Delete a webhook.Events
Every event has the same envelope:
{
"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 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:
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.