Skip to content

Destinations: email, Telegram and signed webhooks

A destination is somewhere submissions can go. Destinations are organization-level and reusable across routes, and every one of them can be tested from the API before it is trusted.

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.

Email

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.

verify.ts
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.

Questions, answered

Can I send to Zapier, Make or n8n?
Yes, through a webhook destination. Those tools give you a catch-hook URL; paste it as the webhook URL. Set a secret if the tool lets you verify signatures.
Does the email come from my domain?
Not yet on the hosted product: mail is sent from a Postbag domain with Reply-To set from the submission. Self-hosted installs configure their own Resend domain with MAIL_FROM. Per-organization verified sending domains are on the roadmap.
How do I verify a webhook signature?
Compute HMAC-SHA256 over {t}.{rawBody} with your secret, compare to v1 in constant time, and reject timestamps older than your tolerance. See the webhook docs for code.

Your first form is three minutes away.

Sign up, get a submit URL, point a form at it. The first submission lands in your inbox and your email. Everything else appears when you need it.