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

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

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.* needs customers_view, technician.* needs team_view, job_request.* needs job_view. Otherwise the call answers 403 WEBHOOK_EVENT_NOT_PERMITTED with the refused events and the missing permissions. Leaving event_types empty 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_reason says 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.

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.