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 — or your integration calls POST /v1/webhooks (see Subscribing through the API) — 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).Subscribing through the API
Automation platforms and other integrations can manage their own subscriptions with three calls: GET /v1/webhooks/event-types lists the events, POST /v1/webhooks subscribes a URL (the signing secret is in the response, once), and DELETE /v1/webhooks/{id} unsubscribes. Listing, editing, verifying, rotating and delivery history stay in the dashboard — an endpoint that fails its first ping is deleted and created again.
- Creating a subscription needs Developer access (
developer_manage_api_keys) — by default the business Owner. - You can only subscribe to events the credential can read:
customer.*needscustomers_view,technician.*needsteam_view,job_request.*needsjob_view. Otherwise the call answers403 WEBHOOK_EVENT_NOT_PERMITTEDwith the refused events and the missing permissions. Leavingevent_typesempty subscribes to every event the credential can read. - The Owner and Administrators are emailed the first time an app or API key subscribes to the business’s events.
- An endpoint created through the API is disabled automatically when the API key that created it is revoked, when the app is disconnected, or when the member who connected the app leaves or is suspended.
disabled_reasonsays which; Verify in the dashboard turns it back on.
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.