Para transcrever áudios do WhatsApp no Laravel você recebe o webhook da WhatsApp Cloud API, pega o media_id da mensagem, baixa o arquivo pela Graph API e envia o binário para o endpoint /v1/audio/transcriptions da OpenAI. Tudo isso roda dentro de um job na fila, nunca dentro do webhook. O texto que volta entra no mesmo fluxo das mensagens digitadas, e o seu agente de IA nem precisa saber que aquilo começou como áudio.

Em qualquer produto que atende pelo WhatsApp, como agendamento, cobrança ou suporte, uma parte grande dos clientes manda áudio em vez de texto. Se o agente só entende texto, essas conversas morrem ou caem para um humano. O Claude, que eu uso como LLM principal, não recebe áudio pela API. Por isso a transcrição é uma etapa separada, e a OpenAI resolve bem essa parte.

O que você vai ter funcionando no final

  1. Uma rota de webhook que valida a assinatura da Meta e grava cada mensagem uma única vez.
  2. Um job que baixa o áudio da Graph API e transcreve com a OpenAI, com timeout, retries e tratamento de erro.
  3. O texto transcrito salvo no banco e repassado para o job do agente de IA.
  4. Um teste com Http::fake cobrindo o fluxo inteiro sem gastar um centavo.

Pré-requisitos:

  • PHP 8.2+ e Laravel 11 ou 12.
  • Um app na Meta com WhatsApp Cloud API configurado: token de acesso, App Secret e um número de teste.
  • Uma chave da API da OpenAI.
  • Fila rodando em Redis, de preferência com Horizon.
  • Nenhum SDK extra. Tudo é feito com o Http:: do Laravel.

1. Como configurar as chaves da Meta e da OpenAI no Laravel?

Se o seu projeto Laravel 11 ainda não tem routes/api.php, crie com:

php artisan install:api

No .env:

WHATSAPP_TOKEN=EAAG...
WHATSAPP_APP_SECRET=seu_app_secret
WHATSAPP_VERIFY_TOKEN=uma_string_aleatoria_sua
WHATSAPP_GRAPH_VERSION=v21.0

OPENAI_API_KEY=sk-...
OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe

Em config/services.php:

'whatsapp' => [
    'token' => env('WHATSAPP_TOKEN'),
    'app_secret' => env('WHATSAPP_APP_SECRET'),
    'verify_token' => env('WHATSAPP_VERIFY_TOKEN'),
    'graph_version' => env('WHATSAPP_GRAPH_VERSION', 'v21.0'),
],

'openai' => [
    'key' => env('OPENAI_API_KEY'),
    'transcription_model' => env('OPENAI_TRANSCRIPTION_MODEL', 'gpt-4o-mini-transcribe'),
],

A OpenAI oferece hoje whisper-1, gpt-4o-mini-transcribe e gpt-4o-transcribe nesse endpoint. Deixo o modelo em variável de ambiente para trocar sem deploy. Dá para comparar qualidade e custo com áudios reais dos seus clientes antes de decidir.

2. Como modelar a tabela de mensagens do WhatsApp?

Toda mensagem do WhatsApp tem um id único, o wamid. É ele que garante que cada mensagem seja processada uma vez só, mesmo quando a Meta reenvia o webhook.

php artisan make:model WhatsAppMessage -m
// database/migrations/xxxx_create_whatsapp_messages_table.php
Schema::create('whatsapp_messages', function (Blueprint $table) {
    $table->id();
    $table->string('wamid')->unique();
    $table->string('from', 32);
    $table->string('type', 20);
    $table->string('media_id')->nullable();
    $table->string('mime_type')->nullable();
    $table->text('body')->nullable();
    $table->string('transcription_status', 20)->nullable();
    $table->timestamp('received_at');
    $table->timestamps();
});
// app/Models/WhatsAppMessage.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class WhatsAppMessage extends Model
{
    protected $table = 'whatsapp_messages';

    protected $guarded = [];

    protected $casts = [
        'received_at' => 'datetime',
    ];
}

O $table explícito não é detalhe. Sem ele, o Eloquent procura whats_app_messages por causa do "A" maiúsculo no nome da classe.

