MIGRATION COMPLETE DU WEBHOOK WHATSAPP NEXT.JS VERS LARAVEL =========================================================== Objectif -------- Reproduire dans un projet Laravel tout le fonctionnement WhatsApp actuellement présent dans le projet Next.js : - GET de vérification Meta ; - POST signé avec X-Hub-Signature-256 ; - lecture du corps HTTP brut avant décodage JSON ; - messages texte, image, vidéo, audio, document, sticker, localisation, contacts, boutons et réponses interactives ; - statuts sent, delivered, read et failed ; - conservation des erreurs Meta ; - plusieurs messages et statuts dans un même webhook ; - idempotence ; - transactions MySQL ; - protection multi-client par phone_number_id ; - enregistrement automatique du numéro configuré ; - conservation du payload brut ; - fichier de log storage/logs/webhooks.log ; - refus HTTP 500 si la transaction échoue afin que Meta effectue un retry. Compatibilité visée ------------------- Laravel 10, 11 ou 12, PHP 8.2+, MySQL 8/MariaDB récent. Aucune bibliothèque WhatsApp supplémentaire n'est nécessaire. URL à conserver --------------- GET et POST : https://votre-domaine.com/api/Automation/conversations Attention : sur Linux, les URL sont sensibles à la casse selon la configuration. 1. VARIABLES D'ENVIRONNEMENT ============================ Ajouter dans le .env Laravel, sans préfixe public : WHATSAPP_WEBHOOK_VERIFY_TOKEN=remplacer-par-un-token-long META_APP_SECRET=remplacer-par-le-secret-de-l-application-meta META_ACCESS_TOKEN=remplacer-par-un-token-meta-valide META_APP_ID=remplacer-par-app-id META_WABA_ID=remplacer-par-waba-id META_PHONE_NUMBER_ID=remplacer-par-phone-number-id WHATSAPP_OWNER_CLIENT_ID=10 Ne jamais placer ces valeurs dans VITE_*, MIX_* ou dans le navigateur. Le META_ACCESS_TOKEN n'est pas requis pour recevoir le webhook. Il sera utile plus tard pour télécharger les médias ou envoyer des messages. Ajouter dans config/services.php : 'whatsapp' => [ 'verify_token' => env('WHATSAPP_WEBHOOK_VERIFY_TOKEN'), 'app_secret' => env('META_APP_SECRET'), 'access_token' => env('META_ACCESS_TOKEN'), 'app_id' => env('META_APP_ID'), 'waba_id' => env('META_WABA_ID'), 'phone_number_id' => env('META_PHONE_NUMBER_ID'), 'owner_client_id' => env('WHATSAPP_OWNER_CLIENT_ID'), ], Après toute modification du .env : php artisan optimize:clear php artisan config:cache 2. CANAL DE LOG DEDIE ===================== Ajouter dans config/logging.php, dans la clé channels : 'webhooks' => [ 'driver' => 'daily', 'path' => storage_path('logs/webhooks.log'), 'level' => env('LOG_LEVEL', 'info'), 'days' => 30, 'replace_placeholders' => true, ], Le fichier produit sera : storage/logs/webhooks.log Commande de suivi : tail -f storage/logs/webhooks.log Ne jamais enregistrer META_APP_SECRET, META_ACCESS_TOKEN, la signature complète ou le texte complet des conversations dans les logs. 3. MIGRATIONS LARAVEL ===================== Créer les migrations : php artisan make:migration create_whatsapp_webhook_events_table php artisan make:migration create_whatsapp_phone_numbers_table php artisan make:migration create_whatsapp_contacts_table php artisan make:migration create_whatsapp_messages_table php artisan make:migration create_whatsapp_message_status_events_table 3.1 whatsapp_webhook_events -------------------------- public function up(): void { Schema::create('whatsapp_webhook_events', function (Blueprint $table) { $table->id(); $table->char('payload_sha256', 64)->unique(); $table->json('raw_payload'); $table->timestamp('received_at', 3)->useCurrent(); }); } public function down(): void { Schema::dropIfExists('whatsapp_webhook_events'); } 3.2 whatsapp_phone_numbers -------------------------- public function up(): void { Schema::create('whatsapp_phone_numbers', function (Blueprint $table) { $table->string('phone_number_id', 64)->primary(); $table->unsignedBigInteger('client_id'); $table->string('display_phone_number', 32)->nullable(); $table->timestamps(3); $table->index('client_id', 'idx_whatsapp_phone_client'); }); } public function down(): void { Schema::dropIfExists('whatsapp_phone_numbers'); } Si le projet Laravel possède une vraie table clients, ajouter une clé étrangère adaptée au nom réel de cette table. Ne pas inventer cette contrainte sans avoir inspecté le projet Laravel. 3.3 whatsapp_contacts --------------------- public function up(): void { Schema::create('whatsapp_contacts', function (Blueprint $table) { $table->id(); $table->string('phone_number_id', 64); $table->string('wa_id', 64); $table->string('profile_name')->nullable(); $table->timestamp('last_message_at', 3)->nullable(); $table->timestamps(3); $table->unique(['phone_number_id', 'wa_id'], 'uq_whatsapp_contact'); $table->foreign('phone_number_id') ->references('phone_number_id') ->on('whatsapp_phone_numbers'); }); } public function down(): void { Schema::dropIfExists('whatsapp_contacts'); } 3.4 whatsapp_messages --------------------- public function up(): void { Schema::create('whatsapp_messages', function (Blueprint $table) { $table->id(); $table->string('meta_message_id', 255); $table->string('phone_number_id', 64); $table->string('wa_id', 64)->nullable(); $table->enum('direction', ['incoming', 'outgoing']); $table->string('message_type', 64); $table->text('message_text')->nullable(); $table->string('media_id', 255)->nullable(); $table->string('media_mime_type', 127)->nullable(); $table->string('media_sha256', 255)->nullable(); $table->string('context_message_id', 255)->nullable(); $table->string('status', 32); $table->timestamp('status_timestamp', 3)->nullable(); $table->timestamp('message_timestamp', 3); $table->json('raw_payload'); $table->timestamps(3); $table->unique( ['phone_number_id', 'meta_message_id'], 'uq_whatsapp_message' ); $table->index( ['phone_number_id', 'wa_id', 'message_timestamp'], 'idx_whatsapp_messages_contact_time' ); $table->index( ['phone_number_id', 'status'], 'idx_whatsapp_messages_status' ); $table->foreign('phone_number_id') ->references('phone_number_id') ->on('whatsapp_phone_numbers'); }); } public function down(): void { Schema::dropIfExists('whatsapp_messages'); } 3.5 whatsapp_message_status_events ---------------------------------- public function up(): void { Schema::create('whatsapp_message_status_events', function (Blueprint $table) { $table->id(); $table->string('meta_message_id', 255); $table->string('phone_number_id', 64); $table->string('recipient_wa_id', 64)->nullable(); $table->string('status', 32); $table->timestamp('status_timestamp', 3); $table->string('error_code', 64)->nullable(); $table->text('error_message')->nullable(); $table->json('raw_payload'); $table->char('event_fingerprint', 64)->unique(); $table->timestamp('created_at', 3)->useCurrent(); $table->index( ['phone_number_id', 'meta_message_id', 'status_timestamp'], 'idx_whatsapp_status_message' ); $table->foreign('phone_number_id') ->references('phone_number_id') ->on('whatsapp_phone_numbers'); }); } public function down(): void { Schema::dropIfExists('whatsapp_message_status_events'); } Exécuter : php artisan migrate 4. MODELES ELOQUENT =================== Créer : app/Models/WhatsAppWebhookEvent.php app/Models/WhatsAppPhoneNumber.php app/Models/WhatsAppContact.php app/Models/WhatsAppMessage.php app/Models/WhatsAppMessageStatusEvent.php Modèle WhatsAppWebhookEvent : 'array', 'received_at' => 'datetime', ]; } Modèle WhatsAppPhoneNumber : 'datetime']; } Modèle WhatsAppMessage : 'array', 'message_timestamp' => 'datetime', 'status_timestamp' => 'datetime', ]; } Modèle WhatsAppMessageStatusEvent : 'array', 'status_timestamp' => 'datetime', 'created_at' => 'datetime', ]; } 5. SERVICE DE TRAITEMENT ======================== Créer app/Services/WhatsAppWebhookService.php : parse($payload); DB::transaction(function () use ($payload, $events, $requestId): void { $this->log('info', 'transaction_started', $requestId); $serialized = json_encode( $payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); $payloadHash = hash('sha256', $serialized); WhatsAppWebhookEvent::query()->firstOrCreate( ['payload_sha256' => $payloadHash], [ 'raw_payload' => $payload, 'received_at' => now(), ] ); $this->log('info', 'raw_webhook_persisted', $requestId, [ 'payloadHash' => $payloadHash, ]); $phoneIds = collect($events['messages']) ->pluck('phone_number_id') ->merge(collect($events['statuses'])->pluck('phone_number_id')) ->unique(); foreach ($phoneIds as $phoneId) { $this->ensureKnownPhone((string) $phoneId, $requestId); } foreach ($events['messages'] as $message) { $this->saveIncomingMessage($message, $requestId); } foreach ($events['statuses'] as $status) { $this->saveStatus($status, $requestId); } }, 3); // DB::transaction a réellement effectué le COMMIT à ce stade. $this->log('info', 'transaction_committed', $requestId, [ 'messageCount' => count($events['messages']), 'statusCount' => count($events['statuses']), ]); return [ 'message_count' => count($events['messages']), 'status_count' => count($events['statuses']), ]; } private function parse(array $payload): array { if (($payload['object'] ?? null) !== 'whatsapp_business_account') { throw new RuntimeException('Unsupported webhook object'); } $messages = []; $statuses = []; foreach (Arr::wrap($payload['entry'] ?? []) as $entry) { foreach (Arr::wrap($entry['changes'] ?? []) as $change) { $value = is_array($change['value'] ?? null) ? $change['value'] : []; $phoneNumberId = $value['metadata']['phone_number_id'] ?? null; if (!is_string($phoneNumberId) || $phoneNumberId === '') { continue; } $profileNames = []; foreach (Arr::wrap($value['contacts'] ?? []) as $contact) { $waId = $contact['wa_id'] ?? null; $name = $contact['profile']['name'] ?? null; if (is_string($waId) && is_string($name)) { $profileNames[$waId] = $name; } } foreach (Arr::wrap($value['messages'] ?? []) as $message) { $id = $message['id'] ?? null; $from = $message['from'] ?? null; if (!is_string($id) || !is_string($from)) { continue; } $type = is_string($message['type'] ?? null) ? $message['type'] : 'unknown'; $content = is_array($message[$type] ?? null) ? $message[$type] : []; $messages[] = [ 'meta_message_id' => $id, 'phone_number_id' => $phoneNumberId, 'wa_id' => $from, 'profile_name' => $profileNames[$from] ?? null, 'message_type' => $type, 'message_text' => $this->extractMessageText($message, $type), 'media_id' => $content['id'] ?? null, 'media_mime_type' => $content['mime_type'] ?? null, 'media_sha256' => $content['sha256'] ?? null, 'context_message_id' => $message['context']['id'] ?? null, 'message_timestamp' => $this->timestamp($message['timestamp'] ?? null), 'raw_payload' => $message, ]; } foreach (Arr::wrap($value['statuses'] ?? []) as $status) { $id = $status['id'] ?? null; $state = $status['status'] ?? null; if (!is_string($id) || !is_string($state)) { continue; } $error = Arr::wrap($status['errors'] ?? [])[0] ?? []; $statuses[] = [ 'meta_message_id' => $id, 'phone_number_id' => $phoneNumberId, 'recipient_wa_id' => $status['recipient_id'] ?? null, 'status' => $state, 'status_timestamp' => $this->timestamp($status['timestamp'] ?? null), 'error_code' => isset($error['code']) ? (string) $error['code'] : null, 'error_message' => $error['message'] ?? $error['title'] ?? $error['error_data']['details'] ?? null, 'raw_payload' => $status, ]; } } } return compact('messages', 'statuses'); } private function extractMessageText(array $message, string $type): ?string { return match ($type) { 'text' => $message['text']['body'] ?? null, 'button' => $message['button']['text'] ?? $message['button']['payload'] ?? null, 'interactive' => $message['interactive']['button_reply']['title'] ?? $message['interactive']['button_reply']['id'] ?? $message['interactive']['list_reply']['title'] ?? $message['interactive']['list_reply']['id'] ?? null, 'location' => trim(implode(' — ', array_filter([ $message['location']['name'] ?? null, $message['location']['address'] ?? null, ]))) ?: null, 'contacts' => json_encode( $message['contacts'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ), default => $message[$type]['caption'] ?? null, }; } private function timestamp(mixed $timestamp): CarbonImmutable { if (!is_numeric($timestamp)) { return CarbonImmutable::createFromTimestampUTC(0); } return CarbonImmutable::createFromTimestampUTC((int) $timestamp); } private function ensureKnownPhone(string $phoneNumberId, string $requestId): void { $this->log('info', 'phone_number_check_started', $requestId, [ 'phoneNumberId' => $phoneNumberId, ]); $phone = WhatsAppPhoneNumber::query()->find($phoneNumberId); $configuredPhoneId = trim((string) config('services.whatsapp.phone_number_id')); $ownerClientId = config('services.whatsapp.owner_client_id'); if (!$phone && hash_equals($configuredPhoneId, $phoneNumberId)) { if (!$ownerClientId || !ctype_digit((string) $ownerClientId)) { throw new RuntimeException('WHATSAPP_OWNER_CLIENT_ID missing or invalid'); } $phone = WhatsAppPhoneNumber::query()->updateOrCreate( ['phone_number_id' => $phoneNumberId], ['client_id' => (int) $ownerClientId] ); $this->log('info', 'configured_phone_registered', $requestId, [ 'phoneNumberId' => $phoneNumberId, 'clientId' => (int) $ownerClientId, ]); } if (!$phone) { throw new RuntimeException( "Unregistered WhatsApp phone_number_id: {$phoneNumberId}" ); } $this->log('info', 'phone_number_authorized', $requestId, [ 'phoneNumberId' => $phoneNumberId, ]); } private function saveIncomingMessage(array $message, string $requestId): void { WhatsAppContact::query()->updateOrCreate( [ 'phone_number_id' => $message['phone_number_id'], 'wa_id' => $message['wa_id'], ], [ 'profile_name' => $message['profile_name'], 'last_message_at' => DB::raw( "GREATEST(COALESCE(last_message_at, '{$message['message_timestamp']->format('Y-m-d H:i:s.u')}'), '{$message['message_timestamp']->format('Y-m-d H:i:s.u')}')" ), ] ); $this->log('info', 'contact_upserted', $requestId, [ 'phoneNumberId' => $message['phone_number_id'], 'metaMessageId' => $message['meta_message_id'], ]); WhatsAppMessage::query()->firstOrCreate( [ 'phone_number_id' => $message['phone_number_id'], 'meta_message_id' => $message['meta_message_id'], ], [ 'wa_id' => $message['wa_id'], 'direction' => 'incoming', 'message_type' => $message['message_type'], 'message_text' => $message['message_text'], 'media_id' => $message['media_id'], 'media_mime_type' => $message['media_mime_type'], 'media_sha256' => $message['media_sha256'], 'context_message_id' => $message['context_message_id'], 'status' => 'received', 'message_timestamp' => $message['message_timestamp'], 'raw_payload' => $message['raw_payload'], ] ); $this->log('info', 'message_persisted', $requestId, [ 'phoneNumberId' => $message['phone_number_id'], 'metaMessageId' => $message['meta_message_id'], 'messageType' => $message['message_type'], ]); } private function saveStatus(array $status, string $requestId): void { $fingerprint = hash('sha256', implode("\0", [ $status['phone_number_id'], $status['meta_message_id'], $status['status'], $status['status_timestamp']->toISOString(), $status['error_code'] ?? '', ])); WhatsAppMessageStatusEvent::query()->firstOrCreate( ['event_fingerprint' => $fingerprint], [ 'meta_message_id' => $status['meta_message_id'], 'phone_number_id' => $status['phone_number_id'], 'recipient_wa_id' => $status['recipient_wa_id'], 'status' => $status['status'], 'status_timestamp' => $status['status_timestamp'], 'error_code' => $status['error_code'], 'error_message' => $status['error_message'], 'raw_payload' => $status['raw_payload'], 'created_at' => now(), ] ); $this->log('info', 'status_history_persisted', $requestId, [ 'phoneNumberId' => $status['phone_number_id'], 'metaMessageId' => $status['meta_message_id'], 'messageStatus' => $status['status'], ]); // Cette condition empêche un ancien statut d'écraser un statut récent. WhatsAppMessage::query() ->where('phone_number_id', $status['phone_number_id']) ->where('meta_message_id', $status['meta_message_id']) ->where(function ($query) use ($status) { $query->whereNull('status_timestamp') ->orWhere('status_timestamp', '<=', $status['status_timestamp']); }) ->update([ 'status' => $status['status'], 'status_timestamp' => $status['status_timestamp'], ]); $this->log('info', 'message_status_update_attempted', $requestId, [ 'phoneNumberId' => $status['phone_number_id'], 'metaMessageId' => $status['meta_message_id'], 'messageStatus' => $status['status'], ]); } private function log( string $level, string $event, string $requestId, array $details = [] ): void { Log::channel('webhooks')->log($level, $event, [ 'service' => 'whatsapp_webhook', 'event' => $event, 'requestId' => $requestId, ...$details, ]); } } NOTE IMPORTANTE SUR last_message_at ---------------------------------- Si le projet impose PostgreSQL au lieu de MySQL, remplacer l'expression GREATEST/COALESCE par une expression adaptée. Avec MySQL, le code ci-dessus est compatible. Pour éviter toute expression SQL, on peut aussi verrouiller le contact avec lockForUpdate(), comparer les dates en PHP puis sauvegarder. 6. CONTROLEUR DU WEBHOOK ======================== Créer app/Http/Controllers/WhatsAppWebhookController.php : log('info', 'verification_received', $requestId, [ 'method' => 'GET', ]); $mode = $request->query('hub_mode', $request->query('hub.mode')); $token = $request->query('hub_verify_token', $request->query('hub.verify_token')); $challenge = $request->query('hub_challenge', $request->query('hub.challenge')); $configuredToken = (string) config('services.whatsapp.verify_token'); $accepted = $mode === 'subscribe' && $configuredToken !== '' && is_string($token) && hash_equals($configuredToken, $token) && $challenge !== null; $this->log( $accepted ? 'info' : 'warning', $accepted ? 'verification_accepted' : 'verification_rejected', $requestId, [ 'status' => $accepted ? 200 : 403, 'mode' => $mode, 'hasChallenge' => $challenge !== null, 'durationMs' => (int) ((microtime(true) - $startedAt) * 1000), ] ); return response( $accepted ? (string) $challenge : 'Forbidden', $accepted ? 200 : 403, ['Content-Type' => 'text/plain; charset=UTF-8'] ); } public function receive( Request $request, WhatsAppWebhookService $service ): JsonResponse { $requestId = substr( (string) ($request->header('X-Request-Id') ?: Str::uuid()), 0, 128 ); $startedAt = microtime(true); $this->log('info', 'webhook_function_called', $requestId, [ 'functionCalled' => true, 'method' => 'POST', 'contentType' => $request->header('Content-Type'), ]); $secret = trim((string) config('services.whatsapp.app_secret')); if ($secret === '') { $this->log('error', 'configuration_invalid', $requestId, [ 'functionCalled' => true, 'processed' => false, 'reason' => 'META_APP_SECRET missing', 'status' => 503, ]); return response()->json(['success' => false], 503); } // getContent() retourne le corps brut exact requis pour le HMAC. $rawBody = $request->getContent(); $this->log('info', 'body_read', $requestId, [ 'bodyBytes' => strlen($rawBody), ]); $signature = $request->header('X-Hub-Signature-256'); $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret); if (!is_string($signature) || !hash_equals($expected, $signature)) { $this->log('warning', 'signature_rejected', $requestId, [ 'functionCalled' => true, 'processed' => false, 'status' => 403, 'durationMs' => (int) ((microtime(true) - $startedAt) * 1000), ]); return response()->json(['success' => false], 403); } $this->log('info', 'signature_verified', $requestId); try { $payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $exception) { $this->log('warning', 'json_rejected', $requestId, [ 'functionCalled' => true, 'processed' => false, 'errorMessage' => $exception->getMessage(), 'status' => 400, ]); return response()->json([ 'success' => false, 'error' => 'Invalid JSON', ], 400); } if (!is_array($payload)) { return response()->json(['success' => false], 400); } $this->log('info', 'json_parsed', $requestId); try { $this->log('info', 'persistence_started', $requestId); $result = $service->persist($payload, $requestId); $this->log('info', 'webhook_processed_successfully', $requestId, [ 'functionCalled' => true, 'processed' => true, 'status' => 200, 'messageCount' => $result['message_count'], 'statusCount' => $result['status_count'], 'durationMs' => (int) ((microtime(true) - $startedAt) * 1000), ]); return response()->json(['success' => true], 200); } catch (Throwable $exception) { report($exception); $this->log('error', 'webhook_processing_failed', $requestId, [ 'functionCalled' => true, 'processed' => false, 'errorName' => $exception::class, 'errorMessage' => $exception->getMessage(), 'status' => 500, 'durationMs' => (int) ((microtime(true) - $startedAt) * 1000), ]); // HTTP 500 est volontaire : Meta pourra renvoyer le webhook. return response()->json(['success' => false], 500); } } private function log( string $level, string $event, string $requestId, array $details = [] ): void { Log::channel('webhooks')->log($level, $event, [ 'service' => 'whatsapp_webhook', 'event' => $event, 'requestId' => $requestId, ...$details, ]); } } 7. ROUTES ========= Dans routes/api.php : use App\Http\Controllers\WhatsAppWebhookController; use Illuminate\Support\Facades\Route; Route::get('/Automation/conversations', [ WhatsAppWebhookController::class, 'verify', ]); Route::post('/Automation/conversations', [ WhatsAppWebhookController::class, 'receive', ]); Avec le préfixe standard /api de Laravel, l'URL finale sera : https://votre-domaine.com/api/Automation/conversations Vérifier avec : php artisan route:list --path=Automation/conversations Si le projet Laravel a désactivé routes/api.php, enregistrer ce fichier dans bootstrap/app.php conformément à la structure de la version Laravel utilisée. 8. CSRF ET MIDDLEWARE ===================== Une route placée dans routes/api.php n'utilise normalement pas le middleware CSRF web. Ne pas désactiver globalement la protection CSRF. Si la route est exceptionnellement placée dans routes/web.php, l'exclure de la protection CSRF uniquement pour ce chemin : api/Automation/conversations Il est fortement recommandé de conserver la route dans routes/api.php. Ne pas appliquer un middleware d'authentification utilisateur à ce webhook : Meta ne possède pas de session Laravel. La sécurité est assurée par : - le Verify Token sur GET ; - X-Hub-Signature-256 sur POST ; - META_APP_SECRET ; - la liste autorisée de phone_number_id ; - WHATSAPP_OWNER_CLIENT_ID. 9. TESTS FEATURE ================ Créer tests/Feature/WhatsAppWebhookTest.php : set('services.whatsapp.verify_token', 'verify-me'); config()->set('services.whatsapp.app_secret', $this->secret); config()->set('services.whatsapp.phone_number_id', 'phone-1'); config()->set('services.whatsapp.owner_client_id', '10'); } public function test_get_verification_succeeds(): void { $this->get('/api/Automation/conversations?' . http_build_query([ 'hub.mode' => 'subscribe', 'hub.verify_token' => 'verify-me', 'hub.challenge' => '123456', ]))->assertOk()->assertSeeText('123456'); } public function test_get_verification_is_rejected(): void { $this->get('/api/Automation/conversations?' . http_build_query([ 'hub.mode' => 'subscribe', 'hub.verify_token' => 'wrong', 'hub.challenge' => '123456', ]))->assertForbidden(); } public function test_valid_signed_text_message_is_saved(): void { $payload = $this->payload([ [ 'from' => '212600000000', 'id' => 'wamid.test.1', 'timestamp' => '1700000000', 'type' => 'text', 'text' => ['body' => 'Bonjour'], ], ]); $this->signedPost($payload)->assertOk(); $this->assertDatabaseHas('whatsapp_messages', [ 'meta_message_id' => 'wamid.test.1', 'phone_number_id' => 'phone-1', 'message_type' => 'text', 'message_text' => 'Bonjour', 'status' => 'received', ]); } public function test_invalid_signature_is_rejected(): void { $payload = $this->payload([]); $body = json_encode($payload, JSON_THROW_ON_ERROR); $this->call( 'POST', '/api/Automation/conversations', [], [], [], [ 'CONTENT_TYPE' => 'application/json', 'HTTP_X_HUB_SIGNATURE_256' => 'sha256=' . str_repeat('0', 64), ], $body )->assertForbidden(); } public function test_replayed_webhook_is_idempotent(): void { $payload = $this->payload([ [ 'from' => '212600000000', 'id' => 'wamid.duplicate', 'timestamp' => '1700000000', 'type' => 'text', 'text' => ['body' => 'Test'], ], ]); $this->signedPost($payload)->assertOk(); $this->signedPost($payload)->assertOk(); $this->assertDatabaseCount('whatsapp_messages', 1); $this->assertDatabaseCount('whatsapp_webhook_events', 1); } private function signedPost(array $payload) { $body = json_encode( $payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); $signature = 'sha256=' . hash_hmac('sha256', $body, $this->secret); return $this->call( 'POST', '/api/Automation/conversations', [], [], [], [ 'CONTENT_TYPE' => 'application/json', 'HTTP_X_HUB_SIGNATURE_256' => $signature, ], $body ); } private function payload(array $messages, array $statuses = []): array { return [ 'object' => 'whatsapp_business_account', 'entry' => [[ 'id' => 'waba-test', 'changes' => [[ 'field' => 'messages', 'value' => [ 'messaging_product' => 'whatsapp', 'metadata' => ['phone_number_id' => 'phone-1'], 'contacts' => [[ 'wa_id' => '212600000000', 'profile' => ['name' => 'Client Test'], ]], 'messages' => $messages, 'statuses' => $statuses, ], ]], ]], ]; } } Ajouter également les scénarios suivants : - message image et métadonnées média ; - plusieurs messages dans un webhook ; - delivered ; - read ; - failed avec errors[] ; - statut reçu avant l'existence du message sortant ; - statuts reçus dans le désordre ; - type inconnu ; - panne de base retournant HTTP 500 ; - phone_number_id inconnu retournant HTTP 500 ; - corps JSON invalide retournant HTTP 400. Exécuter : php artisan test --filter=WhatsAppWebhookTest 10. COMMANDES DE DEPLOIEMENT ============================ composer install --no-dev --optimize-autoloader php artisan optimize:clear php artisan migrate --force php artisan config:cache php artisan route:cache php artisan event:cache Redémarrer ensuite PHP-FPM/les workers selon l'hébergement. Si une queue est ajoutée plus tard : php artisan queue:restart 11. CONFIGURATION META ====================== URL de rappel : https://votre-domaine.com/api/Automation/conversations Verify Token : valeur exacte de WHATSAPP_WEBHOOK_VERIFY_TOKEN. Objet webhook : WhatsApp Business Account. Champ à abonner : messages. Le GET doit retourner exactement hub.challenge avec HTTP 200. Le POST valide doit retourner HTTP 200 seulement après le COMMIT. 12. CONTROLE APRES MIGRATION ============================ Suivre les logs : tail -f storage/logs/webhooks.log Événements attendus : webhook_function_called body_read signature_verified json_parsed persistence_started transaction_started raw_webhook_persisted configured_phone_registered (premier appel seulement) phone_number_authorized contact_upserted message_persisted transaction_committed webhook_processed_successfully Requêtes SQL de contrôle : SELECT * FROM whatsapp_phone_numbers; SELECT * FROM whatsapp_webhook_events ORDER BY received_at DESC LIMIT 10; SELECT * FROM whatsapp_contacts ORDER BY updated_at DESC LIMIT 10; SELECT * FROM whatsapp_messages ORDER BY created_at DESC LIMIT 10; SELECT * FROM whatsapp_message_status_events ORDER BY created_at DESC LIMIT 10; 13. POINTS A NE PAS OUBLIER =========================== 1. Lire le corps brut avant tout json_decode pour vérifier la signature. 2. Utiliser hash_equals pour éviter les comparaisons temporelles faibles. 3. Ne jamais télécharger un média pendant la requête webhook. 4. Ne jamais lancer un traitement non attendu après avoir répondu HTTP 200. 5. Conserver les événements de statut même si le message sortant n'existe pas. 6. Ne jamais remplacer un statut récent par un événement plus ancien. 7. Garder les contraintes uniques : elles assurent l'idempotence. 8. Refuser un phone_number_id inconnu. 9. Ne pas journaliser les secrets ni le contenu intégral des messages. 10. Retourner HTTP 500 après un rollback pour permettre le retry Meta. 11. Le faux phone_number_id des exemples Meta doit rester refusé. 12. Le numéro réel est enregistré automatiquement seulement s'il correspond à META_PHONE_NUMBER_ID et si WHATSAPP_OWNER_CLIENT_ID est configuré. 14. FICHIERS NEXT.JS D'ORIGINE A REMPLACER ========================================== Les fonctionnalités proviennent de : src/app/api/Automation/conversations/route.ts src/app/api/webhooks/whatsapp/route.ts src/lib/whatsapp/logger.ts src/lib/whatsapp/repository.ts src/lib/whatsapp/types.ts src/lib/whatsapp/webhook.ts scripts/migrate-whatsapp.ts tests/whatsapp-webhook.test.ts Après validation du projet Laravel et bascule du webhook Meta, ces fichiers Next.js ne doivent plus recevoir les événements. Ne supprimer l'ancienne route qu'après confirmation que Laravel reçoit, valide et persiste les vrais messages. 15. PLAN DE BASCULE SANS PERTE ============================== 1. Déployer les migrations et le code Laravel. 2. Tester localement avec les tests signés. 3. Tester le GET Laravel avec hub.challenge. 4. Envoyer un POST signé simulé vers Laravel. 5. Vérifier les cinq tables et webhooks.log. 6. Modifier l'URL du webhook dans Meta vers Laravel. 7. Envoyer un vrai message au numéro Business. 8. Vérifier message_persisted et transaction_committed. 9. Vérifier le message dans whatsapp_messages. 10. Surveiller pendant plusieurs heures les erreurs 4xx/5xx. 11. Désactiver ensuite seulement l'ancien endpoint Next.js. FIN DU DOCUMENT