Skip to main content

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:

AttemptDelayCumulative
110s10s
21m1m 10s
35m~6m
415m~21m
51h~1h 21m
64h~5h 21m
DroppedPermanent

Error Scenarios:

ScenarioHTTP StatusBehavior
Server 5xx500-599Retry with backoff
Network timeoutN/ARetry with backoff
Invalid signature401Retry (may be transient key state)
4xx (non-auth)400-499 (excl. 401)Drop immediately
6 consecutive failuresN/APermanently 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

ComponentDescription
tUnix timestamp of when the signature was generated
v1HMAC-SHA256 digest of t.payload_body, hex-encoded

Signature Verification

  1. Extract t and v1 from the Webhook-Signature header.
  2. Reconstruct the signed payload: signed_payload = t + "." + raw_body
  3. Compute the expected HMAC: expected = HMAC-SHA256(webhook_secret, signed_payload)
  4. Compare v1 with expected using 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):

ComponentDescription
ephemeral_pkEphemeral X25519 public key (32 bytes)
nonceXSalsa20 nonce (24 bytes)
ciphertextAuthenticated encrypted payload
hmac_key_idKey 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

  1. Generate a new webhook secret.
  2. Dual-delivery window: During the grace_period_seconds, the protocol signs each delivery with both the old and new secret (duplicate v1 header entries). This allows consumers to verify against either key without disruption.
  3. Old key expiration: After the grace period, old-key signatures are dropped.
  4. Verification: Consumers should try each v1 entry 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.

Idempotency-Key: wh_evt_01J7Z2A3B4C5D6E7F8G9H0I1J-retry-1

Consumer Behavior

  1. Store processed Idempotency-Key values for at least 24 hours.
  2. On receipt, check if the Idempotency-Key has already been processed. If yes, return a success response without processing again (idempotent replay).
  3. 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:

AttemptDelay After Previous Attempt
110 seconds
21 minute
35 minutes
415 minutes
51 hour
64 hours
712 hours
824 hours
972 hours (3 days)
10Final attempt — after this, the delivery is marked as failed

Delivery Status

StatusDescription
pendingQueued for delivery
deliveringAttempt in progress
deliveredSuccessfully delivered (2xx response)
failedAll retries exhausted
skippedDelivery skipped due to endpoint being disabled

Webhook Events for Delivery Status

EventDescription
webhook.delivery.succeededWebhook delivered successfully
webhook.delivery.failedAll retries exhausted — delivery permanently failed
webhook.delivery.retryingDelivery failed but will be retried

Key Behaviors

  • Signature always required: Every webhook delivery includes a Webhook-Signature header 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

CodeDescription
webhook_signature_invalidHMAC verification failed
webhook_secret_expiredThe webhook secret used for signing has expired
webhook_decryption_failedE2E decryption failed (wrong key or corrupted payload)
webhook_key_not_foundNo matching public key found for the provided hmac_key_id
webhook_delivery_failedAll delivery retries exhausted
webhook_endpoint_disabledThe endpoint has been disabled; delivery skipped

Next Steps