O transcription_status aceita pending, done, empty, skipped ou failed. Mensagens de texto ficam com null.

3. Como validar a assinatura do webhook do WhatsApp no Laravel?

A Meta assina o corpo de cada POST com HMAC SHA-256 usando o App Secret e manda o resultado no header X-Hub-Signature-256, no formato sha256=<hex>. HMAC é uma assinatura feita com uma chave secreta compartilhada: se o hash não bate, a requisição não veio da Meta ou o corpo foi alterado no caminho.

// app/Http/Middleware/VerifyMetaSignature.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class VerifyMetaSignature
{
    public function handle(Request $request, Closure $next)
    {
        $header = (string) $request->header('X-Hub-Signature-256', '');

        $expected = 'sha256=' . hash_hmac(
            'sha256',
            $request->getContent(),
            config('services.whatsapp.app_secret')
        );

        abort_unless(hash_equals($expected, $header), 401);

        return $next($request);
    }
}

O ponto crítico é $request->getContent(). A assinatura é calculada sobre o corpo cru. Se você recodificar o JSON com json_encode($request->all()), a ordem das chaves e os escapes mudam e o hash nunca bate. Se precisar depurar uma assinatura que não confere, cole payload, segredo e header no testador de webhook e veja onde está a diferença.

4. Como receber o webhook e gravar a mensagem sem duplicar?

A Meta faz duas coisas nessa URL. Um GET de verificação quando você cadastra o webhook, e POSTs com as mensagens. O controller precisa responder rápido, porque a Meta reenvia o evento se a resposta demora ou não volta 200.

// routes/api.php
use App\Http\Controllers\WhatsAppWebhookController;
use App\Http\Middleware\VerifyMetaSignature;

Route::get('/webhooks/whatsapp', [WhatsAppWebhookController::class, 'verify']);
Route::post('/webhooks/whatsapp', [WhatsAppWebhookController::class, 'receive'])
    ->middleware(VerifyMetaSignature::class);
// app/Http/Controllers/WhatsAppWebhookController.php
namespace App\Http\Controllers;

use App\Jobs\HandleIncomingMessage;
use App\Jobs\TranscribeWhatsAppAudio;
use App\Models\WhatsAppMessage;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;

class WhatsAppWebhookController extends Controller
{
    public function verify(Request $request)
    {
        // O PHP converte "hub.mode" em "hub_mode" na query string
        $mode = $request->query('hub_mode');
        $token = (string) $request->query('hub_verify_token');

        if ($mode === 'subscribe' && hash_equals(config('services.whatsapp.verify_token'), $token)) {
            return response($request->query('hub_challenge'), 200);
        }

        abort(403);
    }

    public function receive(Request $request)
    {
        foreach ($request->input('entry', []) as $entry) {
            foreach ($entry['changes'] ?? [] as $change) {
                foreach ($change['value']['messages'] ?? [] as $msg) {
                    $this->store($msg);
                }
            }
        }

        return response()->json(['ok' => true]);
    }

    private function store(array $msg): void
    {
        $isAudio = ($msg['type'] ?? null) === 'audio';

        $message = WhatsAppMessage::createOrFirst(
            ['wamid' => $msg['id']],
            [
                'from' => $msg['from'],
                'type' => $msg['type'],
                'media_id' => $isAudio ? $msg['audio']['id'] : null,
                'mime_type' => $isAudio ? ($msg['audio']['mime_type'] ?? null) : null,
                'body' => $msg['text']['body'] ?? null,
                'transcription_status' => $isAudio ? 'pending' : null,
                'received_at' => Carbon::createFromTimestamp((int) $msg['timestamp']),
            ]
        );

        if (! $message->wasRecentlyCreated) {
            return; // reentrega da Meta, já processada
        }

        $isAudio
            ? TranscribeWhatsAppAudio::dispatch($message->id)
            : HandleIncomingMessage::dispatch($message->id);
    }
}

