Webhooks Guide
Webhooks let you receive real-time notifications when events happen in your Subcent vaults -- payments completed, escrows released, vault balance warnings, and more. Instead of polling the API, your application receives HTTP POST requests with signed payloads.
Setup Walkthrough#
1. Create an Endpoint#
Set up an HTTPS endpoint in your application to receive webhook events. The endpoint must:
- Accept
POSTrequests - Parse JSON request bodies
- Return a
200status code within 10 seconds - Be publicly accessible (no localhost for production)
2. Register the Webhook#
TypeScript
3. Store the Secret#
The registration response includes a secret field with a whsec_ prefix. This is your webhook signing secret. Store it securely -- it is only returned once and cannot be retrieved later.
Payload Structure#
Every webhook delivery is a JSON POST request with this structure:
{
"event": "payment.completed",
"event_id": "evt_a1b2c3d4e5f6g7h8i9j0k1l2",
"timestamp": "2025-01-15T10:30:00.000Z",
"data": {
"payment_id": "pay_770e8400...",
"status": "completed",
"amount": "25.00",
"currency": "USDC",
"merchant_id": "mrc_660e8400...",
"vault_id": "550e8400...",
"agent_id": "agt_550e8400..."
}
}
| Field | Type | Description |
|---|---|---|
| event | string | The event type (e.g., "payment.completed") |
| event_id | string | Unique identifier for this event (prefixed with evt_) |
| timestamp | string | ISO 8601 timestamp of when the event occurred |
| data | object | Event-specific data (varies by event type) |
Signature Verification#
Every webhook includes an X-Subcent-Signature header containing an HMAC-SHA256 signature. Always verify this signature before processing the event.
TypeScript#
import crypto from "node:crypto";
import express from "express";
const app = express();
// IMPORTANT: Use raw body parser for webhook routes
app.use("/webhooks/subcent", express.raw({ type: "application/json" }));
const WEBHOOK_SECRET = process.env.SUBCENT_WEBHOOK_SECRET!;
function verifySignature(secret: string, body: string, signature: string): boolean {
const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
app.post("/webhooks/subcent", (req, res) => {
const signature = req.headers["x-subcent-signature"] as string;
const rawBody = req.body.toString("utf-8");
if (!signature || !verifySignature(WEBHOOK_SECRET, rawBody, signature)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(rawBody);
switch (event.event) {
case "payment.completed":
handlePaymentCompleted(event.data);
break;
case "payment.pending_approval":
handlePaymentPending(event.data);
break;
case "vault.low_balance":
handleLowBalance(event.data);
break;
default:
console.log(`Unhandled event: ${event.event}`);
}
res.status(200).json({ received: true });
});
Python#
import os
import hmac
import hashlib
import json
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
WEBHOOK_SECRET = os.environ["SUBCENT_WEBHOOK_SECRET"]
def verify_signature(secret: str, body: str, signature: str) -> bool:
expected = hmac.new(
secret.encode("utf-8"),
body.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhooks/subcent")
async def handle_webhook(request: Request):
raw_body = await request.body()
body_str = raw_body.decode("utf-8")
signature = request.headers.get("x-subcent-signature", "")
if not verify_signature(WEBHOOK_SECRET, body_str, signature):
raise HTTPException(status_code=401, detail="Invalid signature")
event = json.loads(body_str)
if event["event"] == "payment.completed":
await handle_payment_completed(event["data"])
elif event["event"] == "payment.pending_approval":
await handle_payment_pending(event["data"])
elif event["event"] == "vault.low_balance":
await handle_low_balance(event["data"])
return {"received": True}
Always use constant-time comparison (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python) to prevent timing attacks against the signature verification.
Event Types by Category#
Payment Events#
| Event | Trigger | Data Fields |
|---|---|---|
| payment.completed | Payment auto-approved or manually approved | payment_id, status, amount, currency, merchant_id, vault_id, agent_id, transaction |
| payment.pending_approval | Payment requires human review | payment_id, status, amount, currency, merchant_id, vault_id, agent_id, approval_url |
| payment.rejected | Payment rejected by policy or human | payment_id, status, reason |
| payment.failed | Payment failed during on-chain execution | payment_id, status, error |
Escrow Events#
| Event | Trigger | Data Fields |
|---|---|---|
| escrow.funded | New escrow created and funded | escrow_id, vault_id, amount, condition_type, expires_at |
| escrow.released | Escrow funds released to merchant | escrow_id, status, tx_hash_release, released_at |
| escrow.refunded | Escrow funds returned to vault | escrow_id, status, refunded_at |
| escrow.disputed | Dispute raised on escrow | escrow_id, status, reason, disputed_at |
Vault and Policy Events#
| Event | Trigger | Data Fields |
|---|---|---|
| vault.low_balance | Vault balance below threshold | vault_id, balance, threshold |
| vault.frozen | Vault frozen by owner | vault_id, status, frozen_at |
| policy.updated | Policy created or updated | policy_id, agent_id, vault_id, version |
Retry and Failure Handling#
Subcent uses exponential backoff for failed deliveries:
| Attempt | Delay After Failure | |---|---| | 1st attempt | Immediate | | 2nd attempt | 2 seconds | | 3rd attempt | 4 seconds |
A delivery is considered failed if:
- Your endpoint returns a non-2xx HTTP status code
- Your endpoint does not respond within 10 seconds
- The connection cannot be established
After 3 failed attempts, the delivery is permanently marked as failed and no further retries occur.
Best Practices#
-
Return 200 immediately. Do heavy processing asynchronously. Queue the event and return a success response right away.
-
Handle duplicate events. Use the
event_idfield to deduplicate. Network issues can cause the same event to be delivered more than once. -
Log raw payloads. Store the raw JSON body for debugging. If signature verification fails, the raw body is essential for troubleshooting.
-
Monitor delivery failures. If your endpoint is consistently failing, webhooks will be marked as failed after 3 attempts. Set up alerting to detect webhook delivery issues.
For development and testing, use a tunneling service like ngrok to expose your local server to the internet:
ngrok http 3000
Then register the ngrok URL as your webhook endpoint.