ILLEST Developers

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.

Python
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:

FieldWhat it holds
guild_idthe server
actionwhat happened, such as member.ban or channel.update
actor_id, actor_namewho did it
target_id, target_namewho or what it was done to, when anybody
detailan object with what changed, when the action has detail
atwhen, in seconds since 1970
channel_idthe 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:

Python
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 2xx is 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

typeWhendata
application.authorizedsomebody 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.deauthorizedsomebody takes back the sign-in they alloweduser
pingthe address is saved, or somebody presses Send a test eventnothing

A webhook hears both events unless its page says otherwise. Every body has the same four fields:

JSON
{
  "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:

HeaderWhat it holds
webhook-idthe event's id, msg_ and letters; the same on every retry of one event, so you can skip one you have already handled
webhook-timestampwhen this attempt was sent, in seconds since 1970
webhook-signaturev1a, 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.

Python
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 False

In Node, with nothing to install:

JavaScript
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 2xx within 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 Gone switches 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 timestamp to order them.
  • Addresses inside a private network are refused, whatever a name resolves to.