> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cloak.chat/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Types, methods, caps and errors for the plaintext webhook embed lane.

<Warning>
  Everything on this page posts messages that are **not end-to-end encrypted** and are attributed to a webhook identity rather than to your bot. The payload type is called `UnencryptedWebhookPost` for that reason. For the encrypted alternative, see [cards and embeds](/api-reference/embeds).
</Warning>

For when to use this lane at all, read [webhook embeds](/guides/webhook-embeds) first.

```ts theme={null}
import { CloakWebhookError, postToWebhook, MAX_WEBHOOK_CONTENT } from '@cloak-software/bot-sdk';
import type {
  Embed,
  UnencryptedWebhookPost,
  WebhookInfo,
  WebhookFetch,
  WebhookApi,
  WebhookMint,
} from '@cloak-software/bot-sdk';
```

## sendEmbed

```ts theme={null}
client.sendEmbed(serverId: string, channelId: string, post: UnencryptedWebhookPost): Promise<void>
client.guild(serverId).sendEmbed(channelId: string, post: UnencryptedWebhookPost): Promise<void>
```

Finds or mints a webhook for the channel and posts through it. Requires `manage_server`, because minting is a server management action.

The payload is validated before anything is minted, so a post that will be refused does not burn one of the 10 creations per account per hour. On a `401` the SDK drops its cached URL, re-mints, and retries exactly once. Reuse only ever adopts a webhook the bot itself created.

## webhooks

`client.webhooks` implements `WebhookApi`. `client.guild(serverId).webhooks` exposes the same methods with the `serverId` argument dropped.

### list

```ts theme={null}
webhooks.list(serverId: string): Promise<WebhookInfo[]>
```

Every webhook on a server. Requires `manage_server`. Never includes a token, by design.

### create

```ts theme={null}
webhooks.create(
  serverId: string,
  channelId: string,
  name: string,
  opts?: { icon?: string; miniIcon?: string },
): Promise<WebhookMint>
```

Mints a webhook on a channel and returns its one-time delivery URL. Requires `manage_server`. Capped at 15 per channel and 10 creations per account per hour.

`icon` and `miniIcon` are already-hosted image URLs. The SDK never uploads, because file upload is closed to bots. The server sanitizes them on write: a non-Cloak URL is rewritten through the image proxy, or dropped when the proxy is unconfigured.

### edit

```ts theme={null}
webhooks.edit(
  serverId: string,
  webhookId: string,
  changes: { name?: string; icon?: string; miniIcon?: string; channelId?: string },
): Promise<void>
```

Rename, re-icon, or move a webhook to another channel. Requires `manage_server`. Omitted fields are left alone. `icon` and `miniIcon` are written as a pair, so passing `miniIcon` without `icon` does nothing.

### regenerate

```ts theme={null}
webhooks.regenerate(serverId: string, webhookId: string): Promise<WebhookMint>
```

Mints a fresh token for an existing webhook and returns its new URL. Requires `manage_server`.

<Warning>
  This permanently invalidates the previous URL. Anything still posting to it starts getting `401`. It is also the only way to obtain a URL for a webhook you did not just create, because `list()` never returns a token.
</Warning>

### delete

```ts theme={null}
webhooks.delete(serverId: string, webhookId: string): Promise<void>
```

Deletes a webhook. Requires `manage_server`. Messages it already posted keep rendering, because the identity is stamped on each row.

### post

```ts theme={null}
webhooks.post(url: string, post: UnencryptedWebhookPost): Promise<void>
```

POSTs to a delivery URL. Requires no permissions at all, which makes it the recommended path: a human creates the webhook in the Cloak app, you put the URL in an environment variable, and your bot never needs `manage_server`.

Rejects with `CloakWebhookError` on anything but a `204`. The URL never appears in the error.

## postToWebhook

The standalone function behind `webhooks.post()`. Use it when you have no `Client` at all.

```ts theme={null}
postToWebhook(
  fetchImpl: WebhookFetch | undefined,
  url: string,
  post: UnencryptedWebhookPost,
): Promise<void>
```

Pass `undefined` for `fetchImpl` to use `globalThis.fetch`. It runs no retries and holds no state: the mint, reuse and re-mint-on-`401` policy lives in `sendEmbed()`.

