웹훅
Crisphive에서 일이 발생하면 알림을 받으세요 — 폴링하는 대신, 우리가 이벤트를 여러분의 URL로 POST합니다.
작동 방식
팀원이 Crisphive 대시보드에서 HTTPS 엔드포인트를 등록하고 수신할 이벤트를 선택합니다(또는 전체). 서명 시크릿(whsec_…)은 한 번만 발급되므로 — 안전하게 보관하세요. 시크릿의 유효 기간도 여기서 선택합니다 — 기본 30일, 1일부터 365일까지(시크릿 유효 기간 & 로테이션 참고).
등록 즉시 서명된 검증 핑이 전송됩니다. 2xx로 응답하면 엔드포인트가 active 상태로 생성되고, 그렇지 않으면 대시보드에서 검증(Verify)을 통과할 때까지 pending_verification 상태(아무것도 수신하지 않음)로 남습니다. URL을 변경하면 재검증이 필요합니다.
이벤트가 발생하면 Crisphive는 JSON 본문과 Crisphive-Signature 헤더를 담아 여러분의 URL로 POST를 보냅니다.
엔드포인트는 서명을 검증하고, 2xx를 빠르게 반환한 뒤, 비동기적으로 처리합니다.
2xx가 아닌 응답 / 타임아웃 ⇒ 백오프 간격(1m, 5m, 30m, 2h, 6h)으로 재시도합니다. 연속 5회 실패 후(성공 한 번이면 카운트가 초기화됨) 엔드포인트가 자동으로 비활성화되고 비즈니스 소유자/관리자에게 이메일이 전송됩니다. 다시 활성화하려면 검증(Verify)에 성공해야 하며, 이때 카운터도 초기화됩니다.
모든 전송이 기록됩니다 — 대시보드는 엔드포인트별로 전체 전송 이력(상태, 시도 횟수, 마지막 HTTP 코드/오류)을 제공합니다.
id 기준으로 중복을 제거하세요(무작정 추가하지 말고 upsert).시크릿 유효 기간 & 로테이션
웹훅 서명 시크릿은 만료되며, 제자리에서 로테이션됩니다. 시크릿의 유효 기간은 엔드포인트를 등록할 때 선택합니다 — 기본 30일, 1일부터 365일까지. API 키와 달리 웹훅 엔드포인트는 하나의 등록된 URL이므로 나란히 복제할 수 없습니다(같은 URL을 두 번 등록하면 모든 이벤트가 두 번씩 전송됩니다). 그래서 두 번째 엔드포인트 대신 시크릿을 로테이션합니다:
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 | 기사가 로스터에서 제거됨. |
상태 변경은 정확히 하나의 이벤트를 발생시킵니다(해당하는 특정 마일스톤이 있으면 그것을, 없으면 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)입니다. 원본 요청 본문에 대해 계산하고, 상수 시간(constant-time)으로 비교하며, t가 너무 오래된 경우(예: > 5분) 리플레이를 막기 위해 거부하세요.
단일 값이 아니라 서명 목록에 대해 검증하세요. 24시간의 로테이션 유예 기간 동안 헤더에는 두 개의 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
테스트
대시보드에서 엔드포인트의 테스트 전송(Send test)을 사용하면 서명된 ping 이벤트를 수신하여 수신기 + 서명 처리가 처음부터 끝까지 제대로 동작하는지 확인할 수 있습니다(진단 전용 — 상태는 절대 바뀌지 않음). 검증(Verify)은 pending_verification 또는 비활성 엔드포인트를 활성화할 때 사용합니다: 동일한 서명된 핑이지만, 2xx를 반환하면 active로 전환되고 실패 카운터가 초기화됩니다. 서명 시크릿이 만료된 경우 Verify는 WEBHOOK_SECRET_EXPIRED와 함께 거부하므로 — 먼저 시크릿을 로테이션하세요.