In short: Postbag ships three destination types: email, sent through Resend with Reply-To set from the submission; Telegram, a bot message rendered from a template; and webhook, a JSON POST signed with HMAC-SHA256 and retried with backoff. Webhooks are the universal extension point for CRMs, automation tools and your own systems.
Config: to[], cc[], subject_template (default "New submission: {{form.name}}"), from_name. Mail is sent from a Postbag domain with Reply-To set from the submission (settings.reply_to_field, defaulting to the first field that looks like an email), so replying to a notification answers the person who wrote in. Per-organization sending domains are planned for the commercial phase.
Telegram
Config: bot_token, chat_id, optional template. Submissions are rendered into an HTML-formatted bot message; values are escaped so a submission cannot inject markup. Create a bot with @BotFather, add it to your chat, and paste the token and chat id.
Webhook (signed)
Config: url, optional secret, optional headers. Postbag POSTs JSON with Postbag-Delivery (the delivery id), Postbag-Event (submission.received, digest.ready, …) and, when a secret is set, Postbag-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "{t}.{body}">. 2xx means sent, 410 means the destination disabled itself, anything else retries with backoff up to 10 attempts.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verify(secret: string, header: string, rawBody: string, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]))
const t = Number(parts.t)
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""))
} import { createHmac, timingSafeEqual } from "node:crypto"
export function verify(secret: string, header: string, rawBody: string, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]))
const t = Number(parts.t)
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""))
} Test before you trust
POST /v1/destinations/{id}/test sends a sample payload through the real adapter and returns the provider's response inline: status code, latency, an excerpt of the body. This is the agent's verification step, and a good habit for humans too.
What is next
Slack and Discord incoming webhooks are typed in the API and are the next adapters. Native CRM destinations (for example Dekhval) follow only once the webhook path has shown the pattern, because each native adapter is maintenance forever. Adding a destination type is one file implementing the DestinationAdapter interface: configSchema, redactConfig, test, deliver.