Before the request it checks the `unencrypted: true` acknowledgement, requires an absolute URL, and refuses plain `http://` to anything but `localhost`, `127.0.0.1` or `::1`, because the token secret is a path component of the URL. Its own POST timeout is 15 seconds.

## UnencryptedWebhookPost

<ResponseField name="unencrypted" type="true" required>
  Required acknowledgement that this message is not end-to-end encrypted. There is no default and no way to omit it. It is re-checked at runtime, and it never reaches the wire.
</ResponseField>

<ResponseField name="content" type="string">
  Plain text body, up to 2000 characters. Either this or a non-empty `embeds` is required.
</ResponseField>

<ResponseField name="embeds" type="Embed[]">
  First 10 kept. The serialized JSON is capped at 6000 bytes measured after the server rewrites every media URL through its image proxy, so your real budget is smaller. The SDK does not pre-check it.
</ResponseField>

<ResponseField name="username" type="string">
  Per-post display-name override, honored at 1 to 80 characters. Out-of-range values fall back to the webhook's stored name. This is the real win of the lane: one webhook can speak as many personas.
</ResponseField>

<ResponseField name="avatar_url" type="string">
  Per-post avatar, as an already-hosted image URL. The SDK never uploads. Honored only when the server's image proxy is configured, and otherwise the webhook's stored icon is used while the POST still returns success.
</ResponseField>

## Embed

A Discord-shaped rich embed. Field names are wire names in `snake_case`, so do not camelCase them. Unknown keys are passed through untouched.

