Webhook Encryption
The Webhook Encryption capability ensures that webhook payloads are both authenticated (proven origin) and confidential (readable only by the intended merchant). Inspired by Stripe's Stripe-Signature header, Square's constant-time HMAC verification, and PGP/GPG end-to-end encryption, this system provides two complementary layers: standard HMAC-SHA256 signatures for authenticity, and optional X25519-based E2E encryption for payload confidentiality.
Interaction Traces & Swimlane Diagrams
1. Webhook Registration Flow
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Human │ │ Agent │ │ ItPay │
│ (Admin) │ │ (Operator) │ │ Protocol │
└────┬─────┘ └──────┬───────┘ └────┬─────┘
│ │ │
│ Generate │ │
│ X25519 keypair │ │
│ (private + │ │
│ public) │ │
│<──────────────── │ │
│ │ │
│ Register webhook│ │
│ endpoint URL │ │
│─────────────────>│ │
│ │ POST /v1/ │
│ │ webhook- │
│ │ endpoints │
│ │──────────────────>│
│ │ │
│ │ Register public │
│ │ key for E2E │
│ │──────────────────>│
│ │ │
│ │ 201 + whsec_*** │
│ │ + endpoint_id │
│ │<──────────────────│
│ Store webhook │ │
│ secret + │ │
│ endpoint URL │ │
│<─────────────────│ │
Registration HTTP Call:
POST /v1/webhook-endpoints
Content-Type: application/json
Authorization: Bearer *** "url": "https://merchant.example.com/webhooks",
"description": "Production webhook for payment events",
"enabled_events": [
"payment_intent.succeeded",
"payment_intent.failed",
"escrow.held",
"escrow.released",
"escrow.dispute.created"
],
"encryption": {
"public_key": "MCowBQYDK2VuAyEA7bM2...",
"key_type": "x25519"
}
}
Response:
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": "we_01J7Z2A3B4C5D6E7F8G9H0I1J",
"url": "https://merchant.example.com/webhooks",
"status": "enabled",
"secret": "whsec_abc123def456...",
"encryption": {
"public_key_fingerprint": "a1b2c3d4e5f6...",
"key_type": "x25519",
"enabled": true
},
"created_at": "2026-05-27T09:00:00Z"
}
2. Event Delivery & E2E Decryption Flow
┌──────────────┐ ┌──────────┐ ┌────────────┐
│ ItPay │ │ Agent │ │ Merchant │
│ Protocol │ │(Operator)│ │ (Private) │
└──────┬───────┘ └────┬─────┘ └─────┬──────┘
│ │ │
│ Event occurs │ │
│ (e.g. payment │ │
│ succeeded) │ │
│ │ │
│ Encrypt payload │ │
│ with merchant's │ │
│ X25519 public │ │
│ key │ │
│ (NaCl box: │ │
│ ephem_pk + │ │
│ nonce + ct) │ │
│ │ │
│ Compute HMAC- │ │
│ SHA256 of │ │
│ t.payload_body │ │
│ │ │
│ POST to webhook │ │
│ URL │ │
│─────────────────>│ │
│ │ │
│ │ 1. Parse header │
│ │ Webhook- │
│ │ Signature │
│ │ │
│ │ 2. Extract t, │
│ │ v1, enc │
│ │ │
│ │ 3. Decrypt with │
│ │ private key │── Decrypt using
│ │──────────────────│ X25519 privkey
│ │ decrypted_body │ via crypto_box_
│ │<─────────────────│ open_afternm
│ │ │
│ │ 4. Verify HMAC │
│ │ (constant- │
│ │ time compare)│
│ │ │
│ │ 5. Check t │
│ │ is within │
│ │ 5 min skew │
│ │ │
│ │ 6. Process │
│ │ event │
│ │ │
│ 200 OK │ │
│<─────────────────│ │
Webhook Payload (E2E encrypted) — the body the agent receives:
POST /webhooks HTTP/1.1
Host: merchant.example.com
Content-Type: application/json
Webhook-Signature: t=1716792600,v1=abc123def456...,enc=base64(ciphertext)
Idempotency-Key: wh_evt_01J7Z2A3B4C5D6E7F8G9H0I1J-retry-1
{
"encrypted": true,
"key_fingerprint": "a1b2c3d4e5f6...",
"ciphertext": "base64_encoded_nacl_box..."
}
Decryption Code (Python):
import base64
from nacl.bindings import crypto_box_open_afternm
def decrypt_webhook(payload: dict, private_key: bytes) -> bytes:
"""Decrypt an E2E-encrypted webhook payload."""
ciphertext = base64.b64decode(payload["ciphertext"])
# NaCl box: ephemeral_pk (32) + nonce (24) + ciphertext
ephemeral_pk = ciphertext[:32]
nonce = ciphertext[32:56]
ct = ciphertext[56:]
return crypto_box_open_afternm(ct, nonce, ephemeral_pk, private_key)
# Decrypted payload
# {
# "id": "evt_01J7Z3A4B5C6D7E8F9G0H1I2J",
# "type": "payment_intent.succeeded",
# "data": {
# "object": {
# "id": "pi_01J7Z...",
# "amount": 50000,
# "status": "succeeded"
# }
# },
# "created": 1716792600
# }
Verification Code (Python):
import hmac
import hashlib
import time
def verify_and_decrypt(
raw_body: bytes,
signature_header: str,
webhook_secret: bytes,
private_key: bytes | None = None
) -> dict | None:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t = int(parts["t"])
v1 = parts["v1"]
# 1. Timestamp check (±5 min skew)
if abs(time.time() - t) > 300:
raise ValueError("webhook_signature_invalid: timestamp skew too large")
# 2. Verify HMAC on raw body (before decryption)
signed_payload = f"{t}.{raw_body.decode()}"
expected = hmac.new(webhook_secret, signed_payload.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(v1, expected):
raise ValueError("webhook_signature_invalid: HMAC mismatch")
# 3. Decrypt if E2E is active
if private_key and "enc" in parts:
payload = json.loads(raw_body)
decrypted = decrypt_webhook(payload, private_key)
return json.loads(decrypted)
return json.loads(raw_body)
3. Webhook Key Rotation Flow
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Human │ │ Agent │ │ ItPay │
│ (Admin) │ │ (Operator) │ │ Protocol │
└────┬─────┘ └──────┬───────┘ └────┬─────┘
│ │ │
│ Generate new │ │
│ X25519 keypair │ │
│<──────────────── │ │
│ │ │
│ Register new │ │
│ public key │ │
│─────────────────>│ │
│ │ POST /v1/ │
│ │ merchant/ │
│ │ webhook-keys │
│ │ {public_key: new}│
│ │──────────────────>│
│ │ │── Store new key
│ │ │── Keep old key
│ │ │ in rotation
│ │ │
│ │ 201 + new │
│ │ fingerprint │
│ │<──────────────────│
│ │ │
│ ═══ Grace Period ═══ │
│ (dual deliveries signed │
│ with both old + new keys) │
│ │ │
│ Remove old key │ │
│ after grace │ │
│─────────────────>│ │
│ │ DELETE /v1/ │
│ │ merchant/ │
│ │ webhook-keys/old │
│ │──────────────────>│
│ │ │
│ │ 200 ok │
│ │<──────────────────│
Key Registration Request:
POST /v1/merchant/webhook-keys
Content-Type: application/json
Authorization: Bearer *** "public_key": "MCowBQYDK2VuAyEA7bM2...",
"key_type": "x25519",
"label": "production-2026-v2"
}
Response:
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": "whk_01J7Z4A5B6C7D8E9F0G1H2I3J",
"key_type": "x25519",
"fingerprint": "e5f6g7h8...",
"label": "production-2026-v2",
"created_at": "2026-05-27T09:00:00Z"
}
4. Webhook Delivery Retry & Failure Flow
┌──────────────┐ ┌──────────────┐ ┌──────────┐
│ ItPay │ │ Agent │ │ Merchant │
│ Protocol │ │ (Operator) │ │ Server │
└──────┬───────┘ └──────┬───────┘ └────┬─────┘
│ │ │
│ Attempt 1 │ │
│ ── POST ─────────>│ │
│ │── POST ──────────>│
│ │ │── Server
│ │ │ returns
│ │ │ 503 Service
│ │ │ Unavailable
│ │<── 503 ───────────│
│ 503 │ │
│<───────────────────│ │
│ │ │
│ Wait 10s │ │
│ │ │
│ Attempt 2 │ │
│ ── POST ─────────>│ │
│ │── POST ──────────>│
│ │ │── Still down
│ │ │ 503
│ │<── 503 ───────────│
│ 503 │ │
│<───────────────────│ │
│ │ │
│ ░░░ exponential backoff ░░░ │
│ Attempts 3-5: │
│ 5min → 15min → 1hr │
│ │ │
│ Attempt 6 │ │
│ ── POST ─────────>│ │
│ │── POST ──────────>│
│ │ │── Still 503
│ │<── 503 ───────────│
│ 503 │ │
│<───────────────────│ │
│ │ │
│ ✗ 6 failures │ │
│ ✗ Permanently │ │
│ dropped │ │
│ │ │
│ Webhook delivery │ │
│ event: │ │
│ webhook.delivery │ │
│ .failed │ │
│───────────────────>│ │
Retry Schedule:
| Attempt | Delay | Cumulative |
|---|---|---|
| 1 | 10s | 10s |
| 2 | 1m | 1m 10s |
| 3 | 5m | ~6m |
| 4 | 15m | ~21m |
| 5 | 1h | ~1h 21m |
| 6 | 4h | ~5h 21m |
| → | Dropped | Permanent |
Error Scenarios:
| Scenario | HTTP Status | Behavior |
|---|---|---|
| Server 5xx | 500-599 | Retry with backoff |
| Network timeout | N/A | Retry with backoff |
| Invalid signature | 401 | Retry (may be transient key state) |
| 4xx (non-auth) | 400-499 (excl. 401) | Drop immediately |
| 6 consecutive failures | N/A | Permanently dropped |
Webhook Event on Permanent Failure:
{
"type": "webhook.delivery.failed",
"data": {
"endpoint_id": "we_01J7Z2A3B4C5D6E7F8G9H0I1J",
"event_id": "evt_01J7Z3A4B5C6D7E8F9G0H1I2J",
"attempts": 6,
"failures": [
{"attempt": 1, "status": 503, "at": "2026-05-27T09:00:10Z"},
{"attempt": 2, "status": 503, "at": "2026-05-27T09:01:10Z"},
{"attempt": 3, "status": 503, "at": "2026-05-27T09:06:10Z"},
{"attempt": 4, "status": 503, "at": "2026-05-27T09:21:10Z"},
{"attempt": 5, "status": 503, "at": "2026-05-27T10:21:10Z"},
{"attempt": 6, "status": 503, "at": "2026-05-27T14:21:10Z"}
],
"final_status": "failed"
}
}
HMAC-SHA256 Signature (Authenticity)
Every webhook delivery includes a Webhook-Signature header in Stripe-compatible format:
Webhook-Signature: t=1716792600,v1=abc123def456...
Header Format
| Component | Description |
|---|---|
t | Unix timestamp of when the signature was generated |
v1 | HMAC-SHA256 digest of t.payload_body, hex-encoded |
Signature Verification
- Extract
tandv1from theWebhook-Signatureheader. - Reconstruct the signed payload:
signed_payload = t + "." + raw_body - Compute the expected HMAC:
expected = HMAC-SHA256(webhook_secret, signed_payload) - Compare
v1withexpectedusing a constant-time comparison (Square pattern) to prevent timing attacks.
import hmac
import hashlib
def verify_signature(payload: bytes, header: str, secret: bytes) -> bool:
# Parse header
parts = dict(p.split("=") for p in header.split(","))
t = parts["t"]
v1 = parts["v1"]
# Reconstruct signed payload
signed_payload = f"{t}.{payload.decode()}"
# Compute expected HMAC
expected = hmac.new(secret, signed_payload.encode(), hashlib.sha256).hexdigest()
# Constant-time comparison
return hmac.compare_digest(v1, expected)
Raw Body Requirement
The payload body MUST NOT be pre-parsed or modified before verification. The raw_body used in signature computation is the exact HTTP request body as received (including line endings and whitespace). JSON parsing, pretty-printing, or any transformation of the body before verification will cause signature mismatch (Stripe raw body requirement).
E2E Encryption (Confidentiality)
For sensitive payloads, merchants can register an X25519 public key. When enabled, the webhook payload body is encrypted before signing, ensuring that only the merchant's private key can decrypt the contents.
Merchant Key Registration
POST /v1/merchant/webhook-keys
Content-Type: application/json
Authorization: Bearer ***
{
"public_key": "MCowBQYDK2VuAyEA7bM2...",
"key_type": "x25519",
"label": "production-2026"
}
Response Example
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": "whk_01J7Z1A2B3C4D5E6F7G8H9I0J",
"key_type": "x25519",
"fingerprint": "a1b2c3d4e5f6...",
"created_at": "2026-05-27T09:00:00Z"
}
Encrypted Payload Format
When E2E encryption is enabled, the Webhook-Signature header carries an additional enc parameter:
Webhook-Signature: t=1716792600,v1=abc123...,enc=base64(ciphertext)
The encrypted payload is a NaCl box (X25519+XSalsa20-Poly1305):
| Component | Description |
|---|---|
ephemeral_pk | Ephemeral X25519 public key (32 bytes) |
nonce | XSalsa20 nonce (24 bytes) |
ciphertext | Authenticated encrypted payload |
hmac_key_id | Key identifier linking to the merchant's registered public key |
The merchant decrypts using their X25519 private key:
from nacl.bindings import crypto_box_open_afternm
def decrypt_payload(encrypted_box: bytes, private_key: bytes) -> bytes:
# Parse the NaCl box structure
ephemeral_pk = encrypted_box[:32]
nonce = encrypted_box[32:56]
ciphertext = encrypted_box[56:]
return crypto_box_open_afternm(ciphertext, nonce, ephemeral_pk, private_key)
Secret Rotation
Webhook secrets should be rotated regularly to limit the blast radius of a compromised secret.
Rotate Secret
POST /v1/merchant/webhook-secrets/rotate
Content-Type: application/json
Authorization: Bearer ***
{
"reason": "scheduled_rotation",
"grace_period_seconds": 86400
}
Response Example
HTTP/1.1 200 OK
Content-Type: application/json
{
"old_key_fingerprint": "f1a2b3c4...",
"new_key_fingerprint": "e5f6g7h8...",
"old_key_expires_at": "2026-05-28T09:00:00Z",
"created_at": "2026-05-27T09:00:00Z"
}
Rotation Process
- Generate a new webhook secret.
- Dual-delivery window: During the
grace_period_seconds, the protocol signs each delivery with both the old and new secret (duplicatev1header entries). This allows consumers to verify against either key without disruption. - Old key expiration: After the grace period, old-key signatures are dropped.
- Verification: Consumers should try each
v1entry until one passes verification; if none pass, reject the delivery.
Idempotency
Webhook deliveries include an Idempotency-Key header following the Stripe pattern. This ensures safe retry of webhook processing: if the consumer receives the same webhook event twice (due to network issues or retries), identical Idempotency-Key values indicate a duplicate.
Header
Idempotency-Key: wh_evt_01J7Z2A3B4C5D6E7F8G9H0I1J-retry-1
Consumer Behavior
- Store processed
Idempotency-Keyvalues for at least 24 hours. - On receipt, check if the
Idempotency-Keyhas already been processed. If yes, return a success response without processing again (idempotent replay). - The key is derived from the webhook event ID and a delivery attempt counter, so each unique delivery of the same event has a distinct key but re-deliveries of the exact same payload reuse the same key.
Failed Delivery Retry Schedule
When a webhook delivery fails (non-2xx response or network timeout), the protocol retries on an exponential backoff schedule:
| Attempt | Delay After Previous Attempt |
|---|---|
| 1 | 10 seconds |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 4 hours |
| 7 | 12 hours |
| 8 | 24 hours |
| 9 | 72 hours (3 days) |
| 10 | Final attempt — after this, the delivery is marked as failed |
Delivery Status
| Status | Description |
|---|---|
pending | Queued for delivery |
delivering | Attempt in progress |
delivered | Successfully delivered (2xx response) |
failed | All retries exhausted |
skipped | Delivery skipped due to endpoint being disabled |
Webhook Events for Delivery Status
| Event | Description |
|---|---|
webhook.delivery.succeeded | Webhook delivered successfully |
webhook.delivery.failed | All retries exhausted — delivery permanently failed |
webhook.delivery.retrying | Delivery failed but will be retried |
Key Behaviors
- Signature always required: Every webhook delivery includes a
Webhook-Signatureheader regardless of whether E2E encryption is enabled. - E2E encryption is opt-in: Merchants register an X25519 public key to enable payload encryption. Without it, payloads are signed but sent in plaintext.
- No pre-parsing: The raw HTTP body must be preserved for signature verification. JSON parsing or pretty-printing before verification will invalidate the signature (critical — Stripe raw body requirement).
- Constant-time comparison: Always use
hmac.compare_digest(or equivalent) to prevent timing side-channel attacks that could leak the signature value. - Grace-period rotation: Dual signing during the grace window allows zero-downtime secret rotation.
- Idempotency window: Idempotency keys are valid for 24 hours. After that, the protocol may re-deliver the event with a new key.
- Final delivery failure: After 10 retries, the delivery is permanently marked as
failed. Merchants can inspect failed deliveries via the dashboard or API.
Error Codes
| Code | Description |
|---|---|
webhook_signature_invalid | HMAC verification failed |
webhook_secret_expired | The webhook secret used for signing has expired |
webhook_decryption_failed | E2E decryption failed (wrong key or corrupted payload) |
webhook_key_not_found | No matching public key found for the provided hmac_key_id |
webhook_delivery_failed | All delivery retries exhausted |
webhook_endpoint_disabled | The endpoint has been disabled; delivery skipped |
Next Steps
- Escrow Payments — Trust-minimized payment flow
- Security Model — Protocol security architecture
- Security Model — Security best practices for merchants