fr

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

1
Enregistrez votre endpoint

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

2
Ping de vérification

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.

3
Nous envoyons les événements en POST

Quand un événement se produit, Crisphive envoie un POST à votre URL avec un corps JSON et un en-tête Crisphive-Signature.

4
Vérifiez et accusez réception

Votre endpoint vérifie la signature, renvoie 2xx rapidement, et traite de manière asynchrone.

5
Tentatives et désactivation automatique

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.

6
Historique des livraisons

Chaque livraison est enregistrée — le tableau de bord expose l'historique complet des livraisons (statut, tentatives, dernier code/erreur HTTP) par endpoint.

Livraison au moins une fois. Une livraison peut se répéter — dédupliquez par l'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.

Si un secret arrive à expiration sans rotation, l'endpoint est désactivé et cesse de recevoir des livraisons. Effectuez d'abord une rotation, puis lancez Vérifier pour reprendre les livraisons — Vérifier sur un secret expiré renvoie 409 WEBHOOK_SECRET_EXPIRED au lieu de réarmer l'endpoint.

Catalogue des événements

Type d'événementQuand il se déclenche
job_request.createdUne 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.confirmedLe client a confirmé un créneau horaire ; l'intervention est planifiée (et un technicien/une équipe est assigné).
job_request.assignedUn technicien/une équipe a été assigné ou réassigné à l'intervention.
job_request.completedL'intervention a atteint son statut terminal terminé.
job_request.archivedL'intervention a été archivée (annulée / clôturée).
job_request.status_changedToute autre transition de statut du flux (par ex. on_the_way, arrived, personnalisé). Contient previous_status.
job_request.priority_changedLa priorité de l'intervention a été modifiée.
job_request.rescheduledL'intervention a été déplacée vers un autre horaire ou un autre technicien/une autre équipe.
customer.createdUne fiche client a été créée.
customer.updatedLe profil, le contact, le niveau ou le statut d'un client a changé.
customer.deletedUne fiche client a été supprimée.
technician.createdUn technicien a rejoint l'effectif (y compris la réintégration d'un membre précédemment retiré).
technician.updatedLe 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.deletedUn 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>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.