Skip to content

Receiving

Inbound is the part every integration gets wrong, and it gets wrong in the same four ways. The SDK handler does all four for you.

app/api/webhooks/switchboard/[secret]/route.ts
import { createWebhookHandler } from "@atrixdigital/switchboard"

export const POST = createWebhookHandler({
  secret: process.env.SWITCHBOARD_WEBHOOK_SECRET!,
  seen: (id) => db.deliveries.exists(id),
  onEvent: async ({ event, meta }) => {
    if (event.kind !== "inbound") return
    await db.deliveries.insert(meta.deliveryId)
    await queue.push(event)        // do the WORK off the request
  },
  onError: (error, ctx) => logger.error({ error, id: ctx.meta.deliveryId }),
})

It takes a Request and returns a Response, so it drops into a Next.js route handler, Elysia, Hono, Bun.serve or plain Node without knowing which.

If you are not using the SDK#

Implement these four yourself.

Verify the secret#

It is the last path segment, not a header — the engine’s webhook producer cannot attach headers. Compare in constant time.

Fail closed when your configured secret is empty

Otherwise a missing environment variable turns your endpoint into an open one that anybody can post fabricated customer messages to.

Dedupe on X-Switchboard-Delivery#

Delivery is at-least-once. That is the guarantee, not a caveat: the alternative is losing messages. The same event arrives again whenever a delivery succeeded and the acknowledgement was lost.

A unique index on it is the whole implementation

The header is stable across retries. Without it, a handler that books a reply or sends an auto-response does it twice.

Never answer 5xx for a bug in your own handler#

A non-2xx tells Switchboard to retry, so one bug becomes hours of redelivery and eventually a dead letter for a message that was fine. Log it, answer 200, fix the bug — the payload stays replayable from /v1/deliveries.

Do answer 5xx when your dependency is down and a redelivery would genuinely help — a database you cannot reach to dedupe against, for instance.

Answer fast; do the work afterwards#

Events arrive in bursts. A handler that runs a database write and an LLM call before returning 200 holds the delivery worker’s connection for the duration. Enqueue and return.

Event kinds#

inbound, delivery, instance, ignored. The parser never throws — an unfamiliar shape becomes ignored with a reason rather than an exception, precisely so a surprise cannot become a retry storm.

Status events#

Instead of asking whether each message arrived, a project can be told. Switch status events on once, and your webhook receives a message.status event whenever one of your messages reaches sent, delivered, read, failed, dead or canceled — WhatsApp and SMS alike — through the same durable queue as inbound.

typescript
await wa.settings.setStatusEvents(true)

// in your webhook handler
const status = parseStatusEvent(body)
if (status) {
  await db.messages.update(status.data.id, { state: status.data.state })
}

What you can rely on

At least once: dedupe on eventId, which is the message id plus its state. Latest state, not every state — a message that went sent → delivered between two passes produces one delivered event, and every event carries every timestamp so far. Only changes after you switched them on are sent.

SMS replies#

Switch replies on and an SMS someone sends back to a Twilio or Telnyx number arrives as a message.inbound event — to the project that most recently texted them, and only that one.

typescript
await wa.settings.setInboundSms(true)

const reply = parseInboundEvent(body)
if (reply) {
  // reply.data.from, .text, .inReplyTo (your message it answers)
}