This type is deliberately **not** the same as [`BotEmbed`](/api-reference/embeds#botembed) on the encrypted lane. The caps differ, this one has `video` and `provider.icon`, and its media is proxied rather than restricted to a single origin.

<ResponseField name="title" type="string">
  Truncated server-side to 256 characters.
</ResponseField>

<ResponseField name="description" type="string">
  Truncated server-side to 4096 characters. Markdown links render here.
</ResponseField>

<ResponseField name="url" type="string">
  Click-through target for the title. Never proxied, never stripped.
</ResponseField>

<ResponseField name="color" type="number | string">
  A decimal int (`0x24292f`) or a CSS color string (`'#24292f'`). The renderer accepts both.
</ResponseField>

<ResponseField name="timestamp" type="string">
  ISO-8601, rendered in the footer.
</ResponseField>

<ResponseField name="type" type="'rich'">
  Optional. The server does not require it.
</ResponseField>

<ResponseField name="author" type="{ name?: string; url?: string; icon_url?: string }">
  `name` truncated to 256. `icon_url` is proxied, or stripped when the proxy is unconfigured.
</ResponseField>

<ResponseField name="footer" type="{ text?: string; icon_url?: string }">
  `text` truncated to 2048. `icon_url` is proxied or stripped.
</ResponseField>

<ResponseField name="provider" type="{ name?: string; icon?: string }">
  `icon` is proxied or stripped.
</ResponseField>

<ResponseField name="fields" type="{ name: string; value: string; inline?: boolean }[]">
  First 25 kept. `name` truncated to 256, `value` to 1024. `inline` defaults to **true** on this backend, unlike Discord's default of false, so pass it explicitly if you care.
</ResponseField>

<ResponseField name="image" type="{ url: string }">
  Proxied or stripped.
</ResponseField>

<ResponseField name="thumbnail" type="{ url: string }">
  Proxied or stripped.
</ResponseField>

<ResponseField name="video" type="{ url: string }">
  Proxied or stripped.
</ResponseField>

<Warning>
  Every URL a viewer auto-fetches is rewritten through the server's signed image proxy, and the field is stripped when `IMGPROXY_SECRET` and `IMGPROXY_PUBLIC_BASE` are unset or the URL is not plain `http(s)`. The POST still returns `204`. Text and click-through links always survive.
</Warning>

The server normalizes leniently: out-of-spec payloads are truncated rather than rejected, except for the hard `400`s on `content` length, embed size, request body size, and an empty post.

## WebhookInfo

One row from `webhooks.list()`.

<ResponseField name="id" type="string">
  The webhook id. Not a secret.
</ResponseField>

<ResponseField name="name" type="string">
  The display name it posts under.
</ResponseField>

<ResponseField name="channelId" type="string">
  The channel it posts into.
</ResponseField>

<ResponseField name="miniIcon" type="string | null">
  The small avatar it posts under, or `null`. Cloak-hosted or proxied, because the server sanitizes stored icons on write.
</ResponseField>

<ResponseField name="creatorId" type="string | null">
  The user who created it, or `null` on a row with no creator recorded. The reuse path in `sendEmbed()` matches on this, and a row with no creator is never adopted.
</ResponseField>

<ResponseField name="createdAt" type="Date | null">
  When it was created, or `null` when the row carried no usable timestamp.
</ResponseField>

## WebhookMint

Returned by `create()` and `regenerate()`.

<ResponseField name="id" type="string">
  The webhook id. Not a secret.
</ResponseField>

<ResponseField name="url" type="string">
  The delivery URL. It embeds the token secret, the server stores only its hash, and it is returned exactly once. Treat it like a password: never commit it, never log it, never write it to your keystore.
</ResponseField>

## WebhookFetch

The HTTP seam the lane posts through. Structural on purpose, so `globalThis.fetch` satisfies it and tests can pass an object literal.

```ts theme={null}
type WebhookFetch = (
  url: string,
  init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal },
) => Promise<{ status: number; text(): Promise<string> }>;
```

Supply your own with `ClientOptions.webhookFetch` to route posts through a proxy or a pinned agent.

## Client options for this lane

<ResponseField name="webhookName" type="string">
  Display name for the webhook `sendEmbed()` mints. Defaults to `'Bot'`. Cosmetic only: reuse is matched on creator and channel, never on name, so renaming it does not orphan an existing webhook.
</ResponseField>

<ResponseField name="webhookFetch" type="WebhookFetch">
  Injected `fetch` for the webhook ingress. Defaults to `globalThis.fetch`.
</ResponseField>

## CloakWebhookError

A delivery failure, meaning the HTTPS POST itself. A CRUD denial on `list`, `create`, `edit`, `regenerate` or `delete` arrives as `CloakActionError` instead, carrying the missing permission.

<ResponseField name="name" type="string">
  Always `'CloakWebhookError'`.
</ResponseField>

<ResponseField name="status" type="number">
  The HTTP status, or `0` for a transport or pre-check failure such as DNS, TLS, timeout or abort.
</ResponseField>

<ResponseField name="detail" type="string">
  The ingress body text: `'content'`, `'embeds'`, `'embeds too large'`, `'empty'`, `'unauthorized'`, `'rate limited'` or `'delivery failed'`. When `status` is `0` it is an SDK-side label instead: `'transport'` or `'precheck'`.
</ResponseField>

<ResponseField name="webhookId" type="string | undefined">
  The webhook this post targeted, when known. Never a secret.
</ResponseField>

<Warning>
  The delivery URL contains the token secret, so it never appears in `message`, in a field, or in `toString()`. Do not add it back when you log the error.
</Warning>

## Constants

| Constant                  | Value  | Meaning                                                                                         |
| ------------------------- | ------ | ----------------------------------------------------------------------------------------------- |
| `MAX_WEBHOOK_CONTENT`     | `2000` | Hard `400` on `content` length. Pre-checked by the SDK                                          |
| `MAX_WEBHOOK_EMBEDS_JSON` | `6000` | Hard `400 embeds too large`, measured after the proxy rewrite, so not pre-checkable client-side |

## Next

<CardGroup cols={2}>
  <Card title="Webhook embeds" icon="triangle-exclamation" href="/guides/webhook-embeds">
    The guide: what you give up, the low-privilege path, and server prerequisites.
  </Card>

  <Card title="Cards and embeds" icon="id-card" href="/api-reference/embeds">
    The encrypted lane, which most bots should use instead.
  </Card>

  <Card title="Errors" icon="bug" href="/api-reference/errors">
    Every error class the SDK raises, and which surface raises it.
  </Card>

  <Card title="The guild() handle" icon="server" href="/guides/guild-handle">
    The same webhook surface, pre-bound to one server.
  </Card>
</CardGroup>
