en

Webhooks

Get notified when things happen in Crisphive — instead of polling, we POST events to your URL.

How it works

1
Register your endpoint

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

2
Verification ping

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.

3
We POST events

When an event happens, Crisphive sends a POST to your URL with a JSON body and a Crisphive-Signature header.

4
Verify & acknowledge

Your endpoint verifies the signature, returns 2xx quickly, and processes asynchronously.

5
Retries & auto-disable

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.

6
Delivery history

Every delivery is recorded — the dashboard exposes the full delivery history (status, attempts, last HTTP code/error) per endpoint.

At-least-once delivery. A delivery can repeat — deduplicate by the event 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.

If a secret is allowed to lapse, the endpoint is disabled and stops receiving deliveries. Rotate first, then run Verify to resume deliveries — Verify on a lapsed secret returns 409 WEBHOOK_SECRET_EXPIRED instead of re-arming the endpoint.

Event catalog

Event typeWhen it fires
job_request.createdA job request (booking) was created — via the API, the public booking page, or the dashboard.
job_request.confirmedThe customer confirmed a time slot; the job is scheduled (and a technician/crew assigned).
job_request.assignedA technician/crew was assigned or re-assigned to the job.
job_request.completedThe job reached its terminal completed status.
job_request.archivedThe job was archived (cancelled / closed).
job_request.status_changedAny other workflow status transition (e.g. on_the_way, arrived, custom). Carries previous_status.
job_request.priority_changedThe job’s priority was changed.
job_request.rescheduledThe job was moved to a different time or technician/crew.
customer.createdA customer record was created.
customer.updatedA customer's profile, contact, tier or status changed.
customer.deletedA customer record was deleted.
technician.createdA technician joined the roster (including re-adding a previously removed member).
technician.updatedA technician's profile, role group, tier or status changed. Relation-only writes (buddies/vehicles/skills/service-areas) do not fire events.
technician.deletedA 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.