The full bot
embed-bot.ts
Run it
From a checkout of the SDK, putCLOAK_TOKEN and CLOAK_WEBHOOK_URL in a .env file and run:
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
The trade you are making
The trade you are making
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.webhooks.post() needs no permissions at all
webhooks.post() needs no permissions at all
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.sendEmbed() mints or reuses a webhook, against a quota
sendEmbed() mints or reuses a webhook, against a quota
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.Guarding on serverId before anything server-scoped
Guarding on serverId before anything server-scoped
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.Images can vanish with no error
Images can vanish with no error
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.Two error types, and neither leaks the url
Two error types, and neither leaks the url
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.Two shape differences from Discord
Two shape differences from Discord
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.