Webhooks
Les webhooks sont la façon dont Humind notifie votre backend quand quelque chose d'intéressant se passe, sans que vous ayez à poller. Vous déclarez une URL HTTPS de votre côté, vous l'abonnez à un ou plusieurs types d'event via POST /public/v1/webhooks/endpoints, et Humind envoie un payload JSON signé à cette URL à chaque fois qu'un event matche.
Les events disponibles sont import.completed et import.failed, donc vous pouvez arrêter de poller GET /imports/{sync_id} une fois qu'un import bulk est en cours.
Quickstart
La boucle complète, de bout en bout :
1. POST /webhooks/endpoints → { id, secret } (one-time, copiez le secret)
2. <event chez Humind> → POST {votre_url} avec X-Humind-Signature
3. Vérifiez la signature, ack 2xx → terminé ; non-2xx déclenche un retryTrois étapes pour câbler tout ça :
- Créez un endpoint avec
POST /public/v1/webhooks/endpoints. La réponse contient unsecretone-time, stockez-le dans votre secret manager immédiatement, vous ne le reverrez plus. - Recevez les deliveries sur l'URL que vous avez enregistrée. Humind envoie un
POSTJSON avec le payload spécifique à l'event, plus les headers de signature. - Vérifiez la signature avant de faire confiance au payload, ack avec n'importe quel
2xx. Tout ce qui n'est pas 2xx (ou pas de réponse en moins de 10 secondes) déclenche les retries automatiques.
Étape 1 : Créer un endpoint
POST /public/v1/webhooks/endpoints enregistre une URL de destination et la liste des types d'event que vous voulez recevoir.
Scope requis : webhooks:manage
Request
curl -X POST https://api.thehumind.com/public/v1/webhooks/endpoints \
-H "Authorization: Bearer hmd_live_..." \
-H "Idempotency-Key: 7c1a8d92-3f4b-4e6f-b2a1-d5e9c8f7a3b4" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.merchant.example/humind-webhooks",
"events": ["import.completed", "import.failed"]
}'| Champ | Type | Requis | Description |
|---|---|---|---|
url | string | Oui | URL HTTPS sur laquelle Humind enverra ses POST. Doit être en https:// et publiquement joignable. http:// et les hostnames privés (par exemple localhost, 127.0.0.1) sont rejetés avec invalid_url en mode live. |
events | string[] | Oui | Liste des types d'event à abonner. Voir Events disponibles. Au moins un. Un type d'event inconnu renvoie invalid_event_type. |
Response 201 Created
{
"id": "whe_5f9e3a2b7c1d4e8a9b3f0c2d",
"url": "https://api.merchant.example/humind-webhooks",
"events": ["import.completed", "import.failed"],
"status": "active",
"prefix": "whsec_5f9e3a2b7c1d4e8a9b3f0c",
"secret": "whsec_5f9e3a2b7c1d4e8a9b3f0c2d4e5f6a7b_a1b2c3d4",
"failure_count": 0,
"last_delivered_at": null,
"last_failed_at": null,
"created_at": "2026-04-25T14:30:00Z"
}Le secret de signature n'est montré qu'une seule fois
Le secret complet est renvoyé uniquement par ce POST et par POST /rotate-secret. Les appels GET renvoient prefix (pour que vous puissiez identifier quel secret est en service) mais jamais la valeur complète. Stockez-le dans votre secret manager, Vault, AWS Secrets Manager, Azure Key Vault, Doppler, avant de faire quoi que ce soit d'autre. Si vous le perdez, il faudra rotater et mettre à jour les deux côtés.
Le prefix correspond aux 22 premiers caractères du secret. Vous pouvez l'afficher dans votre dashboard ou vos logs sans leaker le secret lui-même.
Étape 2 : Recevoir les deliveries
Quand un event abonné se produit, Humind envoie un POST HTTPS sur votre url avec ces headers :
| Header | Exemple | Rôle |
|---|---|---|
Content-Type | application/json | Le body est toujours du JSON. |
X-Humind-Event-Type | import.completed | Quel event a fired. Même valeur que event_type dans le body. |
X-Humind-Event-Id | evt_8f3a1c2d4e5b6a7f1e2d3c4b | Unique par event. Utilisez-le pour dédupliquer, voir Idempotency. |
X-Humind-Timestamp | 1745595600 | Unix seconds au moment de la génération de la delivery. Sert à vérifier la signature et à rejeter les replays. |
X-Humind-Signature | t=1745595600,v1=8f3a1c2d4e... | Signature HMAC-SHA256 de <timestamp>.<raw_body>. Voir Étape 3. |
Body
Le body est du JSON. La forme exacte dépend du type d'event, voir Events disponibles.
{
"event_id": "evt_8f3a1c2d4e5b6a7f1e2d3c4b",
"event_type": "import.completed",
"created_at": "2026-04-25T14:41:55Z",
"data": {
"sync_id": "b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b",
"status": "done",
"resource_type": "product",
"total_products": 12000,
"synced_products": 12000,
"report": { "created": 9876, "updated": 2100, "failed": 24 },
"error_logs_count": 24,
"started_at": "2026-04-25T14:31:12Z",
"completed_at": "2026-04-25T14:41:55Z"
}
}Response
Répondez avec n'importe quel 2xx en moins de 10 secondes. Tout le reste, non-2xx, timeout, connection refused, erreur TLS, compte comme un échec et déclenche la retry policy. Le body de votre réponse est ignoré ; un 200 OK vide est OK.
Répondez vite, traitez en async
Ne faites pas de gros traitements de manière synchrone dans le handler. Push le payload sur une queue (Redis, SQS, Pub/Sub, un cron interne) et ack avec 200 immédiatement. Un handler qui prend 8 secondes pour écrire en base est à une interruption réseau d'un timeout, et Humind retry sur timeout, ce qui veut dire que le même event arrive deux fois.
Étape 3 : Vérifier la signature
Chaque delivery est signée. Avant de faire confiance au payload, votre handler doit :
- Lire
X-Humind-Timestampet rejeter s'il est en dehors d'une fenêtre ±5 minutes par rapport à votre horloge serveur (protection replay). - Calculer
HMAC_SHA256(secret, "<timestamp>.<raw_body>")en utilisant le raw body non parsé. - Comparer à la valeur
v1=dansX-Humind-Signatureavec une comparaison constant-time.
Vérifiez avant de parser le JSON ou de déclencher le moindre side effect. La signature est ce qui prouve que le payload vient bien de Humind et n'a pas été altéré, sans elle, n'importe qui qui trouve l'URL de votre endpoint peut poster n'importe quoi.
Node.js
import crypto from 'node:crypto'
function verifyHumindSignature(rawBody, headers, secret) {
const sigHeader = headers['x-humind-signature'] || ''
const tsHeader = headers['x-humind-timestamp'] || ''
// 1. Le timestamp doit être à +/- 5 minutes de maintenant
const now = Math.floor(Date.now() / 1000)
const ts = parseInt(tsHeader, 10)
if (!ts || Math.abs(now - ts) > 5 * 60) return false
// 2. Recalculer le HMAC sur "<timestamp>.<raw_body>"
const signed = `${tsHeader}.${rawBody}`
const expected = crypto
.createHmac('sha256', secret)
.update(signed)
.digest('hex')
// 3. Extraire le composant v1 et comparer en constant time
const provided = (sigHeader.split(',').find(p => p.startsWith('v1=')) || '').slice(3)
if (!provided || provided.length !== expected.length) return false
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(provided, 'hex'),
)
}Utilisez le raw body, pas un JSON re-sérialisé
Les frameworks comme Express parsent le body avant que votre handler tourne. Si vous appelez JSON.stringify(req.body) pour recalculer le HMAC, l'ordre des clés ou le whitespace exact peuvent différer de ce que Humind a signé et le check échouera. Capturez le Buffer brut (Express : express.raw({ type: 'application/json' })) et passez-le au verifier.
Python
import hmac
import hashlib
import time
def verify_humind_signature(raw_body: bytes, headers: dict, secret: str) -> bool:
sig_header = headers.get("X-Humind-Signature", "")
ts_header = headers.get("X-Humind-Timestamp", "")
# 1. Le timestamp doit être à +/- 5 minutes de maintenant
try:
ts = int(ts_header)
except ValueError:
return False
if abs(int(time.time()) - ts) > 5 * 60:
return False
# 2. Recalculer le HMAC sur "<timestamp>.<raw_body>"
signed = f"{ts_header}.".encode("utf-8") + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
# 3. Extraire le composant v1 et comparer en constant time
provided = ""
for part in sig_header.split(","):
if part.startswith("v1="):
provided = part[3:]
break
if not provided:
return False
return hmac.compare_digest(expected, provided)Ruby
require "openssl"
require "rack/utils"
def verify_humind_signature(raw_body, headers, secret)
sig_header = headers["X-Humind-Signature"].to_s
ts_header = headers["X-Humind-Timestamp"].to_s
# 1. Le timestamp doit être à +/- 5 minutes de maintenant
ts = Integer(ts_header) rescue nil
return false if ts.nil? || (Time.now.to_i - ts).abs > 5 * 60
# 2. Recalculer le HMAC sur "<timestamp>.<raw_body>"
signed = "#{ts_header}.#{raw_body}"
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
# 3. Extraire le composant v1 et comparer en constant time
provided = sig_header.split(",").find { |p| p.start_with?("v1=") }&.sub("v1=", "")
return false if provided.nil? || provided.empty?
Rack::Utils.secure_compare(expected, provided)
endL'objet Webhook Endpoint
| Champ | Type | Description |
|---|---|---|
id | string | Identifiant stable, préfixé whe_. À utiliser dans le path de tous les appels suivants. |
url | string | URL HTTPS sur laquelle Humind livre. |
events | string[] | Types d'event auxquels cet endpoint est abonné. |
status | 'active' | 'disabled' | Si les deliveries sont actuellement tentées. Voir Disable vs delete. |
prefix | string | 22 premiers caractères du secret de signature. Safe à logger ; utile pour confirmer quel secret est en service après une rotation. |
failure_count | integer | Nombre de deliveries en échec consécutives depuis la dernière réussie. Reset à 0 à la moindre delivery réussie. |
last_delivered_at | ISO 8601 | null | Quand la dernière delivery réussie s'est terminée. null si aucune delivery n'a encore réussi. |
last_failed_at | ISO 8601 | null | Quand la delivery en échec la plus récente s'est produite. null si aucune n'a échoué. |
disabled_reason | string | null | Set quand l'endpoint a été auto-disabled. consecutive_failures. |
created_at | ISO 8601 | Quand l'endpoint a été enregistré. |
Le secret complet est renvoyé uniquement par POST /webhooks/endpoints et POST /webhooks/endpoints/:id/rotate-secret. GET renvoie prefix mais jamais la valeur complète.
L'objet Delivery
Une delivery c'est une tentative d'envoi d'un event vers un endpoint. Chaque event crée une row delivery, qui est mise à jour en place au fur et à mesure des retries.
| Champ | Type | Description |
|---|---|---|
id | string | Identifiant stable, préfixé whd_. |
webhook_endpoint_id | string | L'endpoint que cette delivery ciblait. |
event_type | string | Par exemple import.completed. |
event_id | string | Unique par event. Même valeur que X-Humind-Event-Id et que l'event_id dans le body. |
status | 'pending' | 'delivered' | 'failed' | 'permanently_failed' | État du cycle de vie. failed veut dire qu'au moins une tentative a échoué mais que d'autres sont schedulées ; permanently_failed veut dire que les 7 tentatives ont été épuisées. |
attempts | object[] | Une entrée par tentative. Chacune contient attempted_at, status_code (ou null si pas de réponse), response_time_ms, et une string error quand la requête n'a même pas pu atteindre le merchant. |
next_attempt_at | ISO 8601 | null | Quand le prochain retry va partir. null une fois que la delivery est delivered ou permanently_failed. |
delivered_at | ISO 8601 | null | Quand un 2xx a été reçu pour la première fois. null jusque-là. |
permanently_failed_at | ISO 8601 | null | Quand on a abandonné la delivery. null si pas épuisée. |
created_at | ISO 8601 | Quand l'event a fired. |
Events disponibles
| Event | Description | Payload |
|---|---|---|
import.completed | Un import bulk a atteint done. Les compteurs et le breakdown du report matchent le GET /imports/{sync_id} final. | Schéma ci-dessous |
import.failed | Un import bulk a atteint failed (échec fatal pendant l'ingestion, pas de la validation par ligne). | Schéma ci-dessous |
import.completed
{
"event_id": "evt_8f3a1c2d4e5b6a7f1e2d3c4b",
"event_type": "import.completed",
"created_at": "2026-04-25T14:41:55Z",
"data": {
"sync_id": "b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b",
"status": "done",
"resource_type": "product",
"total_products": 12000,
"synced_products": 12000,
"report": {
"created": 9876,
"updated": 2100,
"failed": 24
},
"error_logs_count": 24,
"started_at": "2026-04-25T14:31:12Z",
"completed_at": "2026-04-25T14:41:55Z"
}
}Les champs sous data reflètent l'objet Import. error_logs_count est le compteur, pas la liste, fetchez GET /imports/{sync_id} si vous avez besoin des 100 dernières entrées.
import.failed
{
"event_id": "evt_9a4b2c3d5e6f7a8b9c0d1e2f",
"event_type": "import.failed",
"created_at": "2026-04-25T14:33:10Z",
"data": {
"sync_id": "c2e8f4a3-9d1b-4c7e-8a5f-1b3d4e5f6a7c",
"status": "failed",
"resource_type": "product",
"total_products": 12000,
"synced_products": 4321,
"report": {
"created": 3210,
"updated": 1100,
"failed": 11
},
"error_logs_count": 11,
"started_at": "2026-04-25T14:31:12Z",
"completed_at": "2026-04-25T14:33:10Z"
}
}import.failed couvre les échecs fatals pendant l'ingestion (par exemple panne d'infrastructure) : pas les problèmes de validation par ligne, qui sont reportés dans report.failed et error_logs peu importe que l'import dans son ensemble ait atteint done ou failed.
Retry policy
Si votre endpoint renvoie autre chose qu'un 2xx, ou ne répond pas en moins de 10 secondes, Humind retry selon ce schedule exponentiel :
| Tentative | Délai depuis la précédente | Cumulatif |
|---|---|---|
| 1 (initiale) | 0 | 0 |
| 2 | 10 s | 10 s |
| 3 | 1 min | ~1 min |
| 4 | 5 min | ~6 min |
| 5 | 30 min | ~36 min |
| 6 | 2 h | ~2 h 36 min |
| 7 | 12 h | ~14 h 36 min |
| 8 | 24 h | ~38 h 36 min |
Après 7 tentatives en échec (8 tentatives au total : 1 initiale + 7 retries), la delivery est marquée permanently_failed et Humind arrête d'essayer pour cet event spécifique. Les autres events continuent de partir, une mauvaise delivery ne désactive pas l'endpoint à elle seule.
Si votre endpoint accumule 5 deliveries en échec consécutives (5 events d'affilée, peu importe le nombre de tentatives), l'endpoint est auto-disabled : status passe à disabled, disabled_reason devient consecutive_failures, et plus aucune nouvelle delivery n'est mise en queue. Réactivez-le via PATCH /webhooks/endpoints/:id avec { "status": "active" } une fois le problème de fond corrigé.
failure_count reset à 0 à la moindre delivery réussie, donc une seule recovery suffit, vous n'avez pas besoin d'acker un backlog.
4xx déclenche aussi un retry
Humind retry tout non-2xx de la même façon, y compris les 4xx. On n'essaie pas d'être malin avec « ça ressemble à un rejet permanent », un 400 venant d'un handler mal déployé suivi d'un fix doit toujours pouvoir faire passer l'event. La seule façon de dire à Humind d'arrêter, c'est de disable l'endpoint.
Faire tourner le secret
POST /public/v1/webhooks/endpoints/:id/rotate-secret émet un nouveau secret de signature et invalide immédiatement l'ancien.
Scope requis : webhooks:manage
Request
curl -X POST https://api.thehumind.com/public/v1/webhooks/endpoints/whe_5f9e3a2b7c1d4e8a9b3f0c2d/rotate-secret \
-H "Authorization: Bearer hmd_live_..." \
-H "Idempotency-Key: 4d2e1f3a-5b6c-4d7e-8f9a-0b1c2d3e4f5a"Response 200 OK
{
"endpoint": {
"id": "5f9e3a2b7c1d4e8a9b3f0c2d",
"url": "https://api.merchant.example/humind-webhooks",
"events": ["import.completed", "import.failed"],
"prefix": "whsec_2a3b4c5d6e7f8a9b0c1d2e",
"checksum": "70cad189",
"status": "active",
"failure_count": 0,
"created_at": "2026-04-25T14:30:00Z",
"last_delivered_at": null,
"last_failed_at": null,
"disabled_at": null,
"disabled_reason": null
},
"secret": "whsec_2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d_e8f9a0b1",
"rotated_at": "2026-04-25T15:00:00Z"
}Le bloc endpoint reprend la forme de GET /webhooks/endpoints/:id pour que les callers puissent introspecter l'état post-rotation (nouveau prefix, failure_count rafraîchi, etc.) sans appel supplémentaire. Le secret complet est au top level — stockez-le avant de jeter la réponse.
Pas de grace period
Le nouveau secret prend effet immédiatement. Toute delivery en cours (ou schedulée pour retry) commence à être signée avec le nouveau secret dès que la rotation se termine. Mettez à jour le secret de votre handler avant de rotater, une fois le rotate cliqué, l'ancien secret va commencer à faire échouer votre verifier.
La séquence recommandée :
- Ajoutez le nouveau secret à votre secret manager dans une nouvelle version / un slot pending.
- Mettez à jour votre handler pour accepter soit l'ancien soit le nouveau secret pendant une courte fenêtre.
- Appelez
POST /rotate-secret. - Une fois quelques deliveries arrivées avec succès sur le nouveau secret, retirez l'ancien.
Si vous n'avez pas de moyen d'accepter deux secrets à la fois, vous verrez des échecs de vérification pour toute delivery qui arrive entre l'appel de l'API rotate et votre déploiement. La retry policy les rattrapera, mais prévoyez le coup.
Disable vs delete d'un endpoint
Deux façons d'arrêter de recevoir des deliveries sur une URL :
| Action | Endpoint | Effet | Réactivable ? |
|---|---|---|---|
| Disable | PATCH /webhooks/endpoints/:id avec { "status": "disabled" } | Aucune nouvelle delivery mise en queue. Les deliveries existantes sont droppées. L'endpoint reste dans votre liste avec ses events, son secret et son historique. | Oui, PATCH avec { "status": "active" }. |
| Delete | DELETE /webhooks/endpoints/:id | Endpoint supprimé définitivement. Le secret est invalidé. Les deliveries passées restent queryables pour audit mais aucune nouvelle n'est mise en queue. | Non, enregistrez un nouvel endpoint à la place. |
Utilisez disable pour une fenêtre de maintenance planifiée, une intégration en pause, ou pour investiguer un handler qui se comporte mal sans perdre la config. Utilisez delete quand l'endpoint est définitivement parti (service décommissionné, URL migrée).
Les endpoints auto-disabled (après 5 échecs consécutifs) se réactivent de la même façon, PATCH avec { "status": "active" }. Humind n'essaie pas de re-livrer les events qui se sont accumulés pendant que c'était disabled ; vous n'aurez que les nouveaux à partir de ce point.
Lister les deliveries
GET /public/v1/webhooks/deliveries renvoie les deliveries récentes pour votre company, avec des filtres optionnels.
Scope requis : webhooks:manage
Request
curl "https://api.thehumind.com/public/v1/webhooks/deliveries?endpoint_id=whe_5f9e3a2b7c1d4e8a9b3f0c2d&status=permanently_failed&limit=20" \
-H "Authorization: Bearer hmd_live_..."| Param | Type | Description |
|---|---|---|
endpoint_id | string | Filtrer aux deliveries d'un seul endpoint. |
status | 'pending' | 'delivered' | 'failed' | 'permanently_failed' | Filtrer par statut de cycle de vie. |
limit | integer | Combien de deliveries renvoyer. Défaut 20, max 100. |
Response 200 OK
{
"data": [
{
"id": "whd_a1b2c3d4e5f6a7b8c9d0e1f2",
"webhook_endpoint_id": "whe_5f9e3a2b7c1d4e8a9b3f0c2d",
"event_type": "import.completed",
"event_id": "evt_8f3a1c2d4e5b6a7f1e2d3c4b",
"status": "permanently_failed",
"attempts": [
{ "attempted_at": "2026-04-23T10:00:00Z", "status_code": 502, "response_time_ms": 312, "error": null },
{ "attempted_at": "2026-04-23T10:00:10Z", "status_code": 502, "response_time_ms": 280, "error": null }
],
"next_attempt_at": null,
"delivered_at": null,
"permanently_failed_at": "2026-04-25T00:00:10Z",
"created_at": "2026-04-23T10:00:00Z"
}
]
}GET /public/v1/webhooks/deliveries/:id renvoie une delivery seule avec l'historique complet des tentatives et le payload original qui a été signé et envoyé.
Erreurs courantes
L'API Webhooks peut renvoyer n'importe lequel des statuts HTTP standards, mais voici les codes spécifiques à cette ressource :
| Statut | Code | Quand | Fix |
|---|---|---|---|
403 | insufficient_scope | La clé n'a pas webhooks:manage. | Créez une nouvelle clé avec webhooks:manage. |
404 | webhook_endpoint_not_found | Le :id ne correspond pas à un endpoint qui appartient à votre company. | Vérifiez l'id. Les lookups cross-tenant renvoient aussi 404. |
404 | not_found | Un :id de delivery ne correspond à rien chez votre company. | Vérifiez l'id. |
422 | invalid_event_type | Une entrée dans events n'est pas un type connu. | Utilisez une des valeurs listées dans Events disponibles. |
422 | invalid_url | url n'est pas une URL HTTPS valide, ou pointe sur un host privé (localhost, 127.0.0.1, ranges RFC1918) en mode live. | Utilisez une URL https:// publiquement joignable. Pour le développement local, utilisez des clés hmd_test_* avec un tunnel (ngrok, Cloudflare Tunnel). |
400 | validation_failed | Le body a parsé mais ne match pas le schéma (url manquant, events vide, etc.). | Corrigez le champ et renvoyez. |
422 | webhook_disabled | Une tentative d'interaction avec un endpoint disabled qui demande qu'il soit actif. | Réactivez via PATCH /webhooks/endpoints/:id avec { "status": "active" }. |
Bonnes pratiques
- Dédupliquez sur
event_id. Les retries peuvent livrer le même event plus d'une fois. TraitezX-Humind-Event-Idcomme la clé d'idempotency de votre côté : si vous l'avez déjà traité, ackez avec2xxet passez votre chemin. - Répondez 2xx vite, traitez en async. Push le payload sur une queue et ack en moins d'une seconde environ. Un handler de 9 secondes est à une requête lente près d'un timeout, et un timeout veut dire un duplicate.
- Vérifiez la signature avant de parser. Faites le check HMAC sur le raw body avant de
JSON.parse. Une requête qui rate la vérification doit être rejetée avec401ou400et ne pas être passée au reste du code. - Vérifiez la fenêtre de timestamp. Une signature valide sur un vieux payload reste un replay. La fenêtre ±5 minutes dans les snippets ci-dessus est ce qui le bloque.
- Gardez votre healthcheck séparé. Ne mettez pas votre receiver webhook sur la même URL que
/healthou votre homepage, un mauvais réglage d'auth temporaire sur la route parente pourrait silencieusement commencer à droper les events. - Ne loggez jamais le secret. Loggez le
prefix(il est safe by design) pour que vous puissiez confirmer quelle version du secret est en service, mais traitez la stringwhsec_*complète comme votre clé API. - Utilisez un tunnel pour le dev local.
localhostest rejeté en mode live parinvalid_url. Avec une cléhmd_test_*, vous pouvez enregistrerhttps://votre-id.ngrok.appet itérer en local sans exposer de vraie URL.
Pour aller plus loin
- Imports :
import.completedetimport.failedsont les events qui partent aujourd'hui. - Authentication : générer une clé
webhooks:manage. - Errors : référence complète des codes d'erreur.