Implement the Channel interface for any provider.
The interface
| Name | Type | Description |
|---|---|---|
id* | string | From your config; stored on tickets. |
kind* | "email" | "sms" | "whatsapp" | "telegram" | "api" | How the inbox treats it: email gets subjects, HTML and threading headers. |
identityKind* | IdentityKind | How senders are identified: email or phone, for example. |
address* | string | What customers write to, for display. |
capabilities* | ChannelCapabilities | Subjects, attachment limits, maximum length, one conversation per thread or per person, any reply window, and delivery receipts. The composer enforces them. |
replyAddress | string | Email only: an address that routes back, carrying the ticket token. |
webhook | (request) => { events, response? } | Verify, parse, return events. Return response when the provider expects a specific body. |
sync | (state) => { events, state } | For polled providers and renewing subscriptions. Called by runScheduled with the last state. |
send* | (message) => SendResult | Send a reply. Throw ChannelError with retryable set when a retry might work. |
Example
lib/support/channels/acme-chat.ts
import { WebhookError, type Channel } from "@/lib/support/channel";
export function acmeChatChannel(config: { id: string; apiKey: string; signingSecret: string }): Channel {
return {
id: config.id,
kind: "api",
identityKind: "email",
address: "Acme Chat",
capabilities: {
subject: false,
attachments: false,
maxTextLength: 4000,
conversation: "contact", // one running conversation per person
replyWindowHours: null,
deliveryReceipts: false,
},
async webhook(request) {
const body = await request.text();
if (!(await verify(config.signingSecret, request.headers, body))) {
throw new WebhookError("Invalid signature");
}
const payload = JSON.parse(body);
return {
events: [
{
type: "message",
message: {
providerMessageId: payload.id,
from: { address: payload.user.email, name: payload.user.name },
to: [{ address: "Acme Chat" }],
text: payload.text,
receivedAt: new Date(payload.sentAt),
},
},
],
};
},
async send(message) {
const res = await fetch("https://api.acmechat.example/messages", {
method: "POST",
headers: { authorization: `Bearer ${config.apiKey}`, "content-type": "application/json" },
body: JSON.stringify({ to: message.to[0]!.address, text: message.text }),
});
const data = await res.json();
return { providerMessageId: data.id };
},
};
}Rules
- Translate only. Don't thread, store or decide anything: the service does that the same way for every channel.
- Use
fetchand WebCrypto only, so the adapter runs on Workers, Node, Bun and Deno. No provider SDKs. - Verify every webhook, in constant time, and reject old timestamps where the provider sends them.
- Return a
providerMessageIdfor every message and status event: it's how redelivery stays idempotent. syncmust be safe to run twice at once: returning the same events again is fine.
Contribute it
Add tests with recorded payloads from the provider's docs, an entry in
registry.json, and open a pull request. The contributing guide has the checklist.