Webhook
Crisphive で何かが起きたときに通知を受け取れます — ポーリングの代わりに、私たちがイベントをあなたの URL に POST します。
仕組み
チームメンバーが Crisphive ダッシュボードであなたの HTTPS エンドポイントを登録し、受け取るイベント(またはすべて)を選択します。署名シークレット(whsec_…)は一度だけ表示されます — 保管してください。シークレットの有効期間もここで選択します — デフォルトは 30 日、1 日から 365 日まで(シークレットの有効期間とローテーションを参照)。
登録すると直ちに署名付きの検証 pingが送信されます。2xx を返すとエンドポイントは active として作成されます。そうでない場合は pending_verification のまま(何も受信しません)となり、ダッシュボードから検証に合格するまで続きます。URL を変更すると再検証が必要です。
イベントが発生すると、Crisphive は JSON ボディと Crisphive-Signature ヘッダーを付けて、あなたの URL に POST を送信します。
あなたのエンドポイントは署名を検証し、速やかに 2xx を返して、非同期で処理します。
非 2xx/タイムアウトの場合 ⇒ バックオフ(1m, 5m, 30m, 2h, 6h)でリトライします。5 回連続で失敗すると(成功すればカウントはリセットされます)エンドポイントは自動的に無効化され、ビジネスのオーナー/管理者にメールが送信されます。再有効化には検証の成功が必要で、これによりカウンターもリセットされます。
すべての配信が記録されます — ダッシュボードでは、エンドポイントごとに完全な配信履歴(ステータス、試行回数、直近の HTTP コード/エラー)を確認できます。
id で重複排除してください(アップサートし、単純に追加しないこと)。シークレットの有効期間とローテーション
Webhook 署名シークレットには有効期限があり、その場でローテーションされます。シークレットの有効期間はエンドポイント登録時に選択します — デフォルトは 30 日、1 日から 365 日まで。API キーとは異なり、Webhook エンドポイントは登録された単一の URL であり、並べて複製することはできません(同じ URL を 2 回登録すると、すべてのイベントが 2 回配信されてしまいます)。そのため、2 つ目のエンドポイントを作る代わりに、シークレットをローテーションします:
Developers → Webhooks → Rotate secret は新しい whsec_ を発行し、新旧両方のシークレットで 24 時間署名を続けます。ローテーションすると有効期間もリセットされ、ローテーション時に新しい有効期間を選択します(デフォルトは 30 日、1 日から 365 日まで) — そのため、これが更新の手段でもあります。
署名シークレットの有効期限の 7 日前と、期限切れになった時点で、ビジネスのオーナーと管理者にメールが送信されます。シークレットの有効期間が導入される前に登録されたエンドポイントには secret_expires_at がなく、これまでどおり署名を続けます — 最初のローテーションで標準の有効期間に移行します。
409 WEBHOOK_SECRET_EXPIRED を返します。イベントカタログ
| イベントタイプ | 発生タイミング |
|---|---|
job_request.created | ジョブリクエスト(予約)が作成されました — API、公開予約ページ、またはダッシュボード経由。 |
job_request.confirmed | 顧客が時間帯を確定し、ジョブがスケジュールされました(技術者/クルーも割り当て済み)。 |
job_request.assigned | 技術者/クルーがジョブに割り当て、または再割り当てされました。 |
job_request.completed | ジョブが最終的な完了ステータスに到達しました。 |
job_request.archived | ジョブがアーカイブされました(キャンセル/クローズ)。 |
job_request.status_changed | その他のワークフローステータス遷移(例: on_the_way、arrived、カスタム)。previous_status を含みます。 |
job_request.priority_changed | ジョブの優先度が変更されました。 |
job_request.rescheduled | ジョブが別の時間、または別のテクニシャン/クルーに移動されました。 |
customer.created | 顧客レコードが作成されました。 |
customer.updated | 顧客のプロフィール、連絡先、ティア、またはステータスが変更されました。 |
customer.deleted | 顧客レコードが削除されました。 |
technician.created | テクニシャンがロースターに追加されました(以前に削除されたメンバーの再追加を含みます)。 |
technician.updated | テクニシャンのプロフィール、ロールグループ、ティア、またはステータスが変更されました。関係のみの更新(バディ/車両/スキル/サービスエリア)ではイベントは発火しません。 |
technician.deleted | テクニシャンがロースターから削除されました。 |
状態変化はちょうど 1 つのイベントを発火します(該当する固有のマイルストーンがあればそれを、なければ status_changed を)。カタログは追加専用です — 新しいイベントタイプは追加される場合がありますが、既存のものの意味が変わることはありません。
ペイロード
すべての配信ボディはこのエンベロープです。data.object は、対応する /v1 GET と同じ形式のリソースです(必要に応じて、完全/最新の詳細は /v1 を取得してください)。
{
"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
}署名を検証する
ヘッダー形式: Crisphive-Signature: t=<unix>,v1=<hex>。ここで v1 = HMAC_SHA256(secret, t + "." + rawBody) です。生のリクエストボディに対して計算し、定数時間比較を行い、リプレイを防ぐために t が古すぎる場合(例: 5 分超)は拒否してください。
単一の値ではなく、署名のリストに対して検証してください。24 時間のローテーション猶予期間中、ヘッダーには 2 つの v1 エントリが含まれます:
Crisphive-Signature: t=1753857711,v1=<signature-new>,v1=<signature-old>
v1 は署名アルゴリズムのバージョンであり、シークレットの識別子ではありません。保管しているシークレットで HMAC を計算し、いずれかの v1 エントリが一致すれば配信を受け入れてください。最初の v1 だけを読むようにハードコードされたレシーバーは、ローテーションの途中でイベントを拒否し始めます。
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
テスト
ダッシュボードでエンドポイントのテスト送信を使うと、署名付きの ping イベントを受信でき、レシーバーと署名処理がエンドツーエンドで機能することを確認できます(診断のみで、状態は変わりません)。pending_verification または無効化されたエンドポイントを有効化するには検証を使用します: 同じ署名付き ping ですが、2xx を返すと active に切り替わり、失敗カウンターがリセットされます。署名シークレットが失効している場合、検証は WEBHOOK_SECRET_EXPIRED を返して拒否されます — 先にシークレットをローテーションしてください。