Skip to content

Comment ajouter un récepteur de webhooks entrants

Recevoir des webhooks de plusieurs services externes, valider les signatures HMAC par source et stocker les événements avec idempotence.

Schéma

sql
CREATE TABLE webhook_sources (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE, secret TEXT NOT NULL,
    active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL
);
CREATE TABLE inbound_events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    source_id INTEGER NOT NULL REFERENCES webhook_sources(id),
    event_id TEXT NOT NULL, event_type TEXT NOT NULL,
    payload TEXT NOT NULL, processed_at TEXT NOT NULL,
    UNIQUE(source_id, event_id)
);

Routes

MéthodeCheminDescription
POST/sourcesEnregistrer une source de webhook
POST/sources/{id}/receiveRecevoir un webhook
GET/sources/{id}/eventsLister les événements reçus
GET/events/{id}Obtenir un événement spécifique

Validation de signature HMAC-SHA256

Chaque source a son propre secret HMAC. Ne jamais l'exposer dans les réponses.

php
private function verifySignature(string $body, string $header, string $secret): bool
{
    if (!str_starts_with($header, 'sha256=')) {
        return false;
    }
    $expected = hash_hmac('sha256', $body, $secret);
    return hash_equals($expected, substr($header, 7)); // sûr temporellement
}

Ordre d'appel : valider la signature d'abord, puis vérification d'idempotence, puis stocker :

php
if (!$this->verifySignature($rawBody, $sigHeader, $source['secret'])) {
    return $this->json->create(['error' => 'Invalid signature'], 401);
}
// ... vérification d'idempotence ...
$this->repo->storeEvent($sourceId, $eventId, $eventType, $rawBody, $now);

Idempotence (event_id par source)

php
$existing = $this->repo->findEventBySourceAndEventId($sourceId, $eventId);
if ($existing !== null) {
    return $this->json->create(['status' => 'already_processed', 'id' => $existing['id']]);
}

La contrainte UNIQUE(source_id, event_id) est le garde-fou au niveau DB. La vérification PHP ci-dessus évite le chemin d'exception lors du premier doublon.

Ne jamais exposer le secret

php
$source = $this->repo->findSource($id);
unset($source['secret']); // supprimer avant de retourner
return $this->json->create($source, 201);

Vérification de source inactive

php
if (!(bool) $source['active']) {
    return $this->json->create(['error' => 'Source is inactive'], 403);
}

Notes MySQL

La contrainte UNIQUE KEY uq_source_event (source_id, event_id) fonctionne de la même façon en MySQL. Utiliser VARCHAR(191) pour les colonnes texte indexées pour rester dans la limite de longueur de clé InnoDB.

Exécuter les tests d'intégration MySQL

Démarrer le conteneur MySQL FT partagé (port 3308, volume persistant) :

bash
docker compose -f ../NENE2-FT/docker-compose.yml up -d mysql

Puis exécuter les tests d'intégration avec les variables d'environnement :

bash
MYSQL_HOST=127.0.0.1 MYSQL_PORT=3308 MYSQL_DATABASE=ft_test \
  MYSQL_USER=ft_user MYSQL_PASSWORD=ft_pass \
  php8.4 vendor/bin/phpunit --filter Mysql

Sans MYSQL_HOST, les tests MySQL sont automatiquement ignorés (markTestSkipped).

Notes de sécurité

  • hash_equals() prévient les attaques temporelles lors de la comparaison de signatures.
  • Le corps JSON brut est stocké tel quel ; ne pas parser avant la vérification de signature.
  • Le même event_id de deux sources différentes crée des enregistrements séparés — la contrainte UNIQUE est (source_id, event_id), pas juste event_id.

Publié sous licence MIT.