subcent

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 POST requests
  • Parse JSON request bodies
  • Return a 200 status 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}
x

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#

  1. Return 200 immediately. Do heavy processing asynchronously. Queue the event and return a success response right away.

  2. Handle duplicate events. Use the event_id field to deduplicate. Network issues can cause the same event to be delivered more than once.

  3. Log raw payloads. Store the raw JSON body for debugging. If signature verification fails, the raw body is essential for troubleshooting.

  4. 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.