Webhook system for delivering bond lifecycle events to registered endpoints.
bond.created- Bond becomes activebond.slashed- Bond amount decreases while activebond.withdrawn- Bond becomes inactive with zero amountattestation.added- A new attestation was addedattestation.revoked- An attestation was revokedscore.updated- Reputation score was recomputed
{
"event": "bond.created",
"timestamp": "2024-01-01T00:00:00.000Z",
"data": {
"address": "0xabc...",
"bondedAmount": "1000",
"bondStart": 1234567890,
"bondDuration": 86400,
"active": true
}
}- The default maximum payload size is 256KB (configurable via
WEBHOOK_PAYLOAD_SIZE_CAPenvironment variable) - If a payload exceeds this limit and
datais an array/list, the system will automatically split it into multiple chunks- Each chunk includes additional metadata:
chunkId: A unique identifier for the entire multi-chunk eventchunkIndex: 0-based index of the current chunktotalChunks: Total number of chunks in the sequence
- Each chunk includes additional metadata:
- If a payload cannot be split into chunks (e.g.,
datais an object, or a single item exceeds the limit), it will be sent withpayloadTruncated: true
{
"event": "bond.created",
"timestamp": "2024-01-01T00:00:00.000Z",
"data": [
{ "address": "0xabc...", "bondedAmount": "1000" },
{ "address": "0xdef...", "bondedAmount": "2000" }
],
"chunkId": "a1b2c3d4e5f6...",
"chunkIndex": 0,
"totalChunks": 2
}{
"event": "bond.created",
"timestamp": "2024-01-01T00:00:00.000Z",
"data": { "address": "0xabc...", "veryLargeField": "..." },
"payloadTruncated": true
}Payloads are signed with HMAC-SHA256. Verify using the X-Webhook-Signature header:
import { createHmac } from "crypto";
const signature = createHmac("sha256", secret)
.update(requestBody)
.digest("hex");
if (signature !== request.headers["x-webhook-signature"]) {
throw new Error("Invalid signature");
}For full operator guidance on signature verification, replay window tolerances, 24-hour secret rotation grace periods, Express middleware usage, and failure troubleshooting, see WEBHOOK_SIGNING.md.
For enterprise subscribers requiring mutual TLS authentication, webhooks can be configured with client certificates and server certificate pinning.
The webhook configuration supports optional mTLS fields:
clientCertPem- PEM-encoded client certificate for mTLS authenticationclientKeyKmsRef- KMS reference for client private key (never stored as plaintext)pinnedServerCertSha256- SHA256 hash of pinned server certificate for certificate pinning
Client certificates can be rotated by updating the clientCertPem and clientKeyKmsRef fields. The system supports:
- Grace period for certificate rotation (24 hours by default)
- Automatic retry with exponential backoff on temporary certificate issues
- Certificate pinning validation to prevent man-in-the-middle attacks
mTLS-specific failures emit the WEBHOOK_MTLS_FAILURE error code and are tracked via the webhook_mtls_failure_total metric with labels:
subscriber- webhook IDreason- specific failure reason (e.g.,cert_pin_mismatch,handshake_failure)
- Client Key Storage: Client private keys are stored only as KMS references, never in plaintext
- Certificate Pinning: Server certificate hashes are compared in constant time to prevent timing attacks
- Rotation Support: Certificate rotation is supported with a grace period to avoid service disruption
- Fail-Safe: mTLS configuration is optional; webhooks without mTLS continue to work with standard HTTPS
- Automatic retry with exponential backoff (max 3 attempts, configurable per webhook)
- 5 second timeout per request (configurable per webhook)
- Rate limited to 1 delivery per webhook per 100ms
- 4xx errors are not retried
- mTLS handshake failures emit specific error codes for debugging
import { createWebhookService } from "./services/webhooks/index.js";
// Create service with webhook store and postgres DLQ store
const dlqStore = new PostgresDlqStore(pool);
const webhookService = new WebhookService(
store,
{
maxRetries: 3,
initialDelay: 1000,
timeout: 5000,
},
dlqStore,
);
// Emit event
await webhookService.emit("bond.created", {
address: "0xabc",
bondedAmount: "1000",
bondStart: 1234567890,
bondDuration: 86400,
active: true,
});// Configure webhook with mTLS
await webhookService.register({
url: "https://enterprise.example.com/webhook",
events: ["bond.created", "bond.slashed"],
secret: "hmac-signing-secret",
clientCertPem: "-----BEGIN CERTIFICATE-----\n...",
clientKeyKmsRef: "kms://arn:aws:kms:us-east-1:123456789012:key/abc123",
pinnedServerCertSha256: "a1b2c3d4e5f6...",
timeoutMs: 10000,
maxAttempts: 5,
});Failed webhook deliveries (e.g. max retries exceeded or 4xx responses) are permanently stored in a Postgres-backed Dead Letter Queue (webhook_dlq table).
- Durability: Survives application restarts and deployments.
- Metrics: The current size of the DLQ is exposed as a Prometheus gauge
webhook_dlq_size. - Replayability: DLQ entries can be inspected and manually replayed, updating the
replayed_attimestamp. - mTLS Failures: mTLS-specific failures include the
WEBHOOK_MTLS_FAILUREerror code for identification.
Bond lifecycle webhooks for identity state changes must be emitted through the
transactional outbox so delivery survives process crashes and rolls back with
failed state updates. Use webhookIntegrationOutbox.ts:
import { emitWebhookForStateChange } from "./listeners/webhookIntegrationOutbox.js";
// Within the same database transaction as the state update
await db.transaction(async (tx) => {
const oldState = await store.get(address, tx);
await store.set(newState, tx);
await emitWebhookForStateChange(tx, oldState, newState);
});Event type detection is shared in webhookEventDetection.ts (detectEventType).
Both integration modules import it so detection logic cannot drift.
webhookIntegration.ts emitted webhooks by calling webhookService.emit() directly
after the state write, outside the outbox. This path is deprecated — it is not
crash-safe and can double-emit if both paths are used. No internal callers depend on
it; migrate to webhookIntegrationOutbox.ts.
| Module | Status | When to use |
|---|---|---|
webhookEventDetection.ts |
Shared | Import detectEventType only if you need detection without emission |
webhookIntegrationOutbox.ts |
Authoritative | All new identity-state webhook integration |
webhookIntegration.ts |
Deprecated | Do not use in new code; will be removed in a future release |
// Before (deprecated)
await store.set(newState);
await emitWebhookForStateChange(webhookService, oldState, newState);
// After (authoritative)
await db.transaction(async (tx) => {
await store.set(newState, tx);
await emitWebhookForStateChange(tx, oldState, newState);
});The outbox publisher (src/db/outbox/webhookPublisher.ts) delivers events to
registered webhook endpoints asynchronously. See src/db/outbox/README.md for
outbox architecture details.
Key metrics for webhook delivery:
webhook_delivery_duration- Time taken for webhook delivery attemptswebhook_timeout_total- Count of webhook timeoutswebhook_mtls_failure_total- Count of mTLS-specific failures (withsubscriberandreasonlabels)webhook_dlq_size- Current size of the dead letter queuewebhook_payload_bytes- Histogram of webhook payload sizes in bytes (withsubscriberlabel)