Tendkit

Search documentation

Find a docs page

Get Started

Write an adapter

Implement the Channel interface for any provider.

The interface

NameTypeDescription
id*stringFrom your config; stored on tickets.
kind*"email" | "sms" | "whatsapp" | "telegram" | "api"How the inbox treats it: email gets subjects, HTML and threading headers.
identityKind*IdentityKindHow senders are identified: email or phone, for example.
address*stringWhat customers write to, for display.
capabilities*ChannelCapabilitiesSubjects, attachment limits, maximum length, one conversation per thread or per person, any reply window, and delivery receipts. The composer enforces them.
replyAddressstringEmail 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) => SendResultSend 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 fetch and 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 providerMessageId for every message and status event: it's how redelivery stays idempotent.
  • sync must 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.