Webhooks
Soyez notifié quand des événements se produisent dans Crisphive — au lieu de faire du polling, nous envoyons les événements à votre URL via POST.
Comment ça marche
Un membre de l'équipe enregistre votre endpoint HTTPS dans le tableau de bord Crisphive et choisit quels événements recevoir (ou tous). Vous recevez un secret de signature (whsec_…) une seule fois — conservez-le. Vous choisissez aussi ici la durée de vie du secret — 30 jours par défaut, de 1 à 365 (voir Durée de vie & rotation du secret).
L'enregistrement envoie immédiatement un ping de vérification signé. Répondez 2xx et l'endpoint naît active ; sinon il reste pending_verification (ne reçoit rien) jusqu'à ce qu'il réussisse la Vérification depuis le tableau de bord. Changer l'URL nécessite une nouvelle vérification.
Quand un événement se produit, Crisphive envoie un POST à votre URL avec un corps JSON et un en-tête Crisphive-Signature.
Votre endpoint vérifie la signature, renvoie 2xx rapidement, et traite de manière asynchrone.
Réponse non-2xx / timeout ⇒ nous réessayons avec un délai croissant (1m, 5m, 30m, 2h, 6h). Après 5 échecs consécutifs (tout succès réinitialise le compteur), l'endpoint est désactivé automatiquement et les Propriétaires/Administrateurs de l'entreprise sont notifiés par e-mail. Le réactiver nécessite une Vérification réussie, qui réinitialise aussi le compteur.
Chaque livraison est enregistrée — le tableau de bord expose l'historique complet des livraisons (statut, tentatives, dernier code/erreur HTTP) par endpoint.
id de l'événement (upsert, n'ajoutez jamais aveuglément).Durée de vie & rotation du secret
Les secrets de signature de webhook expirent, et leur rotation se fait sur place. Vous choisissez la durée de vie du secret à l'enregistrement de l'endpoint — 30 jours par défaut, de 1 à 365. Contrairement à une clé API, un endpoint webhook est une URL enregistrée unique et ne peut pas être dupliqué côte à côte (enregistrer deux fois la même URL vous livrerait chaque événement deux fois). Au lieu d'un second endpoint, vous effectuez donc une rotation du secret :
Developers → Webhooks → Rotate secret émet un nouveau whsec_ et continue de signer avec le nouveau et l'ancien secret pendant 24 heures. La rotation redémarre aussi la durée de vie — vous en choisissez une nouvelle au moment de la rotation (30 jours par défaut, de 1 à 365) — c'est donc également la voie de renouvellement.
Les Propriétaires et Administrateurs de l'entreprise reçoivent un e-mail 7 jours avant l'expiration d'un secret de signature, puis de nouveau une fois celui-ci expiré. Les endpoints enregistrés avant l'existence des durées de vie de secret ne portent pas de secret_expires_at et continuent de signer comme avant — leur première rotation les fait passer sur la durée de vie standard.
409 WEBHOOK_SECRET_EXPIRED au lieu de réarmer l'endpoint.Catalogue des événements
| Type d'événement | Quand il se déclenche |
|---|---|
job_request.created | Une demande d'intervention (réservation) a été créée — via l'API, la page de réservation publique, ou le tableau de bord. |
job_request.confirmed | Le client a confirmé un créneau horaire ; l'intervention est planifiée (et un technicien/une équipe est assigné). |
job_request.assigned | Un technicien/une équipe a été assigné ou réassigné à l'intervention. |
job_request.completed | L'intervention a atteint son statut terminal terminé. |
job_request.archived | L'intervention a été archivée (annulée / clôturée). |
job_request.status_changed | Toute autre transition de statut du flux (par ex. on_the_way, arrived, personnalisé). Contient previous_status. |
job_request.priority_changed | La priorité de l'intervention a été modifiée. |
job_request.rescheduled | L'intervention a été déplacée vers un autre horaire ou un autre technicien/une autre équipe. |
customer.created | Une fiche client a été créée. |
customer.updated | Le profil, le contact, le niveau ou le statut d'un client a changé. |
customer.deleted | Une fiche client a été supprimée. |
technician.created | Un technicien a rejoint l'effectif (y compris la réintégration d'un membre précédemment retiré). |
technician.updated | Le profil, le groupe de rôles, le niveau ou le statut d'un technicien a changé. Les écritures portant uniquement sur des relations (binômes/véhicules/compétences/zones de service) ne déclenchent pas d'événements. |
technician.deleted | Un technicien a été retiré de l'effectif. |
Un changement d'état déclenche exactement un événement (son jalon spécifique s'il en a un, sinon status_changed). Le catalogue est en extension uniquement — de nouveaux types d'événements peuvent être ajoutés ; les existants ne changent jamais de signification.
Charge utile
Chaque corps de livraison est cette enveloppe. data.object est la ressource dans la même forme que le GET /v1 correspondant (interrogez /v1 pour les détails complets/à jour si nécessaire).
{
"id": "evt_2b9c…", // unique event id — dedupe on this
"type": "job_request.completed",
"created_at": "2026-06-30T10:00:00Z",
"business_id": "b1f0…",
"environment": "live", // or "sandbox"
"data": { "object": { "id": "job_…", "short_code": "REQ-…", "status": "completed" } },
"previous_status": "arrived" // only on job_request.status_changed
}Vérifier la signature
Format de l'en-tête : Crisphive-Signature: t=<unix>,v1=<hex> où v1 = HMAC_SHA256(secret, t + "." + rawBody). Calculez-la sur le corps brut de la requête, comparez en temps constant, et rejetez si t est trop ancien (par ex. > 5 min) pour empêcher les rejeux.
Vérifiez contre une liste de signatures, pas une valeur unique. Pendant la fenêtre de grâce de 24 heures d'une rotation, l'en-tête porte deux entrées v1 :
Crisphive-Signature: t=1753857711,v1=<signature-new>,v1=<signature-old>
v1 est la version de l'algorithme de signature, pas un identifiant de secret. Calculez le HMAC avec votre secret stocké et acceptez la livraison si n'importe quelle entrée v1 correspond. Un récepteur codé en dur pour ne lire que le premier v1 commencera à rejeter des événements au milieu d'une rotation.
Node.js
const crypto = require('crypto'); function verify(rawBody, header, secret) { const parts = header.split(',').map(kv => kv.split('=')); const t = parts.find(([k]) => k === 't')[1]; // two v1 entries during the 24h rotation window — accept any match const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v); const expected = crypto.createHmac('sha256', secret) .update(t + '.' + rawBody).digest('hex'); return sigs.some(v1 => v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))); }
Python
import hmac, hashlib def verify(raw_body: bytes, header: str, secret: str) -> bool: pairs = [kv.split('=', 1) for kv in header.split(',')] t = next(v for k, v in pairs if k == 't') # two v1 entries during the 24h rotation window — accept any match sigs = [v for k, v in pairs if k == 'v1'] expected = hmac.new(secret.encode(), t.encode() + b'.' + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, v1) for v1 in sigs)
Go
func Verify(rawBody []byte, header, secret string) bool { var t string var sigs []string // two v1 entries during the 24h rotation window for _, kv := range strings.Split(header, ",") { kvp := strings.SplitN(kv, "=", 2) switch kvp[0] { case "t": t = kvp[1] case "v1": sigs = append(sigs, kvp[1]) } } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) for _, v1 := range sigs { if hmac.Equal([]byte(expected), []byte(v1)) { return true } } return false }
Java
import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.security.MessageDigest; import java.util.*; public static boolean verify(byte[] rawBody, String header, String secret) throws Exception { String t = null; List<String> sigs = new ArrayList<>(); // two v1 entries during the 24h rotation window for (String kv : header.split(",")) { String[] x = kv.split("=", 2); if (x[0].equals("t")) t = x[1]; if (x[0].equals("v1")) sigs.add(x[1]); } Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); mac.update((t + ".").getBytes()); byte[] sig = mac.doFinal(rawBody); StringBuilder hex = new StringBuilder(); for (byte b : sig) hex.append(String.format("%02x", b)); byte[] expected = hex.toString().getBytes(); for (String v1 : sigs) { if (MessageDigest.isEqual(expected, v1.getBytes())) return true; } return false; }
C# / .NET
using System;
using System.Security.Cryptography;
using System.Collections.Generic;
using System.Text;
public static bool Verify(byte[] rawBody, string header, string secret) {
string t = null;
var sigs = new List<string>(); // two v1 entries during the 24h rotation window
foreach (var kv in header.Split(',')) {
var x = kv.Split('=', 2);
if (x[0] == "t") t = x[1];
if (x[0] == "v1") sigs.Add(x[1]);
}
var prefix = Encoding.UTF8.GetBytes(t + ".");
var input = new byte[prefix.Length + rawBody.Length];
Buffer.BlockCopy(prefix, 0, input, 0, prefix.Length);
Buffer.BlockCopy(rawBody, 0, input, prefix.Length, rawBody.Length);
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Encoding.UTF8.GetBytes(
Convert.ToHexString(mac.ComputeHash(input)).ToLowerInvariant());
foreach (var v1 in sigs) {
if (CryptographicOperations.FixedTimeEquals(
expected, Encoding.UTF8.GetBytes(v1))) return true;
}
return false;
}PHP
<?php function verify(string $rawBody, string $header, string $secret): bool { $t = null; $sigs = []; // two v1 entries during the 24h rotation window foreach (explode(',', $header) as $kv) { [$k, $v] = explode('=', $kv, 2); if ($k === 't') { $t = $v; } if ($k === 'v1') { $sigs[] = $v; } } $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($sigs as $v1) { if (hash_equals($expected, $v1)) { return true; } } return false; }
Ruby
require 'openssl' def verify(raw_body, header, secret) pairs = header.split(',').map { |kv| kv.split('=', 2) } t = pairs.find { |k, _| k == 't' }[1] # two v1 entries during the 24h rotation window — accept any match sigs = pairs.select { |k, _| k == 'v1' }.map { |_, v| v } expected = OpenSSL::HMAC.hexdigest('SHA256', secret, t + '.' + raw_body) # constant-time compare (Rack::Utils.secure_compare / ActiveSupport::SecurityUtils.secure_compare) sigs.any? { |v1| Rack::Utils.secure_compare(expected, v1) } end
Test
Utilisez Envoyer un test sur votre endpoint dans le tableau de bord pour recevoir un événement ping signé et confirmer que votre récepteur + la gestion de la signature fonctionnent de bout en bout (diagnostic uniquement — l'état ne change jamais). Utilisez Vérifier pour activer un endpoint pending_verification ou désactivé : le même ping signé, mais un 2xx le fait passer en active et réinitialise le compteur d'échecs. Si le secret de signature est arrivé à expiration, Vérifier refuse avec WEBHOOK_SECRET_EXPIRED — effectuez d'abord une rotation du secret.