Uso createOrFirst em vez de firstOrCreate de propósito. Ele tenta o insert primeiro e, se a constraint unique do wamid estourar, busca o registro existente. Assim duas entregas simultâneas do mesmo evento não viram dois jobs. É o mesmo princípio que detalhei em como processar webhooks duplicados e fora de ordem uma única vez.

HandleIncomingMessage é o job que já trata mensagens de texto e chama o agente. Se você ainda não tem um, o post sobre agente de IA com tool calling do Claude para agendamentos no WhatsApp mostra como montar.

5. Como baixar o áudio da WhatsApp Cloud API?

O download acontece em duas chamadas. Primeiro um GET em /{media_id}, que devolve um JSON com url, mime_type, sha256 e file_size. Depois um GET nessa url com o mesmo token Bearer, que devolve o binário.

// app/Services/WhatsAppMedia.php
namespace App\Services;

use Illuminate\Support\Facades\Http;

class WhatsAppMedia
{
    public function download(string $mediaId): array
    {
        $token = config('services.whatsapp.token');
        $base = 'https://graph.facebook.com/' . config('services.whatsapp.graph_version');

        $meta = Http::withToken($token)
            ->timeout(15)
            ->get("{$base}/{$mediaId}")
            ->throw()
            ->json();

        $contents = Http::withToken($token)
            ->timeout(60)
            ->get($meta['url'])
            ->throw()
            ->body();

        return [
            'contents' => $contents,
            'mime_type' => $meta['mime_type'],
            'size' => $meta['file_size'] ?? strlen($contents),
        ];
    }
}

A URL de download expira poucos minutos depois de gerada. Por isso eu não guardo a URL no banco: o job sempre pede uma nova a partir do media_id. Num retry, o fluxo funciona de novo sem gambiarra.

6. Como transcrever o áudio com a API da OpenAI no Laravel?

O endpoint de transcrição recebe multipart/form-data com o arquivo e o modelo. Com o Http:: do Laravel, o attach liga o modo multipart e os campos do array do post viram partes do formulário.

// app/Services/OpenAiTranscriber.php
namespace App\Services;

use Illuminate\Support\Facades\Http;
use InvalidArgumentException;

class OpenAiTranscriber
{
    public function transcribe(string $contents, string $mimeType): string
    {
        $extension = $this->extension($mimeType);

        $response = Http::withToken(config('services.openai.key'))
            ->timeout(120)
            ->attach('file', $contents, "audio.{$extension}")
            ->post('https://api.openai.com/v1/audio/transcriptions', [
                'model' => config('services.openai.transcription_model'),
                'language' => 'pt',
                'response_format' => 'json',
                'prompt' => 'Mensagem de cliente sobre agendamento. Termos comuns: corte, escova, manicure, Pix, remarcar.',
            ])
            ->throw();

        return trim((string) $response->json('text'));
    }

    private function extension(string $mimeType): string
    {
        $base = strtolower(trim(explode(';', $mimeType)[0]));

        return match ($base) {
            'audio/ogg' => 'ogg',
            'audio/mpeg' => 'mp3',
            'audio/mp4' => 'm4a',
            default => throw new InvalidArgumentException("Formato não suportado: {$base}"),
        };
    }
}

Três decisões aqui:

  1. language: pt evita que áudios curtos e com sotaque forte sejam detectados como espanhol ou outra língua.
  2. prompt é um texto de contexto que orienta o vocabulário. Coloque nomes de serviços, produtos e termos do seu domínio. Ele não é uma instrução, é uma pista de grafia.
  3. Nota de voz do WhatsApp chega como audio/ogg; codecs=opus, e a OpenAI aceita .ogg direto. Sem ffmpeg. Formatos como AMR e AAC, que aparecem quando o cliente encaminha um arquivo de áudio, não são aceitos: ou você converte, ou responde pedindo para gravar de novo.

7. Como montar o job de transcrição com retries e timeout?

// app/Jobs/TranscribeWhatsAppAudio.php
namespace App\Jobs;

use App\Models\WhatsAppMessage;
use App\Services\OpenAiTranscriber;
use App\Services\WhatsAppMedia;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Http\Client\RequestException;
use InvalidArgumentException;
use Throwable;

