ko

웹훅

Crisphive에서 일이 발생하면 알림을 받으세요 — 폴링하는 대신, 우리가 이벤트를 여러분의 URL로 POST합니다.

작동 방식

1
엔드포인트 등록

팀원이 Crisphive 대시보드에서 HTTPS 엔드포인트를 등록하고 수신할 이벤트를 선택합니다(또는 전체). 서명 시크릿(whsec_…)은 한 번만 발급되므로 — 안전하게 보관하세요. 시크릿의 유효 기간도 여기서 선택합니다 — 기본 30일, 1일부터 365일까지(시크릿 유효 기간 & 로테이션 참고).

2
검증 핑

등록 즉시 서명된 검증 핑이 전송됩니다. 2xx로 응답하면 엔드포인트가 active 상태로 생성되고, 그렇지 않으면 대시보드에서 검증(Verify)을 통과할 때까지 pending_verification 상태(아무것도 수신하지 않음)로 남습니다. URL을 변경하면 재검증이 필요합니다.

3
이벤트 POST

이벤트가 발생하면 Crisphive는 JSON 본문과 Crisphive-Signature 헤더를 담아 여러분의 URL로 POST를 보냅니다.

4
검증 및 확인 응답

엔드포인트는 서명을 검증하고, 2xx를 빠르게 반환한 뒤, 비동기적으로 처리합니다.

5
재시도 및 자동 비활성화

2xx가 아닌 응답 / 타임아웃 ⇒ 백오프 간격(1m, 5m, 30m, 2h, 6h)으로 재시도합니다. 연속 5회 실패 후(성공 한 번이면 카운트가 초기화됨) 엔드포인트가 자동으로 비활성화되고 비즈니스 소유자/관리자에게 이메일이 전송됩니다. 다시 활성화하려면 검증(Verify)에 성공해야 하며, 이때 카운터도 초기화됩니다.

6
전송 이력

모든 전송이 기록됩니다 — 대시보드는 엔드포인트별로 전체 전송 이력(상태, 시도 횟수, 마지막 HTTP 코드/오류)을 제공합니다.

최소 한 번 전송(at-least-once). 전송은 반복될 수 있으므로 — 이벤트 id 기준으로 중복을 제거하세요(무작정 추가하지 말고 upsert).

시크릿 유효 기간 & 로테이션

웹훅 서명 시크릿은 만료되며, 제자리에서 로테이션됩니다. 시크릿의 유효 기간은 엔드포인트를 등록할 때 선택합니다 — 기본 30일, 1일부터 365일까지. API 키와 달리 웹훅 엔드포인트는 하나의 등록된 URL이므로 나란히 복제할 수 없습니다(같은 URL을 두 번 등록하면 모든 이벤트가 두 번씩 전송됩니다). 그래서 두 번째 엔드포인트 대신 시크릿을 로테이션합니다:

Developers → Webhooks → Rotate secret은 새 whsec_을 발급하고 24시간 동안 새 시크릿과 이전 시크릿 모두로 계속 서명합니다. 로테이션하면 유효 기간도 다시 시작되며 — 로테이션하면서 새 유효 기간을 선택합니다(기본 30일, 1일부터 365일까지) — 그래서 이것이 갱신 경로이기도 합니다.

서명 시크릿이 만료되기 7일 전과 만료된 후에 비즈니스의 소유자와 관리자에게 이메일이 발송됩니다. 시크릿 유효 기간이 도입되기 전에 등록된 엔드포인트에는 secret_expires_at이 없으며 이전처럼 계속 서명합니다 — 첫 로테이션을 하면 표준 유효 기간이 적용됩니다.

시크릿이 만료되도록 방치하면 엔드포인트가 비활성화되어 전송을 받지 못합니다. 먼저 로테이션한 다음 검증(Verify)을 실행해 전송을 재개하세요 — 만료된 시크릿에 Verify를 실행하면 엔드포인트를 다시 활성화하는 대신 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와 함께 거부하므로 — 먼저 시크릿을 로테이션하세요.