Skip to main content
Messages posted through this lane are not end-to-end encrypted, and they are not posted as your bot.A webhook message is written to the server’s database as readable text by design. The server, and anyone with server-side access, can read it. It is attributed to a webhook identity with its own name and avatar, and shipped Cloak clients render it with a webhook badge carrying a “not encrypted” notice. Nothing about this is recoverable after the fact.The payload type is called UnencryptedWebhookPost, and unencrypted: true is required on every post. That is deliberate: the trade should be impossible to make by accident.
For most bots the answer is rich cards instead. client.sendCard() is encrypted, posts under your bot’s own identity, needs only message_send, and has no quota. Read this page only if you need something the card lane cannot do.

What you give up

  • The message is plaintext on the server. client.send() and client.sendCard() encrypt in your process and Cloak stores ciphertext. A webhook post is stored as text.
  • It is not posted as your bot. It carries the webhook’s name and avatar, plus the “not encrypted” badge.
  • Your bot will not see it come back. Webhook messages arrive as a systemMessage with type: 'unknown', never as messageCreate. That conveniently avoids an echo loop, and it also means you cannot read your own embeds back.

When this lane is still the right choice

Three things only the webhook lane can do:
  1. Many personas from one identity. Set username and avatar_url per post. One webhook can speak as your CI, your alerting, and your changelog.
  2. Posting with no bot account at all. A human creates the webhook in the Cloak app, you put the URL in an environment variable, and anything that can make an HTTPS request can post.
  3. Reaching clients older than the card rollout. Those clients render a card’s message as its plain content text. A webhook embed renders everywhere.
If the content is sensitive, use sendCard() or send() instead. Have a human create the webhook in the Cloak app under Room settings > Webhooks, hand your bot the URL in an environment variable, and post to it. The bot needs no permissions at all for the post itself: no manage_server, no CRUD calls, no rate limit of its own, and no way to rotate a token somebody else’s integration depends on. Most bots should not hold manage_server.
unencrypted: true is re-checked at runtime, not just by the type, so a plain JavaScript caller cannot skip it. The key never reaches the wire, it is an acknowledgement to the SDK.
The delivery URL is a bearer credential. It embeds the webhook’s token secret, the server stores only a hash of it, and it is returned exactly once. webhooks.list() cannot get it back.Never commit it, never log it, never write it to your keystore. The SDK holds up its end: the URL never appears in an error message, an event, or a log line, and CloakWebhookError carries only the non-secret webhookId.

Minting one yourself, which needs manage_server

sendEmbed() finds or mints a webhook for the channel and posts through it:
On a cold start it lists the server’s webhooks and looks for one in that channel that the bot itself created, then rotates that webhook’s token to obtain a usable URL. Listing never returns a token, so there is no other way to get one. A webhook a human created is never adopted, because rotating it would silently break whatever was already posting to it. Only when there is no match does the SDK mint a new webhook, named after ClientOptions.webhookName (default 'Bot'). That reuse path is load-bearing rather than an optimization. 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. A bot posting to N channels mints N webhooks. The minted URL is cached in memory only and never persisted. If someone rotates or deletes the webhook out from under you, the next post gets a 401, and sendEmbed() re-mints and retries exactly once.

The CRUD surface

Every method below requires manage_server, except post() which requires nothing. All of them are also pre-bound on the server handle as client.guild(serverId).webhooks, with the serverId argument dropped. A denial on a CRUD call rejects with CloakActionError, carrying the permission that was missing. A failed delivery POST rejects with CloakWebhookError. See handling errors.

Two server settings decide whether any of this works

Both live on the Cloak server, not in your bot, and you cannot detect either from the SDK.
The webhook ingress does not run at all unless the port is set. WEBHOOK_PUBLIC_BASE defaults to an empty string, which makes the server hand back a relative /webhooks/{id}/{secret} path instead of an absolute URL.The SDK refuses to post to a non-absolute URL and names both variables in the error. It also refuses plain http:// to anything but localhost, 127.0.0.1 or ::1, because the token secret is a path component of the URL and would travel in cleartext.
Without them, every embed image and icon is silently dropped.Each 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 server’s signed image proxy so viewers never hit a third-party origin. When the proxy is unconfigured, or the URL is not plain http(s), 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 on 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.

Caps

The server normalizes leniently. Out-of-spec payloads are truncated rather than rejected, except for the four hard 400s. A proxied URL is longer than the original, and the 6000-byte embed budget is measured after the rewrite, so your real budget is smaller than 6000. The SDK deliberately does not pre-check it, because any client-side number would be wrong. The server answers 400 embeds too large instead. Two shape differences from Discord: color accepts either a decimal int or a CSS color string, and fields[].inline defaults to true here rather than false. Pass inline explicitly if you care.

Errors

CloakWebhookError is the delivery failure, raised by webhooks.post() and sendEmbed().
This lane reports failures differently from the encrypted one. client.send() and client.sendCard() are fire-and-forget: a server-side denial arrives later on the sendRejected event rather than rejecting the promise. A webhook post is an ordinary HTTPS request, so a failure rejects, and only a 204 counts as success.

A complete webhook embed bot

The SDK ships this as a runnable example that also covers minting and listing. Run it with npm run example:embed.

Next

Webhooks reference

Every method, type, constant and error on this lane.

Rich cards

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

Handling errors

Which failures throw, which arrive as events.

Permissions

What manage_server covers, and what it does not.