class TranscribeWhatsAppAudio implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $timeout = 180;

    public function __construct(public int $messageId) {}

    public function backoff(): array
    {
        return [10, 60];
    }

    public function handle(WhatsAppMedia $media, OpenAiTranscriber $transcriber): void
    {
        $message = WhatsAppMessage::findOrFail($this->messageId);

        if ($message->transcription_status !== 'pending') {
            return; // já transcrito numa tentativa anterior
        }

        $file = $media->download($message->media_id);

        if ($file['size'] > 25 * 1024 * 1024) {
            $message->update(['transcription_status' => 'skipped']);
            return;
        }

        try {
            $text = $transcriber->transcribe($file['contents'], $file['mime_type']);
        } catch (InvalidArgumentException $e) {
            $this->fail($e); // formato não suportado: retry não resolve
            return;
        } catch (RequestException $e) {
            if ($e->response->status() === 400) {
                $this->fail($e);
                return;
            }
            throw $e; // 429 e 5xx: deixa a fila tentar de novo
        }

        $message->update([
            'body' => $text,
            'transcription_status' => $text === '' ? 'empty' : 'done',
        ]);

        if ($text !== '') {
            HandleIncomingMessage::dispatch($message->id);
        }
    }

    public function failed(Throwable $e): void
    {
        WhatsAppMessage::whereKey($this->messageId)
            ->update(['transcription_status' => 'failed']);

        // Aqui vale responder ao cliente: "Não consegui ouvir seu áudio, pode escrever?"
    }
}

A regra que sigo: erro do cliente (400, formato inválido) vai direto para failed, porque repetir a mesma requisição dá o mesmo erro e cobra de novo. Erro transitório (429, 500, timeout) volta para a fila com backoff. A checagem de transcription_status no início impede que um retry depois de um sucesso parcial transcreva o mesmo áudio duas vezes. Esse padrão de idempotência em chamadas pagas está mais detalhado em como chamar LLMs com filas, retries e timeouts sem duplicar custo.

Se o volume de áudio for alto, coloque esse job numa fila própria, por exemplo ->onQueue('transcription'), com supervisor separado no Horizon. Um pico de áudios não deve atrasar as respostas de texto.

8. Como testar a transcrição sem chamar a OpenAI?

// tests/Feature/TranscribeWhatsAppAudioTest.php
use App\Jobs\HandleIncomingMessage;
use App\Jobs\TranscribeWhatsAppAudio;
use App\Models\WhatsAppMessage;
use App\Services\OpenAiTranscriber;
use App\Services\WhatsAppMedia;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;

it('transcreve o áudio e entrega o texto ao agente', function () {
    Queue::fake([HandleIncomingMessage::class]);

    Http::fake([
        'graph.facebook.com/*' => Http::response([
            'url' => 'https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=1',
            'mime_type' => 'audio/ogg; codecs=opus',
            'file_size' => 2048,
            'id' => 'MEDIA1',
        ]),
        'lookaside.fbsbx.com/*' => Http::response('bytes-do-ogg'),
        'api.openai.com/v1/audio/transcriptions' => Http::response([
            'text' => 'Quero marcar um corte amanhã às dez',
        ]),
    ]);

    $message = WhatsAppMessage::create([
        'wamid' => 'wamid.TESTE1',
        'from' => '5511999999999',
        'type' => 'audio',
        'media_id' => 'MEDIA1',
        'transcription_status' => 'pending',
        'received_at' => now(),
    ]);

    (new TranscribeWhatsAppAudio($message->id))
        ->handle(app(WhatsAppMedia::class), app(OpenAiTranscriber::class));

    expect($message->fresh())
        ->body->toBe('Quero marcar um corte amanhã às dez')
        ->transcription_status->toBe('done');

    Queue::assertPushed(HandleIncomingMessage::class);

    Http::assertSent(fn ($request) =>
        str_contains($request->url(), 'audio/transcriptions') && $request->isMultipart()
    );
});

Para testar o webhook de ponta a ponta localmente, gere a assinatura no terminal:

BODY='{"entry":[]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WHATSAPP_APP_SECRET" | sed 's/^.* //')

