Dev.to AI 🤖 Ai 👁 0 📖 5 min read

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:

  1. No inbound webhook handling – the app had no route to receive Meta’s messages webhook, resulting in 404 Not Found errors when Meta tried to deliver events.
  2. 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‑Signature header, so Meta kept rejecting the webhook with 403 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 fetchMedia to correctly retrieve the temporary media URL using the page‑access token.
  • Extended handleIncoming to detect msg.type === "audio" and pipe the raw buffer to a new Groq helper (transcribeAndClassify).
  • Integrated the ticket insertion via insertTicket from the newly created ticket-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
📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.