Skip to content

@voice213/agent-client/server checks that a webhook delivery really came from Voice213, and gives you the event with its types. It runs anywhere the Web Crypto API does: Node.js 20+, Next.js and Remix route handlers, Vercel and Netlify functions (Node or edge), Cloudflare Workers, Deno and Bun.

npm install @voice213/agent-client

​constructWebhookEvent

import { constructWebhookEvent } from "@voice213/agent-client/server";

const event = await constructWebhookEvent(rawBody, signatureHeader, process.env.VOICE213_WEBHOOK_SECRET);
payloadstring | ArrayBuffer | Uint8Arrayrequired

The request body exactly as received. Parsed and re-serialised JSON doesn't match the signature.

signaturestring | nullrequired

The X-Voice213-Signature header.

secretstringrequired

The endpoint's signing secret, whsec_…, shown once when you created the endpoint. An empty or missing secret throws instead of checking anything.

Since 0.2.0. Refuse an event whose createdAt is more than this many seconds away from now, so a captured delivery can't be replayed later. createdAt is inside the signed body, so it can't be changed. Off by default; 300 is a good value.

It returns a promise of the event, { id, event, createdAt, data }. If the signature is missing or wrong, the body isn't an event, or (with maxAgeSeconds) the event is too old, the promise rejects with a WebhookVerificationError: answer 400.

Always await it inside your try. Without await, the check fails after your handler has moved on, and event is a pending promise, not an event.

The payload must be the raw body: a string, ArrayBuffer or Uint8Array (a Node.js Buffer is one). Anything else, such as an already-parsed object, throws a TypeError, since it can't be the bytes that were signed.

verifyWebhookSignature(payload, signature, secret) does the check alone and resolves true or false.

​Next.js

import { constructWebhookEvent } from "@voice213/agent-client/server";

export async function POST(request) {
  let event;
  try {
    event = await constructWebhookEvent(await request.text(), request.headers.get("x-voice213-signature"), process.env.VOICE213_WEBHOOK_SECRET);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  switch (event.event) {
    case "conversation.ended":
      if (event.data.outcome === "lead") await createLead(event.data);
      break;
    case "message.received":
      await logWhatsAppMessage(event.data.contact.phone, event.data.text);
      break;
  }
  return new Response(null, { status: 204 });
}

​Express

Read the body raw: express.json() parses it, and the parsed body no longer matches the signature.

import express from "express";
import { WebhookVerificationError, constructWebhookEvent } from "@voice213/agent-client/server";

const app = express();

app.post("/webhooks/voice213", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const event = await constructWebhookEvent(req.body, req.get("x-voice213-signature"), process.env.VOICE213_WEBHOOK_SECRET);
    res.sendStatus(204); // answer within 10 seconds, then work
    await handle(event);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(400).send(error.message);
    throw error;
  }
});

​Cloudflare Workers

import { constructWebhookEvent } from "@voice213/agent-client/server";

export default {
  async fetch(request, env, ctx) {
    const event = await constructWebhookEvent(await request.text(), request.headers.get("x-voice213-signature"), env.VOICE213_WEBHOOK_SECRET).catch(() => null);
    if (!event) return new Response("Invalid signature", { status: 400 });
    ctx.waitUntil(handle(event, env));
    return new Response(null, { status: 204 });
  },
};

​TypeScript

Narrow on event.event and data takes the matching type:

import type { ConversationEndedData, WebhookEvent } from "@voice213/agent-client/server";

function handle(event: WebhookEvent) {
  if (event.event === "conversation.ended") {
    const data: ConversationEndedData = event.data;
    data.outcome; // "resolved" | "lead" | "follow_up" | "unresolved" | "no_answer"
    data.transcript; // { role: "user" | "agent" | "tool"; text: string; tool?: string; at?: string }[]
  }
}

The exports are WebhookEvent, WebhookEventName, ConversationStartedData, ConversationEndedData, MessageReceivedData, ToolCalledData, Contact, TranscriptEntry and ConversationChannel, plus the header names SIGNATURE_HEADER, EVENT_HEADER and DELIVERY_HEADER.

​Good practice

  • Answer fast. Send the 2xx first, then do the work, or queue it. A delivery that takes over 10 seconds counts as failed, and a failed one is retried only once.
  • Dedupe on id. A retry carries the same id, also in X-Voice213-Delivery. Together with maxAgeSeconds, that turns away replays.
  • Don't rely on order. conversation.ended can arrive before you've finished handling conversation.started.
  • Rotate the secret if it leaks: New secret on the Webhooks page, then update your server.

Was this page helpful?