Tendkit

Search documentation

Find a docs page

Get Started

Webhooks and scheduling

Receive provider webhooks and run the job that polls and renews channels.

The webhook route

One route serves every channel: the last path segment is the channel id. Point each provider's webhook at its URL, such as https://acme.com/api/webhooks/support/email.

app/api/webhooks/support/[channel]/route.ts
import { support } from "@/lib/support";

export async function POST(request: Request, { params }: { params: Promise<{ channel: string }> }) {
  const { channel } = await params;
  return support().handleWebhook(channel, request);
}

// Meta (WhatsApp Cloud API) verifies the URL with a GET first.
export const GET = POST;

handleWebhook verifies the signature, ingests every event and answers the provider. Ingestion is idempotent: a redelivered webhook never creates a second message.

Responses

NameTypeDescription
200Accepted, or the provider's expected body (an empty TwiML document, a verification challenge).
401 / 400The signature or payload didn't verify. Nothing was stored.
404No channel with that id, or it doesn't receive webhooks.
500Storing failed. The provider retries, which is safe.

The scheduled job

Call runScheduled() every few minutes. It polls channels that read mailboxes (Gmail, Microsoft 365, IMAP), renews push subscriptions and watches, and marks replies stuck sending for more than ten minutes as failed so an agent can retry. Push-only channels make it a no-op, and it's safe to run concurrently.

app/api/cron/support/route.ts
import { support } from "@/lib/support";

export async function GET(request: Request) {
  if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }
  return Response.json(await support().runScheduled());
}

It returns a summary such as { channels: { email: "3 events" }, staleFailed: 0 }.

Other runtimes

On Cloudflare Workers, run it from the scheduled handler next to your fetch handler:

worker.ts
export default {
  fetch: app.fetch,
  scheduled(_event, _env, ctx) {
    ctx.waitUntil(support().runScheduled());
  },
};

Keep the webhook URL stable

Twilio signs the exact URL it calls. If your app sits behind a proxy, configure the public URL in the adapter rather than rebuilding it from the incoming request.