Webhooks
Lassen Sie sich benachrichtigen, wenn in Crisphive etwas passiert — statt zu pollen, senden wir Ereignisse per POST an Ihre URL.
So funktioniert es
Ein Teammitglied registriert Ihren HTTPS-Endpunkt im Crisphive-Dashboard und wählt aus, welche Ereignisse empfangen werden sollen (oder alle). Sie erhalten einmalig ein Signatur-Secret (whsec_…) — bewahren Sie es auf. Sie wählen hier auch die Laufzeit des Secrets — standardmäßig 30 Tage, von 1 bis 365 (siehe Secret-Laufzeit & Rotation).
Die Registrierung sendet sofort einen signierten Verifizierungs-Ping. Antworten Sie mit 2xx, und der Endpunkt wird direkt active; andernfalls bleibt er pending_verification (empfängt nichts), bis er die Verify-Prüfung aus dem Dashboard besteht. Ein Ändern der URL erfordert eine erneute Verifizierung.
Wenn ein Ereignis eintritt, sendet Crisphive einen POST an Ihre URL mit einem JSON-Body und einem Crisphive-Signature-Header.
Ihr Endpunkt verifiziert die Signatur, antwortet schnell mit 2xx und verarbeitet asynchron.
Non-2xx / Timeout ⇒ wir wiederholen mit Backoff (1m, 5m, 30m, 2h, 6h). Nach 5 aufeinanderfolgenden Fehlern (jeder Erfolg setzt den Zähler zurück) wird der Endpunkt automatisch deaktiviert und die Inhaber/Administratoren des Unternehmens werden per E-Mail benachrichtigt. Die Reaktivierung erfordert eine erfolgreiche Verify-Prüfung, die auch den Zähler zurücksetzt.
Jede Zustellung wird protokolliert — das Dashboard zeigt pro Endpunkt den vollständigen Zustellverlauf (Status, Versuche, letzter HTTP-Code/Fehler).
id (Upsert, niemals blind anhängen).Secret-Laufzeit & Rotation
Webhook-Signatur-Secrets laufen ab und werden an Ort und Stelle rotiert. Sie wählen die Laufzeit des Secrets, wenn Sie den Endpunkt registrieren — standardmäßig 30 Tage, von 1 bis 365. Anders als bei einem API-Schlüssel ist ein Webhook-Endpunkt eine einzelne registrierte URL und lässt sich nicht parallel duplizieren (dieselbe URL zweimal zu registrieren würde jedes Ereignis doppelt an Sie zustellen). Statt eines zweiten Endpunkts rotieren Sie daher das Secret:
Developers → Webhooks → Rotate secret stellt ein neues whsec_ aus und signiert 24 Stunden lang sowohl mit dem neuen als auch mit dem vorherigen Secret. Die Rotation startet außerdem die Laufzeit neu — Sie wählen beim Rotieren eine frische (standardmäßig 30 Tage, von 1 bis 365) — und ist damit zugleich der Erneuerungsweg.
Die Inhaber und Administratoren des Unternehmens werden 7 Tage bevor ein Signatur-Secret abläuft per E-Mail benachrichtigt, und erneut, sobald es abgelaufen ist. Endpunkte, die registriert wurden, bevor es Secret-Laufzeiten gab, tragen kein secret_expires_at und signieren weiter wie bisher — ihre erste Rotation bringt sie auf die Standard-Laufzeit.
409 WEBHOOK_SECRET_EXPIRED, statt den Endpunkt wieder scharfzuschalten.Ereignis-Katalog
| Ereignistyp | Wann es ausgelöst wird |
|---|---|
job_request.created | Eine Auftragsanfrage (Buchung) wurde erstellt — über die API, die öffentliche Buchungsseite oder das Dashboard. |
job_request.confirmed | Der Kunde hat ein Zeitfenster bestätigt; der Auftrag ist eingeplant (und ein Techniker/Team wurde zugewiesen). |
job_request.assigned | Ein Techniker/Team wurde dem Auftrag zugewiesen oder neu zugewiesen. |
job_request.completed | Der Auftrag hat seinen endgültigen abgeschlossenen Status erreicht. |
job_request.archived | Der Auftrag wurde archiviert (storniert / geschlossen). |
job_request.status_changed | Jeder andere Workflow-Statusübergang (z. B. on_the_way, arrived, benutzerdefiniert). Enthält previous_status. |
job_request.priority_changed | Die Priorität des Auftrags wurde geändert. |
job_request.rescheduled | Der Auftrag wurde auf eine andere Zeit oder einen anderen Techniker/ein anderes Team verschoben. |
customer.created | Ein Kundendatensatz wurde erstellt. |
customer.updated | Das Profil, der Kontakt, die Stufe oder der Status eines Kunden hat sich geändert. |
customer.deleted | Ein Kundendatensatz wurde gelöscht. |
technician.created | Ein Techniker wurde in den Personalbestand aufgenommen (einschließlich des erneuten Hinzufügens eines zuvor entfernten Mitglieds). |
technician.updated | Das Profil, die Rollengruppe, die Stufe oder der Status eines Technikers hat sich geändert. Reine Beziehungs-Schreibvorgänge (Buddies/Fahrzeuge/Skills/Servicegebiete) lösen keine Ereignisse aus. |
technician.deleted | Ein Techniker wurde aus dem Personalbestand entfernt. |
Eine Zustandsänderung löst genau ein Ereignis aus (den spezifischen Meilenstein, falls vorhanden, sonst status_changed). Der Katalog ist nur erweiterbar — neue Ereignistypen können hinzukommen; bestehende ändern niemals ihre Bedeutung.
Payload
Jeder Zustell-Body ist dieses Envelope. data.object ist die Ressource in derselben Form wie das passende /v1-GET (rufen Sie bei Bedarf /v1 für vollständige/aktuelle Details ab).
{
"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
}Signatur verifizieren
Header-Format: Crisphive-Signature: t=<unix>,v1=<hex> wobei v1 = HMAC_SHA256(secret, t + "." + rawBody). Berechnen Sie ihn über den rohen Request-Body, vergleichen Sie in konstanter Zeit und lehnen Sie ab, wenn t zu alt ist (z. B. > 5 Min.), um Replays zu verhindern.
Verifizieren Sie gegen eine Liste von Signaturen, nicht gegen einen einzelnen Wert. Während des 24-stündigen Rotations-Übergangsfensters enthält der Header zwei v1-Einträge:
Crisphive-Signature: t=1753857711,v1=<signature-new>,v1=<signature-old>
v1 ist die Version des Signaturalgorithmus, kein Secret-Identifikator. Berechnen Sie den HMAC mit Ihrem gespeicherten Secret und akzeptieren Sie die Zustellung, wenn irgendein v1-Eintrag übereinstimmt. Ein Empfänger, der fest verdrahtet nur den ersten v1-Eintrag liest, beginnt mitten in einer Rotation, Ereignisse abzulehnen.
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
Testen
Verwenden Sie Send test an Ihrem Endpunkt im Dashboard, um ein signiertes ping-Ereignis zu empfangen und zu bestätigen, dass Ihr Empfänger + die Signaturprüfung durchgängig funktionieren (nur diagnostisch — der Status ändert sich nie). Verwenden Sie Verify, um einen pending_verification- oder deaktivierten Endpunkt zu aktivieren: derselbe signierte Ping, aber ein 2xx schaltet ihn auf active und setzt den Fehlerzähler zurück. Ist das Signatur-Secret abgelaufen, verweigert Verify mit WEBHOOK_SECRET_EXPIRED — rotieren Sie zuerst das Secret.