Skip to main content
This bot posts rich embeds three ways: through a webhook url a human created, through a webhook the bot mints itself, and it lists the webhooks on a server. It is the runnable companion to Webhook embeds.
Everything this bot posts is not end-to-end encrypted. A webhook message is stored on the Cloak server as plaintext by design, and it renders under a webhook identity (its own name and avatar, plus a “not encrypted” badge) rather than as your bot. The unencrypted: true field is required, and re-checked at runtime, so you cannot make that trade by accident.If the content is sensitive, use sendCard(), which is encrypted and posts under the bot’s own identity, or plain send().

The full bot

embed-bot.ts

Run it

From a checkout of the SDK, put CLOAK_TOKEN and CLOAK_WEBHOOK_URL in a .env file and run:
To run your own copy, pass the environment directly:
CLOAK_WEBHOOK_URL is optional. Without it, !embed refuses and !mint still works if the server granted manage_server. Then, in any text channel the bot can see:

How it works, piece by piece

Three things are true of every webhook post, and none of them are recoverable after the fact.The message is stored server-side as readable text. It is attributed to a webhook identity, not to your bot. Your bot will not receive it back on messageCreate, so you cannot read your own embeds back (they surface as a systemMessage with type: 'unknown').unencrypted: true is required on the payload and re-checked at runtime, so a plain JavaScript caller cannot skip it either. The payload type is named UnencryptedWebhookPost for the same reason. See Choosing an embed lane.
client.webhooks.post(url, post) is the low-privilege path and the one to default to. A human creates the webhook in the Cloak app, the operator hands the bot its url in an env var, and the bot holds no manage_server, no CRUD rights, and no ability to rotate a token somebody else’s integration depends on.The delivery url is a bearer credential. It embeds the token secret, the server stores only its hash, and it is returned exactly once. Never commit it, never log it, never write it into your keystore.
client.sendEmbed(serverId, channelId, post) finds or mints a webhook for that channel and posts through it. It requires manage_server, which most bots should not hold.On a cold start it lists the server’s webhooks, looks for one in that channel that this bot itself created, and rotates its token to obtain a usable url. Webhooks a human created are never adopted: rotating one would silently break whatever was posting to it. Only when there is no match does it mint a new one, named after ClientOptions.webhookName (default 'Bot').That reuse path is load-bearing. Creations are capped at 10 per account per hour and 15 per channel, so a restart loop that minted every time would exhaust the quota. The minted url is cached in memory only and never persisted. If someone rotates or deletes the webhook underneath you, the next post gets a 401, and sendEmbed() re-mints and retries exactly once.
Message.serverId is string | null. It is null for a direct message, because a DM has no server at all. Direct messages arrive on the same messageCreate event as server traffic.Webhooks are server-scoped, so this bot returns early on if (msg.isDM || !msg.serverId) return;. After that guard, TypeScript narrows msg.serverId to string and client.guild(msg.serverId) and client.sendEmbed(msg.serverId, ...) both typecheck. Every bot that does server-scoped work from messageCreate needs this line. See Direct messages.
Every URL a viewer would auto-fetch (image.url, thumbnail.url, video.url, author.icon_url, footer.icon_url, provider.icon, and a per-post avatar_url) is rewritten through the Cloak server’s signed image proxy, so viewers never hit a third-party origin.When that proxy is unconfigured server-side, the field is stripped and the POST still returns success. There is no error, no warning, and no way to tell from the response. Text and click-through links always survive: title, description, url, color, timestamp, fields, author.name, provider.name, and footer.text.Quick check against a live deployment: set avatar_url on a post. If the avatar does not change, the proxy is off and your image will vanish too.
CloakWebhookError is a delivery failure: the HTTPS POST itself. It carries status (or 0 for a transport failure) and detail, a short label such as 'content', 'embeds too large', 'empty', 'unauthorized', or 'rate limited'.A CRUD denial on list, create, edit, regenerate, or delete is different: it rejects with CloakActionError, which carries the backend code and a permission field naming what was missing.Neither ever contains the delivery url. CloakWebhookError carries only the non-secret webhookId.
color accepts either a Discord decimal int (0x22c55e) or a CSS color string ('#22c55e'). The Cloak renderer takes both.fields[].inline defaults to true on this backend, not false. Pass it explicitly if you care.Caps are enforced leniently (out-of-spec payloads are truncated) except for four hard 400s: content over 2000 characters, serialized embeds over 6000 bytes, a request body over 64 KiB, and a post with no content and no surviving embeds. The 6000-byte budget is measured after the proxy rewrite lengthens every media URL, so your real budget is smaller.

Next

Webhook embeds

The full webhook lane: CRUD, quotas, and the server-side configuration it depends on.

Rich cards

The encrypted embed lane, posted under your bot’s own identity.

Choosing an embed lane

A short decision page between the two.

Webhooks reference

WebhookApi, UnencryptedWebhookPost, Embed, and the caps table.