Webhooks
Get notified when things happen in Crisphive — instead of polling, we POST events to your URL.
How it works
A team member registers your HTTPS endpoint in the Crisphive dashboard and picks which events to receive (or all). You get a signing secret (whsec_…) once — store it. You also choose the secret’s lifetime here — 30 days by default, from 1 up to 365 (see Secret lifetime & rotation).
Registration immediately sends a signed verification ping. Answer 2xx and the endpoint is born active; otherwise it stays pending_verification (receives nothing) until it passes Verify from the dashboard. Changing the URL requires re-verification.
When an event happens, Crisphive sends a POST to your URL with a JSON body and a Crisphive-Signature header.
Your endpoint verifies the signature, returns 2xx quickly, and processes asynchronously.
Non-2xx / timeout ⇒ we retry with backoff (1m, 5m, 30m, 2h, 6h). After 5 consecutive failures (any success resets the count) the endpoint is auto-disabled and the business Owners/Administrators are emailed. Re-enabling requires a successful Verify, which also resets the counter.
Every delivery is recorded — the dashboard exposes the full delivery history (status, attempts, last HTTP code/error) per endpoint.
id (upsert, never blindly append).Secret lifetime & rotation
Webhook signing secrets expire, and they rotate in place. You choose the secret’s lifetime when you register the endpoint — 30 days by default, from 1 up to 365. Unlike an API key, a webhook endpoint is a single registered URL and cannot be duplicated side by side (registering the same URL twice would deliver every event to you twice). So instead of a second endpoint, you rotate the secret:
Developers → Webhooks → Rotate secret issues a new whsec_ and keeps signing with both the new and the previous secret for 24 hours. Rotating also restarts the lifetime — you pick a fresh one as you rotate (30 days by default, from 1 up to 365) — so it is the renewal path as well.
The business’s Owners and Administrators are emailed 7 days before a signing secret expires, and again once it has. Endpoints registered before secret lifetimes existed carry no secret_expires_at and keep signing as before — their first rotation moves them onto the standard lifetime.
409 WEBHOOK_SECRET_EXPIRED instead of re-arming the endpoint.Event catalog
| Event type | When it fires |
|---|---|
job_request.created | A job request (booking) was created — via the API, the public booking page, or the dashboard. |
job_request.confirmed | The customer confirmed a time slot; the job is scheduled (and a technician/crew assigned). |
job_request.assigned | A technician/crew was assigned or re-assigned to the job. |
job_request.completed | The job reached its terminal completed status. |
job_request.archived | The job was archived (cancelled / closed). |
job_request.status_changed | Any other workflow status transition (e.g. on_the_way, arrived, custom). Carries previous_status. |
job_request.priority_changed | The job’s priority was changed. |
job_request.rescheduled | The job was moved to a different time or technician/crew. |
customer.created | A customer record was created. |
customer.updated | A customer's profile, contact, tier or status changed. |
customer.deleted | A customer record was deleted. |
technician.created | A technician joined the roster (including re-adding a previously removed member). |
technician.updated | A technician's profile, role group, tier or status changed. Relation-only writes (buddies/vehicles/skills/service-areas) do not fire events. |
technician.deleted | A technician was removed from the roster. |
A state change fires exactly one event (its specific milestone if it has one, else status_changed). The catalog is extend-only — new event types may be added; existing ones never change meaning.
Payload
Every delivery body is this envelope. data.object is the resource in the same shape as the matching /v1 GET (fetch /v1 for full/fresh detail if needed).
{
"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
}Verifying the signature
Header format: Crisphive-Signature: t=<unix>,v1=<hex> where v1 = HMAC_SHA256(secret, t + "." + rawBody). Compute it over the raw request body, constant-time compare, and reject if t is too old (e.g. > 5 min) to stop replays.
Verify against a list of signatures, not a single value. During the 24-hour rotation grace window the header carries two v1 entries:
Crisphive-Signature: t=1753857711,v1=<signature-new>,v1=<signature-old>
v1 is the signature algorithm version, not a secret identifier. Compute the HMAC with your stored secret and accept the delivery if any v1 entry matches. A receiver hardcoded to read only the first v1 will start rejecting events halfway through a 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
Testing
Use Send test on your endpoint in the dashboard to receive a signed ping event and confirm your receiver + signature handling work end to end (diagnostic only — state never changes). Use Verify to activate a pending_verification or disabled endpoint: same signed ping, but a 2xx flips it to active and resets the failure counter. If the signing secret has lapsed, Verify refuses with WEBHOOK_SECRET_EXPIRED — rotate the secret first.