# Vérifier les signatures de webhook en Python

> Vérifiez les signatures des webhooks Ikiform dans Flask, FastAPI et Django, avec le package standardwebhooks ou la bibliothèque standard, plus un test pytest.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/webhook-signature-verification/python

Lisez le corps de la requête en octets avant que quoi que ce soit ne l'analyse : `request.get_data()` dans Flask, `await request.body()` dans FastAPI, `request.body` dans Django. Passez ces octets et les en-têtes à `Webhook(secret).verify()` du package `standardwebhooks`, ou calculez vous-même le HMAC-SHA256 avec `hmac` et comparez avec `hmac.compare_digest`. Répondez `400` en cas d'échec et n'analysez le JSON qu'une fois la vérification réussie.

Trois frameworks, deux façons de vérifier. Flask et Django utilisent le package `standardwebhooks` ; FastAPI utilise une fonction de la bibliothèque standard avec une suite pytest construite sur le [vecteur de test](https://ikiform.com/fr/guides/webhook-signature-verification.md#test-vector). Combinez-les comme vous voulez : la fonction et le package sont interchangeables.

## Ce qui diffère en Python

Les frameworks Python vous donnent le corps sous une forme qui semble brute mais ne l'est pas toujours. FastAPI est le plus piégeux : déclarez un modèle Pydantic en paramètre et FastAPI lit, analyse et valide le corps avant l'exécution de votre fonction. Vous obtenez un modèle, et les octets signés par Ikiform ne sont jamais entre vos mains. Le même piège existe dans Flask avec `request.get_json()` et dans Django REST framework avec `request.data`. Dans Starlette et FastAPI, le corps est un flux que `await request.body()` lit et met en cache, donc appelez-le avant que quoi que ce soit ne parcoure `request.stream()`.

Schéma : Un gestionnaire FastAPI avec un paramètre de corps Pydantic comparé à un gestionnaire qui lit le corps brut de la requête

1. Avec un modèle Pydantic comme paramètre de corps, FastAPI analyse et valide le JSON avant l'exécution du gestionnaire.
2. Resérialiser le modèle produit des octets différents, donc la signature échoue.
3. Prendre la Request et attendre request.body() donne les octets exacts.
4. Le gestionnaire les vérifie, les analyse avec json.loads, répond 204 et confie le travail lent à BackgroundTasks.

Validez avec Pydantic après la vérification, si vous voulez un modèle : Event.model_validate_json(body).

## Flask avec standardwebhooks

Installez le package avec `pip install standardwebhooks`.

`app.py`:

```python
import os

from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["IKIFORM_WEBHOOK_SECRET"])


@app.post("/webhooks/ikiform")
def ikiform_webhook():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError as error:
        return str(error), 400
    if event["type"] == "submission.created":
        app.logger.info("submission %s %s", event["id"], event["data"]["fields"])
    return "", 204
```

- `request.get_data()` renvoie le corps en octets. Flask le met en cache, donc appeler `get_json()` ensuite fonctionne toujours. Ne passez pas `as_text=True` : décoder d'abord est une étape qu'il vous faudrait ensuite défaire exactement.
- `Webhook(secret)` prend le secret `whsec_` complet et retire lui-même le préfixe. Construisez-le une fois à l'import, pas à chaque requête.
- `verify` met les noms d'en-têtes en minuscules, donc `dict(request.headers)` fonctionne quelle que soit la casse utilisée par le proxy. Il vérifie le timestamp, chaque signature `v1`, et renvoie le JSON analysé.

## FastAPI avec la bibliothèque standard

`signatures.py`:

```python
import base64
import binascii
import hashlib
import hmac
import re
import time
from collections.abc import Mapping

TOLERANCE_SECONDS = 5 * 60


class InvalidSignature(Exception):
    pass


def verify_signature(body: bytes, headers: Mapping[str, str], secret: str, now: float | None = None) -> None:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        raise InvalidSignature("missing_headers")
    if not re.fullmatch(r"[0-9]{1,12}", timestamp):
        raise InvalidSignature("invalid_timestamp")

    age = int(time.time() if now is None else now) - int(timestamp)
    if age > TOLERANCE_SECONDS:
        raise InvalidSignature("timestamp_too_old")
    if age < -TOLERANCE_SECONDS:
        raise InvalidSignature("timestamp_too_new")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = hmac.new(key, f"{msg_id}.{timestamp}.".encode() + body, hashlib.sha256).digest()

    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version != "v1":
            continue
        try:
            received = base64.b64decode(value, validate=True)
        except binascii.Error:
            continue
        if hmac.compare_digest(received, expected):
            return
    raise InvalidSignature("no_matching_signature")
```

`main.py`:

```python
import json
import logging
import os

from fastapi import BackgroundTasks, FastAPI, Request, Response

from signatures import InvalidSignature, verify_signature

app = FastAPI()
log = logging.getLogger("webhooks")
SECRET = os.environ["IKIFORM_WEBHOOK_SECRET"]


def handle_submission(delivery_id: str, fields: dict) -> None:
    log.info("submission %s %s", delivery_id, fields)


@app.post("/webhooks/ikiform")
async def ikiform_webhook(request: Request, background: BackgroundTasks) -> Response:
    body = await request.body()
    try:
        verify_signature(body, request.headers, SECRET)
    except InvalidSignature as error:
        return Response(str(error), status_code=400)
    event = json.loads(body)
    if event["type"] == "submission.created":
        background.add_task(handle_submission, event["id"], event["data"]["fields"])
    return Response(status_code=204)
```

- Le gestionnaire prend une `Request`, pas un modèle, et lit `await request.body()` en premier.
- `request.headers` n'est pas sensible à la casse, donc `.get("webhook-id")` trouve l'en-tête quelle que soit la façon dont il a été envoyé.
- `hmac.compare_digest` compare les octets décodés en temps constant. `validate=True` fait d'une signature malformée une non-correspondance au lieu d'une valeur partielle décodée silencieusement.
- `BackgroundTasks` s'exécute après l'envoi du `204`, donc le travail lent ne compte pas dans l'attente de 15 secondes d'Ikiform.
- Les annotations de type nécessitent Python 3.10 ou plus récent.

## Un test pytest

`test_signatures.py`:

```python
import json

import pytest

from signatures import InvalidSignature, verify_signature

SECRET = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw="
ROLLED = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A="
BODY = b'{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}'
HEADERS = {
    "webhook-id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
    "webhook-timestamp": "1792300000",
    "webhook-signature": "v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=",
}
NOW = 1792300000 + 30


def test_accepts_the_vector():
    verify_signature(BODY, HEADERS, SECRET, now=NOW)


def test_accepts_either_secret_during_a_roll():
    both = {**HEADERS, "webhook-signature": HEADERS["webhook-signature"] + " v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak="}
    verify_signature(BODY, both, SECRET, now=NOW)
    verify_signature(BODY, both, ROLLED, now=NOW)


def test_rejects_a_reserialized_body():
    pretty = json.dumps(json.loads(BODY), indent=2).encode()
    with pytest.raises(InvalidSignature, match="no_matching_signature"):
        verify_signature(pretty, HEADERS, SECRET, now=NOW)


def test_rejects_a_stale_timestamp():
    with pytest.raises(InvalidSignature, match="timestamp_too_old"):
        verify_signature(BODY, HEADERS, SECRET, now=NOW + 600)
```

Lancez `pytest`. Le `NOW` fixe garde le timestamp du vecteur dans la fenêtre de 5 minutes. `json.dumps(indent=2)` est une façon réaliste dont un corps se retrouve modifié dans une base de code Python, donc ce cas a toute sa place.

## Django

`webhooks/views.py`:

```python
import os

from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

webhook = Webhook(os.environ["IKIFORM_WEBHOOK_SECRET"])


@csrf_exempt
@require_POST
def ikiform_webhook(request):
    try:
        event = webhook.verify(request.body, dict(request.headers))
    except WebhookVerificationError as error:
        return HttpResponseBadRequest(str(error))
    if event["type"] == "submission.created":
        print("submission", event["id"])
    return HttpResponse(status=204)
```

`request.body` contient les octets bruts. `@csrf_exempt` est obligatoire, car Ikiform n'a pas de jeton CSRF et Django refuserait l'envoi avec `403` ; la vérification de signature remplace la protection CSRF pour cette seule vue. Lisez `request.body` avant de toucher à `request.POST`, ce qui ne compte que pour les envois de formulaire mais peut consommer le flux.

## Événements typés après vérification

Pydantic reste utile, une étape plus tard. Vérifiez les octets, puis validez ces mêmes octets dans un modèle. Vous obtenez un accès typé au payload et une erreur claire si un champ sur lequel vous comptez manque, sans renoncer à la vérification de signature :

`events.py`:

```python
from pydantic import BaseModel


class Form(BaseModel):
    id: str
    name: str


class SubmissionData(BaseModel):
    submission_id: str
    form: Form
    fields: dict[str, object]
    created_at: str
    truncated: bool


class SubmissionCreated(BaseModel):
    id: str
    type: str
    data: SubmissionData


def parse_submission(body: bytes) -> SubmissionCreated:
    return SubmissionCreated.model_validate_json(body)
```

Pydantic ignore par défaut les clés supplémentaires, donc les nouveaux champs qu'Ikiform ajoute au payload ne cassent pas le modèle. N'appelez `parse_submission(body)` que pour `submission.created` ; un événement `endpoint.test` a une forme de `data` différente.

## Ignorer les répétitions

Une livraison vérifiée peut arriver deux fois quand Ikiform ne reçoit pas votre réponse en 15 secondes et réessaie avec le même `webhook-id`. Dans Django, une colonne unique et une `IntegrityError` rendent la seconde sans effet :

`webhooks/models.py`:

```python
from django.db import IntegrityError, models, transaction


class Delivery(models.Model):
    delivery_id = models.CharField(max_length=64, unique=True)
    received_at = models.DateTimeField(auto_now_add=True)


def first_delivery(delivery_id: str) -> bool:
    try:
        with transaction.atomic():
            Delivery.objects.create(delivery_id=delivery_id)
    except IntegrityError:
        return False
    return True
```

Appelez `first_delivery(event["id"])` après la vérification ; s'il renvoie `False`, répondez `204` et arrêtez. Le bloc `atomic` empêche un insert échoué de casser le reste de la transaction de la requête. Avec SQLAlchemy, `insert(...).on_conflict_do_nothing()` fait la même chose sur PostgreSQL et SQLite.

> Le `verify` du package `standardwebhooks` décode le corps en UTF-8 avant le hachage, ce qui convient au JSON d'Ikiform. La version de la bibliothèque standard hache directement les octets, donc elle ne dépend d'aucun décodage.

## Vérifiez que tout fonctionne

1. Lancez `pytest`. Les quatre tests passent.
2. Démarrez l'app derrière une URL `https:` publique et ajoutez-la sous **Webhooks** dans le tableau de bord.
3. Définissez `IKIFORM_WEBHOOK_SECRET` avec le secret `whsec_` de l'endpoint et redémarrez le processus.
4. Choisissez **Send test**. Le journal de livraison affiche `204`, et le journal de votre application affiche l'événement `endpoint.test` avec son identifiant `dlv_`.
5. Envoyez le formulaire une fois avec un nom accentué, comme `Zoë`, et vérifiez qu'il arrive intact.

## Pièges

| Problème | Cause et solution |
| --- | --- |
| Le gestionnaire FastAPI échoue toujours avec un modèle de corps | Le modèle a analysé le corps en premier. Prenez la `Request`, vérifiez `await request.body()`, puis `Model.model_validate_json(body)` |
| `403 Forbidden` depuis Django | Le middleware CSRF. Ajoutez `@csrf_exempt` à la seule vue du webhook |
| `missing_headers` dans la version de la bibliothèque standard | Un simple `dict` d'en-têtes a gardé la casse du serveur. Utilisez l'objet d'en-têtes insensible à la casse du framework, ou mettez les clés en minuscules |
| `binascii.Error: Incorrect padding` au démarrage | Le secret a été copié sans son `=` final. Copiez à nouveau toute la valeur `whsec_` |
| `timestamp_too_old` à chaque livraison | L'horloge du serveur est décalée de plus de 5 minutes. Synchronisez-la avec NTP ; la signature elle-même est peut-être correcte |

## Questions

### Comment obtenir le corps brut de la requête dans FastAPI ?

Prenez l'objet Request en paramètre et appelez await request.body(). Ne déclarez pas de modèle Pydantic comme paramètre de corps pour une route de webhook ; il analyse le JSON avant que vous puissiez le vérifier.

### Le package Python standardwebhooks fonctionne-t-il avec Ikiform ?

Oui. Installez-le avec pip install standardwebhooks et appelez Webhook(secret).verify(body, headers) avec le secret whsec_ du tableau de bord. Il lève WebhookVerificationError quand une vérification échoue.

### Pourquoi utiliser hmac.compare_digest plutôt que == ?

== peut s'arrêter dès qu'un caractère diffère, ce qui laisse fuiter une information temporelle sur la part correcte d'une tentative. compare_digest prend le même temps dans tous les cas.

### Ai-je besoin de csrf_exempt pour une vue de webhook Django ?

Oui. Le middleware CSRF de Django refuse les envois sans jeton CSRF, et Ikiform n'en envoie pas. C'est la vérification de signature qui protège la vue à la place.

### Pourquoi ai-je une TypeError sur bytes et str en calculant le HMAC ?

hmac.new a besoin de bytes pour la clé comme pour le message. Décodez le secret avec base64.b64decode, qui renvoie des bytes, et construisez le message avec f"{msg_id}.{timestamp}.".encode() + body, où body correspond aux octets bruts de la requête.

## Autres guides

- [Vérifier les signatures de webhook](https://ikiform.com/fr/guides/webhook-signature-verification.md): Vérifiez qu'un webhook de formulaire vient bien d'Ikiform : le schéma HMAC-SHA256 de Standard Webhooks, un vecteur de test, les corps bruts, les timestamps et la rotation du secret.
- [Node.js](https://ikiform.com/fr/guides/webhook-signature-verification/nodejs.md): Vérifiez les signatures des webhooks Ikiform en Node.js : corps bruts dans Express et Fastify, le SDK, le package standardwebhooks et une version node:crypto testée.
- [Go net/http](https://ikiform.com/fr/guides/webhook-signature-verification/go.md): Vérifiez les signatures des webhooks Ikiform en Go avec net/http : lisez le corps une seule fois, vérifiez-le avec crypto/hmac, testez-le, ou utilisez la bibliothèque Standard Webhooks.
- [PHP](https://ikiform.com/fr/guides/webhook-signature-verification/php.md): Vérifiez les signatures des webhooks Ikiform en PHP simple avec hash_hmac et hash_equals, et dans Laravel avec la bibliothèque Standard Webhooks et une exception CSRF.
- [Ruby](https://ikiform.com/fr/guides/webhook-signature-verification/ruby.md): Vérifiez les signatures des webhooks Ikiform dans Sinatra avec OpenSSL et un test Minitest, et dans Rails avec la gem standardwebhooks et request.raw_post.
