Webhooks

Webhooks

Voxa receives two inbound webhooks — one from Stripe and one from ElevenLabs. Both are verified at the Next.js edge, then forwarded into n8n for orchestration. Voxa never returns non-2xx to a webhook caller: all payloads are persisted to the idempotency log first, then handled async, so retries are safe.

Verifying signatures

Stripe uses HMAC-SHA256 with the timestamped signature header stripe-signature. Voxa reads the raw request body via request.text() (NOT parsed JSON) and passes it to stripe.webhooks.constructEvent alongside STRIPE_WEBHOOK_SECRET. If verification fails the endpoint returns 400.

ElevenLabs delivers post-call analytics signed with ELEVENLABS_WEBHOOK_SECRET. Voxa uses constant-time HMAC verification (see app/api/elevenlabs/webhook/route.ts).

app/api/stripe/webhook/route.tstypescript
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) return new Response('Missing signature', { status: 400 });

let event: Stripe.Event;
try {
  event = stripe.webhooks.constructEvent(
    rawBody,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );
} catch {
  return new Response('Invalid signature', { status: 400 });
}

Always raw, never parsed

Calling request.json() before signature verification re-serializes the body and the resulting hash will not match. The Stripe webhook handler MUST read request.text() first.

Stripe events

POST/api/stripe/webhook

Accepts every Stripe event the dashboard is subscribed to. Forwards only the events Voxa orchestrates against.

checkout.session.completedeventoptional

Triggers n8n W1 (onboarding). Voxa upserts the subscriptions row first, then invokes the workflow with { business_id, tier, stripe_customer_id, ... }.

customer.subscription.updatedeventoptional

Updated locally in the subscriptions table. Not forwarded.

customer.subscription.deletedeventoptional

Triggers n8n W4 (cleanup) — release the Twilio number, delete the EL agent, archive the row.

invoice.payment_failedeventoptional

Triggers n8n W4 (cleanup) — pauses the agent and WhatsApps the owner.

ElevenLabs post-call

POST/api/elevenlabs/webhook

Fires 5–15 seconds after the caller hangs up. Forwards to n8n W3 for billing + lead extraction.

post_call_transcription.jsonjson
{
  "type": "post_call_transcription",
  "data": {
    "conversation_id": "conv_xyz",
    "agent_id": "agent_xyz",
    "started_at": "2026-05-20T08:14:02Z",
    "ended_at":   "2026-05-20T08:18:47Z",
    "transcript": [
      { "role": "agent", "text": "Namaste, Sharma Dental..." },
      { "role": "user",  "text": "Mujhe kal subah appointment chahiye." }
    ],
    "audio_url": "https://elevenlabs.io/...",
    "language": "hi",
    "caller_id": "+919876543210",
    "summary": "Hindi caller booked an appointment for tomorrow morning."
  }
}

Why forward to n8n?

The Next.js handler does the bare minimum (verify → persist → enqueue) and returns 200 to ElevenLabs in <200ms. n8n W3 then does the slow work — Claude extraction (~1.5s), Stripe Meter Event POST, multi-row Supabase write, conditional WhatsApp alert. If n8n is briefly down, the audit row persists and the workflow can be re-fired by hand.