Tickets from your own forms and backends, with signed requests.
Install
Terminal
npx shadcn@latest add @tendkit/channel-apiIt lands in lib/support/channels/. Import apiChannel from there.
Set up
Generate a secret
"whsec_" plus the base64 of 32 random bytes. Keep it on your server.
Configure the channel
Usually with
kind: "email", so replies go out by email through your email channel.Post from your backend
Sign each request with
signApiRequest(secret, body)and POST the JSON message to the channel's webhook URL. Never sign in a browser.
Configure
lib/support/index.ts
const email = resendChannel({ /* … */ });
apiChannel({
id: "contact-form",
secret: process.env.CONTACT_FORM_SECRET!,
address: "Contact form",
kind: "email",
replyAddress: "support@in.acme.com",
deliver: (message) => email.send(message),
capabilities: email.capabilities,
})
// In your contact form's server action:
const body = JSON.stringify({ id: submission.id, from: { email, name }, subject, text });
await fetch("https://acme.com/api/webhooks/support/contact-form", {
method: "POST",
headers: await signApiRequest(process.env.CONTACT_FORM_SECRET!, body),
body,
});Options
| Name | Type | Description |
|---|---|---|
id* | string | Stable channel id, stored on every ticket and used in the webhook URL. Never change it. |
secret* | string | "whsec_" + base64 of 24 to 64 random bytes, shared with the app that signs. |
address* | string | Where the ticket came from, such as "Contact form". |
deliver* | (message) => SendResult | Sends replies. Usually your email channel's send. |
kind | "api" | "email" | "email" gives replies a subject, HTML, acknowledgements and a threading reply address.Default: "api" |
replyAddress | string | The email channel's receiving address, so answers return to the ticket. |
capabilities | Partial<ChannelCapabilities> | Usually the delivering channel's. |
Behaviour
- Resending the same message
idis a no-op, so retries are safe. - API messages are untrusted: they never link to an account, so a form can't impersonate a customer.