curl -X POST http://localhost:8000/api/webhooks/whatsapp \
  -H 'Content-Type: application/json' \
  -H "X-Hub-Signature-256: sha256=$SIG" \
  -d "$BODY"

Erros comuns

  1. Verificação do webhook sempre devolve 403. O PHP troca pontos por underscore nos nomes da query string, então hub.mode vira hub_mode. Ler $request->query('hub.mode') retorna null.
  2. Assinatura nunca bate. Você está assinando o JSON decodificado e recodificado, ou usando o token de acesso no lugar do App Secret. Use $request->getContent() e o App Secret do painel do app.
  3. 401 ao baixar o áudio. A URL retornada pela Graph API também exige o header Authorization: Bearer. Abrir no navegador ou chamar sem token não funciona.
  4. Download falha só nos retries. A URL de mídia expirou. Sempre peça uma nova a partir do media_id, nunca reutilize a antiga.
  5. OpenAI responde 400 com formato inválido. O arquivo foi enviado sem extensão ou com extensão errada. O nome no attach precisa terminar em .ogg, .mp3 ou .m4a de acordo com o mime type.
  6. Job morre por timeout e roda duas vezes. O $timeout do job precisa ser menor que o retry_after da conexão em config/queue.php. Se não for, outro worker pega o job enquanto o primeiro ainda está esperando a OpenAI.
  7. Transcrição com frase que ninguém falou. Áudios mudos ou só com ruído às vezes voltam com uma frase genérica inventada pelo modelo. Trate transcrições muito curtas de áudios longos (ou o contrário) como suspeitas e peça confirmação ao cliente antes de executar uma ação.
  8. Mensagens processadas duas vezes. A Meta reentrega o evento quando a resposta demora. Responda 200 rápido, jogue o trabalho para a fila e garanta unicidade pelo wamid.

Perguntas frequentes

Dá para mandar o áudio direto para o Claude?

Não. A API do Claude aceita texto, imagens e PDFs, mas não áudio. Você transcreve antes, com a OpenAI ou outro serviço de speech-to-text, e manda o texto para o Claude. O lado bom é que o custo do agente passa a ser só de tokens de texto, e dá para estimar na calculadora de custo de IA.

Qual modelo de transcrição usar: whisper-1 ou gpt-4o-transcribe?

Comece com gpt-4o-mini-transcribe e compare com gpt-4o-transcribe usando uns 50 áudios reais dos seus clientes. Como o modelo está no .env, trocar é só mudar uma variável. Escolha pelo erro em nomes, números e horários, que é o que quebra o agente.

Preciso guardar o arquivo de áudio?

Eu não guardo. O áudio é dado pessoal, e a voz pode ser considerada dado biométrico. Baixe, transcreva em memória e descarte. A transcrição também é dado pessoal, então ela entra na mesma política de retenção das mensagens de texto. Se o agente executa ações, vale ler sobre prompt injection e LGPD em agentes de IA: um áudio também pode carregar instruções maliciosas.

O que fazer com áudios muito longos?

O limite do endpoint é 25 MB, e notas de voz do WhatsApp raramente chegam perto disso. O problema real é custo e qualidade do agente com monólogos de vários minutos. Defina um teto de tamanho e, acima dele, responda pedindo um resumo por texto.

Como o agente sabe que a mensagem veio de um áudio?

Passe essa informação no contexto, algo como "(mensagem transcrita de áudio)". Com isso, o agente confirma dados críticos (data, horário, valor) antes de agir, porque erros de transcrição acontecem justamente em números.

Conclusão prática

A transcrição em si são cinco linhas de Http::attach. O que dá trabalho é o que fica em volta, e é ali que vale caprichar: assinatura validada sobre o corpo cru, unicidade pelo wamid, download sempre a partir do media_id, job com timeout menor que o retry_after e separação entre erro que merece retry e erro que não merece. Monte nessa ordem, escreva o teste com Http::fake antes de ligar o número de produção e trate o texto transcrito como entrada não confiável. Feito isso, o agente que você já tem para texto passa a atender quem só manda áudio, sem nenhuma mudança nele.