Webhooks
Three things are called a webhook here, and each works differently.
Interaction follow-ups
interaction.followup sends more messages after the first answer. It is not a URL: commands, presses and forms
travel sealed inside the conversation, so there is no interaction token to post to. Follow-ups are taken for 15 minutes.
import illest
async def slow(interaction: illest.Interaction):
await interaction.response.defer()
await interaction.followup.send("Done.")Server webhooks
A server can send its audit log to an address of yours: every ban, kick, role change, channel change and so on, as it happens. Its managers set them up in the server's settings, under Bots & webhooks; managing them needs the Manage webhooks permission. A server holds up to 15.
Each webhook can listen to some actions only, and to one channel only. It never carries a message: the server cannot read one.
What arrives
A POST with a JSON body, one per audit line:
| Field | What it holds |
|---|---|
guild_id | the server |
action | what happened, such as member.ban or channel.update |
actor_id, actor_name | who did it |
target_id, target_name | who or what it was done to, when anybody |
detail | an object with what changed, when the action has detail |
at | when, in seconds since 1970 |
channel_id | the channel, for an action about one |
The request carries content-type: application/json, user-agent: illest-webhook/1 and x-illest-signature.
Checking it came from the server
x-illest-signature is the HMAC-SHA256 of the raw body, keyed with the webhook's secret, written as hex. The secret is shown when
the webhook is made. Compare in constant time, over the bytes as they arrived:
import hashlib
import hmac
def verify(secret: str, body: bytes, signature: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Delivery
- The server sends from its own network after the change is saved, and waits up to eight seconds for an answer. A
2xxis delivered; anything else, a redirect included, is a failure. - Twenty failures in a row switch the webhook off, and the server's settings say why. Switching it on again starts the count over.
- Addresses inside a private network are refused, whatever a name resolves to.
Application events
An application can name one address of yours that hears when somebody starts or stops using it. Set it on the application's Webhooks page, here or in the app under Settings, Developers. Its developers, admins and owner may change it; anybody on its team can see it.
Saving an address sends it a signed ping first. It is saved only if it answers with any 2xx within five seconds.
Events
type | When | data |
|---|---|---|
application.authorized | somebody adds it to a server, or lets it sign them in (or allows it more than before) | integration (guild or user), user, scopes; for a server also guild and permissions |
application.deauthorized | somebody takes back the sign-in they allowed | user |
ping | the address is saved, or somebody presses Send a test event | nothing |
A webhook hears both events unless its page says otherwise. Every body has the same four fields:
{
"type": "application.authorized",
"timestamp": "2026-10-31T18:02:11Z",
"application_id": "118846923871977472",
"data": {
"integration": "guild",
"user": { "id": "118846923871977473", "username": "ada" },
"guild": { "id": "118846923871977474", "name": "Night Shift" },
"scopes": ["bot"],
"permissions": "65536"
}
}Ids are strings, so no language rounds them.
Checking it came from ILLEST
Each request is signed the way Standard Webhooks sign with a public key, version v1a:
| Header | What it holds |
|---|---|
webhook-id | the event's id, msg_ and letters; the same on every retry of one event, so you can skip one you have already handled |
webhook-timestamp | when this attempt was sent, in seconds since 1970 |
webhook-signature | v1a, and the base64 Ed25519 signature of {webhook-id}.{webhook-timestamp}.{body} |
The key to check it with is on the Webhooks page: whpk_ and the base64 of an Ed25519 public key. It is public, so it can sit in
your code or your config; only ILLEST holds the private half. Check the bytes of the body as they arrived, before parsing them,
and refuse a timestamp more than five minutes from your clock. The header may one day carry several signatures, separated by
spaces; accept the request if any v1a one checks.
import base64
import time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def verified(public_key: str, headers, body: bytes) -> bool:
key = Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key.removeprefix("whpk_")))
stamp = headers["webhook-timestamp"]
if abs(time.time() - int(stamp)) > 300:
return False
signed = f"{headers['webhook-id']}.{stamp}.".encode() + body
for part in headers["webhook-signature"].split():
version, _, signature = part.partition(",")
if version != "v1a":
continue
try:
key.verify(base64.b64decode(signature), signed)
return True
except InvalidSignature:
pass
return FalseIn Node, with nothing to install:
import { createPublicKey, verify } from "node:crypto";
// What comes before a raw Ed25519 key in the form node:crypto reads.
const PREFIX = Buffer.from("302a300506032b6570032100", "hex");
export function verified(publicKey, headers, body) {
const raw = Buffer.from(publicKey.slice("whpk_".length), "base64");
const key = createPublicKey({ key: Buffer.concat([PREFIX, raw]), format: "der", type: "spki" });
const stamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(stamp)) > 300) return false;
const signed = Buffer.concat([Buffer.from(`${headers["webhook-id"]}.${stamp}.`), body]);
return headers["webhook-signature"].split(" ").some((part) => {
const [version, signature] = part.split(",");
return version === "v1a" && verify(null, signed, key, Buffer.from(signature, "base64"));
});
}Delivery
- An event is sent once what it tells of is saved. Answer with any
2xxwithin five seconds; do slow work after answering. - A failure (no answer in time, a refused connection, a redirect or any other status) is retried after 5 seconds, 30 seconds,
90 seconds, 3 minutes and 5 minutes: six tries over about ten minutes, each signed afresh, each with the same
webhook-id. - An event that fails all six is lost. Three lost in a row switch the webhook off; one delivered starts the count over.
410 Goneswitches it off at once, with no retry. The page says when and why, and the application's Activity records it. Saving the address again turns it back on. - Events are not queued while it is off, and their order is not promised: use
timestampto order them. - Addresses inside a private network are refused, whatever a name resolves to.