SIGNED ENVELOPE · SANDBOX INBOX · EXTERNAL_CALLS=0
Webhook envelope и безопасный Inbox
Реализованный контур создаёт tenant-scoped sandbox Webhook Inbox и append-only delivery observations. Он не отправляет внешний HTTPS-запрос: external_calls=0 остаётся частью safety contract.
- Граница выполнения
- LOCAL in-process Inbox · no external HTTPS target
- Signed sandbox Webhook Inbox
- РЕАЛИЗОВАНО
Контракты и исходные файлыOpenAPI, схемы и примеры для скачивания.
OpenAPI, схемы и примеры для скачивания.
| Endpoint family | Поведение | External effect |
|---|---|---|
| /v1/checkout/webhooks/verify | Проверяет signed envelope и replay window | Нет внешнего вызова |
| /v1/checkout/sandbox/webhook-subscriptions | Создаёт и читает tenant-scoped Inbox subscriptions | Только Local durable state |
| …/{subscription_id}/rotate | revoke | Ротирует или отзывает sandbox signing state | Нет provider credential |
| …/test-events | deliveries | retry | Фиксирует synthetic attempts и bounded retry | Только in-process Inbox |
- Считайте request body один раз как exact bytes и передавайте их verifier до JSON normalization.
- Выбирайте current или previous secret по X-CertaRail-Key-ID; после проверки сопоставьте header с verified key_id.
- Не логируйте signing secret, raw restricted payload или bearer.
import { createHash } from 'node:crypto';
import { verifyWebhook } from '@certrail/sdk';
const rawBody = new Uint8Array(await readRawRequestBytes(request));
const keyId = request.headers.get('X-CertaRail-Key-ID') ?? '';
const secret = await keyring.require(keyId); // keep current + previous key by key_id
const event = await verifyWebhook(rawBody, secret, {
toleranceMs: 5 * 60 * 1_000,
});
if (event.keyId !== keyId) throw new Error('WEBHOOK_KEY_ID_MISMATCH');
// The event identity, digest binding, and business change commit together.
const digest = createHash('sha256').update(rawBody).digest('hex');
const result = await inbox.processOnceAtomic(
event.eventId,
event.bodySha256,
digest,
(tx) => handleVerifiedEvent(tx, event),
);
if (result === 'CONFLICTING_DIGEST') {
await quarantine(event.eventId, 'WEBHOOK_REPLAY_CONFLICT');
return new Response(null, { status: 409 });
}
return new Response(null, { status: 204 });Verifier принимает occurred_at только в симметричном окне пяти минут относительно trusted UTC clock. Формат нормализован до UTC с точностью не более микросекунды; событие раньше или позже окна отклоняется как WEBHOOK_REPLAY_WINDOW до business handling.
Синхронизируйте часы receiver, измеряйте clock skew и не увеличивайте tolerance как способ скрыть проблемы времени.
Verify first
Проверьте exact raw bytes, body_sha256, HMAC и timestamp window до любой бизнес-операции.
Reserve atomically
В одной транзакции создайте UNIQUE(event_id), привяжите его к проверенному body_sha256 и зафиксируйте business change. При ошибке handler вся транзакция откатывается; race между workers имеет одного победителя.
Acknowledge exact duplicate
Тот же event_id с тем же body_sha256 возвращает 2xx без повторного handler; другой digest для того же event_id отклоняется и помещается в quarantine.
Каждый вызов фиксирует synthetic delivery observation внутри sandbox Webhook Inbox. Ни один сценарий не выполняет внешний HTTPS-вызов.
| signature_scenario | Receiver expectation | Предусловие |
|---|---|---|
| VALID_CURRENT_KEY | VALID · DELIVERED | ACTIVE endpoint и current secret |
| INVALID_SIGNATURE | WEBHOOK_SIGNATURE_INVALID · QUARANTINED | ACTIVE endpoint; RETRYABLE_FAILURE запрещён |
| ROTATED_KEY | VALID · DELIVERED | После rotate → verify → activate; session существовала при rotation, previous secret сохранён, событие внутри пяти минут |
# Valid event signed by the current key generation
curl -X POST "http://localhost:8080/v1/checkout/sandbox/webhook-subscriptions/${SUBSCRIPTION_ID}/test-events" \
-H "Authorization: Bearer ${CERTARAIL_API_KEY}" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: webhook-current-000001" \
--data '{"session_id":"'"${SESSION_ID}"'","simulate_outcome":"ACCEPTED","signature_scenario":"VALID_CURRENT_KEY"}'
# Expected receiver result: WEBHOOK_SIGNATURE_INVALID; stored as QUARANTINED
curl -X POST "http://localhost:8080/v1/checkout/sandbox/webhook-subscriptions/${SUBSCRIPTION_ID}/test-events" \
-H "Authorization: Bearer ${CERTARAIL_API_KEY}" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: webhook-invalid-000001" \
--data '{"session_id":"'"${SESSION_ID}"'","simulate_outcome":"ACCEPTED","signature_scenario":"INVALID_SIGNATURE"}'
# Run after rotate -> verify -> activate, inside the five-minute overlap
curl -X POST "http://localhost:8080/v1/checkout/sandbox/webhook-subscriptions/${SUBSCRIPTION_ID}/test-events" \
-H "Authorization: Bearer ${CERTARAIL_API_KEY}" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: webhook-rotated-000001" \
--data '{"session_id":"'"${SESSION_ID}"'","simulate_outcome":"ACCEPTED","signature_scenario":"ROTATED_KEY"}'Нашли неточность?
Участники private repository могут предложить правку через reviewed pull request. Остальные пользователи — отправить техническое сообщение без credentials и чувствительных данных.