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()andclient.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
systemMessagewithtype: 'unknown', never asmessageCreate. 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:- Many personas from one identity. Set
usernameandavatar_urlper post. One webhook can speak as your CI, your alerting, and your changelog. - 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.
- Reaching clients older than the card rollout. Those clients render a card’s message as its plain
contenttext. A webhook embed renders everywhere.
sendCard() or send() instead.
The recommended path, with no manage_server
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: nomanage_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.
Minting one yourself, which needs manage_server
sendEmbed() finds or mints a webhook for the channel and posts through it:
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 requiresmanage_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.WEBHOOK_INGRESS_PORT and WEBHOOK_PUBLIC_BASE
WEBHOOK_INGRESS_PORT and WEBHOOK_PUBLIC_BASE
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.IMGPROXY_SECRET and IMGPROXY_PUBLIC_BASE
IMGPROXY_SECRET and IMGPROXY_PUBLIC_BASE
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 hard400s.
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
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.