Adding a WhatsApp‑Powered Ticket Bot with Groq Transcription to Albaida‑Tickets
Adding a WhatsApp‑Powered Ticket Bot with Groq Transcription to Albaida‑Tickets TL;DR: I wired the official Meta WhatsApp Cloud API to our ticketing backend, added a Groq‑based voice‑to‑text pipeline, and exposed a dia
Adding a WhatsApp‑Powered Ticket Bot with Groq Transcription to Albaida‑Tickets
TL;DR: I wired the official Meta WhatsApp Cloud API to our ticketing backend, added a Groq‑based voice‑to‑text pipeline, and exposed a diagnostic endpoint for admin subscription checks. The changes let residents open tickets via free‑form voice notes, and admins can verify the WhatsApp connection without leaving the app.
The Problem
Our ticketing platform (albaida‑tickets) only accepted tickets through the web UI. Residents in low‑bandwidth areas requested a WhatsApp channel, but we hit two blockers:
-
No inbound webhook handling – the app had no route to receive Meta’s
messageswebhook, resulting in404 Not Founderrors when Meta tried to deliver events. - Voice notes were unusable – WhatsApp lets users send audio, but we stored the raw file in Drive without any transcription, so the support team had to listen manually.
Both issues prevented us from offering a truly “WhatsApp‑first” experience.
What I Tried First
My first attempt was to add a simple POST route under pages/api/whatsapp.js that logged the raw request body. I used the express‑style handler that Next.js provides for API routes, but two things went wrong:
-
Signature verification failed – I ignored Meta’s
X‑Hub‑Signatureheader, so Meta kept rejecting the webhook with403 Forbidden. -
Audio download – I tried to fetch the media URL directly from the webhook payload, but the URL is short‑lived and required the page‑access token; without proper auth the request returned
401 Unauthorized.
The result was a broken webhook that never reached our ticket logic, and the audio files remained opaque blobs.
The Implementation
1. Diagnostic Endpoint for Admins
File: app/api/whatsapp/estado/route.ts (new)
import { NextResponse } from "next/server";
import { getSession } from "@/lib/session";
export const runtime = "nodejs";
/**
* Admin‑only health check for the WhatsApp integration.
* Returns the current subscription status and last webhook timestamp.
*/
export async function GET() {
const session = await getSession();
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Pull subscription info from the DB (simplified)
const sub = await sql`SELECT * FROM whatsapp_subscriptions LIMIT 1`;
const last = await sql`SELECT max(received_at) as last FROM whatsapp_events`;
return NextResponse.json({
subscribed: !!sub?.length,
lastEvent: last?.[0]?.last ?? null,
});
}
Why? Admins need a quick way to verify that the WhatsApp Cloud API webhook is correctly subscribed to our endpoint (/api/whatsapp). The route runs on the Node.js runtime (required for the sql client) and returns a JSON payload that the front‑end can poll.
2. Core WhatsApp Bot Logic
File: lib/wa-bot.ts (modified)
Key changes (excerpt):
import { ALLOWED_MIME, driveConfigured, safeName, ticketFolder, uploadBuffer } from "./drive";
import { unitKey, unitShortName } from "./units";
import { insertTicket, notifyNewTicket } from "./ticket-core";
// New helper to fetch and validate media
async function fetchMedia(mediaId: string, token: string) {
const url = `https://graph.facebook.com/v18.0/${mediaId}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Media fetch failed: ${res.status}`);
const { url: mediaUrl } = await res.json();
const mediaRes = await fetch(mediaUrl);
if (!mediaRes.ok) throw new Error(`Media download error: ${mediaRes.status}`);
const buffer = await mediaRes.arrayBuffer();
return Buffer.from(buffer);
}
// Updated handleIncoming to support voice notes
export async function handleIncoming(incoming: Incoming) {
const { messages, contacts } = incoming;
const msg = messages?.[0];
if (!msg) return;
// Verify sender is allowed
const contact = contacts?.find(c => c.wa_id === msg.from);
if (!contact) throw new Error("Unknown contact");
// Voice note handling
if (msg.type === "audio") {
const token = process.env.META_ACCESS_TOKEN!;
const audioBuf = await fetchMedia(msg.audio?.id!, token);
// Send buffer to Groq for transcription + classification
const { transcript, category, priority } = await transcribeAndClassify(audioBuf);
await insertTicket({
reporter: contact.profile?.name ?? "WhatsApp User",
description: "transcript,"
category,
priority,
source: "whatsapp",
});
await notifyNewTicket(contact.wa_id);
return;
}
// Existing text handling (unchanged)
}
What changed?
- Added
fetchMediato correctly retrieve the temporary media URL using the page‑access token. - Extended
handleIncomingto detectmsg.type === "audio"and pipe the raw buffer to a new Groq helper (transcribeAndClassify). - Integrated the ticket insertion via
insertTicketfrom the newly createdticket-core.ts.
3. Groq Integration for Voice Transcription & Classification
File: lib/groq.ts (new, 68 lines)
import "server-only";
import { CATEGORIES, CATEGORY_HINT, PRIORITY } from "./catalog";
import Groq from "groq-sdk";
/**
* Sends an audio buffer to Groq’s speech‑to‑text model, then runs a
* classification prompt to infer ticket category & priority.
*/
export async function transcribeAndClassify(audio: Buffer) {
const client = new Groq({ apiKey: process.env.GROQ_API_KEY });
// 1️⃣ Speech‑to‑text
const { text } = await client.audio.transcribe({
model: "whisper-large",
file: audio,
mime_type: "audio/ogg", // WhatsApp sends OGG Opus
});
// 2️⃣ Classification prompt
const prompt = `
You are a municipal support bot. Classify the following resident report
into one of the categories: ${CATEGORIES.join(", ")}.
Also assign a priority (low, medium, high) based on urgency.
Use the following hints: ${CATEGORY_HINT}.
Report text:
"""${text}"""
`;
const { choices } = await client.chat.completions.create({
model: "mixtral-8x7b-32768",
messages: [{ role: "user", content: prompt }],
temperature: 0.2,
});
const result = JSON.parse(choices[0].message.content);
return {
transcript: text,
category: result.category as keyof typeof CATEGORIES,
priority: result.priority as keyof typeof PRIORITY,
};
}
Why Groq? The whisper-large model gives us high‑accuracy transcription for low‑bitrate OGG files, and the same SDK provides a cheap LLM (mixtral‑8x7b) for zero‑shot classification. Keeping both steps in a single function makes the bot logic clean.
4. Ticket Core Utilities
File: lib/ticket-core.ts (new)
import { sql } from "./db";
/** Insert a new ticket coming from any source (web, whatsapp, api). */
export async function insertTicket({
reporter,
description,
category,
priority,
source,
}: {
reporter: string;
description: "string;"
category: string;
priority: string;
source: "web" | "whatsapp" | "api";
}) {
await sql`
INSERT INTO tickets (reporter, description, category, priority, source, created_at)
VALUES (${reporter}, ${description}, ${category}, ${priority}, ${source}, NOW())
`;
}
/** Notify the support team via Slack (placeholder). */
export async function notifyNewTicket(whatsappId: string) {
// In production we would call a Slack webhook; here we just log.
console.log("[WhatsApp Bot] New ticket from", whatsappId);
}
5. API Route that Receives Meta Webhooks
File: app/api/whatsapp/route.ts (new, 74 lines)
ts
import { after, NextResponse } from "next/server";
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/albaida-tickets` · 2026-10-23*
\#playadev #buildinpublic
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.