Skip to content

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 retry

Trois étapes pour câbler tout ça :

  1. Créez un endpoint avec POST /public/v1/webhooks/endpoints. La réponse contient un secret one-time, stockez-le dans votre secret manager immédiatement, vous ne le reverrez plus.
  2. Recevez les deliveries sur l'URL que vous avez enregistrée. Humind envoie un POST JSON avec le payload spécifique à l'event, plus les headers de signature.
  3. 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

bash
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"]
  }'
ChampTypeRequisDescription
urlstringOuiURL 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.
eventsstring[]OuiListe des types d'event à abonner. Voir Events disponibles. Au moins un. Un type d'event inconnu renvoie invalid_event_type.

Response 201 Created

json
{
  "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 :

HeaderExempleRôle
Content-Typeapplication/jsonLe body est toujours du JSON.
X-Humind-Event-Typeimport.completedQuel event a fired. Même valeur que event_type dans le body.
X-Humind-Event-Idevt_8f3a1c2d4e5b6a7f1e2d3c4bUnique par event. Utilisez-le pour dédupliquer, voir Idempotency.
X-Humind-Timestamp1745595600Unix seconds au moment de la génération de la delivery. Sert à vérifier la signature et à rejeter les replays.
X-Humind-Signaturet=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.

json
{
  "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 :

  1. Lire X-Humind-Timestamp et rejeter s'il est en dehors d'une fenêtre ±5 minutes par rapport à votre horloge serveur (protection replay).
  2. Calculer HMAC_SHA256(secret, "<timestamp>.<raw_body>") en utilisant le raw body non parsé.
  3. Comparer à la valeur v1= dans X-Humind-Signature avec 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

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

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

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)
end

L'objet Webhook Endpoint

ChampTypeDescription
idstringIdentifiant stable, préfixé whe_. À utiliser dans le path de tous les appels suivants.
urlstringURL HTTPS sur laquelle Humind livre.
eventsstring[]Types d'event auxquels cet endpoint est abonné.
status'active' | 'disabled'Si les deliveries sont actuellement tentées. Voir Disable vs delete.
prefixstring22 premiers caractères du secret de signature. Safe à logger ; utile pour confirmer quel secret est en service après une rotation.
failure_countintegerNombre de deliveries en échec consécutives depuis la dernière réussie. Reset à 0 à la moindre delivery réussie.
last_delivered_atISO 8601 | nullQuand la dernière delivery réussie s'est terminée. null si aucune delivery n'a encore réussi.
last_failed_atISO 8601 | nullQuand la delivery en échec la plus récente s'est produite. null si aucune n'a échoué.
disabled_reasonstring | nullSet quand l'endpoint a été auto-disabled. consecutive_failures.
created_atISO 8601Quand 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.

ChampTypeDescription
idstringIdentifiant stable, préfixé whd_.
webhook_endpoint_idstringL'endpoint que cette delivery ciblait.
event_typestringPar exemple import.completed.
event_idstringUnique 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.
attemptsobject[]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_atISO 8601 | nullQuand le prochain retry va partir. null une fois que la delivery est delivered ou permanently_failed.
delivered_atISO 8601 | nullQuand un 2xx a été reçu pour la première fois. null jusque-là.
permanently_failed_atISO 8601 | nullQuand on a abandonné la delivery. null si pas épuisée.
created_atISO 8601Quand l'event a fired.

Events disponibles

EventDescriptionPayload
import.completedUn import bulk a atteint done. Les compteurs et le breakdown du report matchent le GET /imports/{sync_id} final.Schéma ci-dessous
import.failedUn import bulk a atteint failed (échec fatal pendant l'ingestion, pas de la validation par ligne).Schéma ci-dessous

import.completed

json
{
  "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

json
{
  "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 :

TentativeDélai depuis la précédenteCumulatif
1 (initiale)00
210 s10 s
31 min~1 min
45 min~6 min
530 min~36 min
62 h~2 h 36 min
712 h~14 h 36 min
824 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

bash
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

json
{
  "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 :

  1. Ajoutez le nouveau secret à votre secret manager dans une nouvelle version / un slot pending.
  2. Mettez à jour votre handler pour accepter soit l'ancien soit le nouveau secret pendant une courte fenêtre.
  3. Appelez POST /rotate-secret.
  4. 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 :

ActionEndpointEffetRéactivable ?
DisablePATCH /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" }.
DeleteDELETE /webhooks/endpoints/:idEndpoint 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

bash
curl "https://api.thehumind.com/public/v1/webhooks/deliveries?endpoint_id=whe_5f9e3a2b7c1d4e8a9b3f0c2d&status=permanently_failed&limit=20" \
  -H "Authorization: Bearer hmd_live_..."
ParamTypeDescription
endpoint_idstringFiltrer aux deliveries d'un seul endpoint.
status'pending' | 'delivered' | 'failed' | 'permanently_failed'Filtrer par statut de cycle de vie.
limitintegerCombien de deliveries renvoyer. Défaut 20, max 100.

Response 200 OK

json
{
  "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 :

StatutCodeQuandFix
403insufficient_scopeLa clé n'a pas webhooks:manage.Créez une nouvelle clé avec webhooks:manage.
404webhook_endpoint_not_foundLe :id ne correspond pas à un endpoint qui appartient à votre company.Vérifiez l'id. Les lookups cross-tenant renvoient aussi 404.
404not_foundUn :id de delivery ne correspond à rien chez votre company.Vérifiez l'id.
422invalid_event_typeUne entrée dans events n'est pas un type connu.Utilisez une des valeurs listées dans Events disponibles.
422invalid_urlurl 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).
400validation_failedLe body a parsé mais ne match pas le schéma (url manquant, events vide, etc.).Corrigez le champ et renvoyez.
422webhook_disabledUne 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. Traitez X-Humind-Event-Id comme la clé d'idempotency de votre côté : si vous l'avez déjà traité, ackez avec 2xx et 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 avec 401 ou 400 et 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 /health ou 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 string whsec_* complète comme votre clé API.
  • Utilisez un tunnel pour le dev local. localhost est rejeté en mode live par invalid_url. Avec une clé hmd_test_*, vous pouvez enregistrer https://votre-id.ngrok.app et itérer en local sans exposer de vraie URL.

Pour aller plus loin

  • Imports : import.completed et import.failed sont les events qui partent aujourd'hui.
  • Authentication : générer une clé webhooks:manage.
  • Errors : référence complète des codes d'erreur.

Released under the proprietary Humind license.