Webhooks

Get notified about payout events on your own endpoint. Every delivery is signed — verify it before you trust it.

Webhooks push payout state changes to your backend so you don't have to poll. You register an HTTPS endpoint, subscribe to the events you care about, and Caibo POSTs a signed JSON payload each time a subscribed event occurs.

Events

EventFires when
payout.createdA payout has been created and accepted.
payout.processingThe payout is being broadcast on-chain.
payout.completedThe payout settled. tx_hash is set.
payout.failedThe broadcast failed; funds were refunded to your balance.
payout.blockedScreening blocked the payout. It was not sent.

See Payout lifecycle for how these events map to the payout state machine.

Registering an endpoint

Create an endpoint over HTTPS and choose your events. The response includes a signing secret shown once — store it securely; you'll use it to verify every delivery.

bash
curl https://api.caiboglobal.com/v1/webhooks \
  -H "X-API-Key: $CAIBO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/caibo/webhook",
    "events": ["payout.completed", "payout.failed", "payout.blocked"]
  }'
json
{
  "object": "webhook_endpoint",
  "id": "whe_1a2b3c4d",
  "url": "https://api.example.com/caibo/webhook",
  "events": ["payout.completed", "payout.failed", "payout.blocked"],
  "active": true,
  "secret": "whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c",
  "created_at": "2026-07-23T14:03:00Z"
}

Payload

The request body is a JSON object describing the event and the payout it concerns:

json
{
  "event": "payout.completed",
  "object": "payout",
  "id": "po_9f8e7d6c5b4a",
  "status": "completed",
  "amount": "25.00",
  "currency": "USDC",
  "mode": "live",
  "destination": { "network": "polygon", "address": "0x1234...5678" },
  "tx_hash": "0x8f3a9c2b1d4e5f60...",
  "created_at": "2026-07-23T14:03:00Z"
}

Two headers accompany every delivery:

HeaderValue
Caibo-EventThe event name, e.g. payout.completed.
Caibo-SignatureThe signature: t=<unix>,v1=<hex>.

Verifying the signature

Before trusting a delivery, verify its signature. The Caibo-Signature header contains a timestamp t and a signature v1, where v1 = HMAC-SHA256(secret, "<t>.<raw_body>") in lowercase hex. To verify:

  1. Read the raw request body — the exact bytes received, before any JSON parsing.
  2. Parse t and v1 from the header.
  3. Reject replays: if |now − t| > 5 minutes, discard the request.
  4. Compute the expected HMAC and compare it to v1 in constant time.

Node.js (Express)

javascript
import express from "express";
import crypto from "crypto";

const app = express();
const SECRET = process.env.CAIBO_WEBHOOK_SECRET;

function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // replay guard
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "", "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// IMPORTANT: use the RAW body for verification, not a parsed object.
app.post("/caibo/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verify(SECRET, req.get("Caibo-Signature") ?? "", raw)) {
    return res.status(400).send("invalid signature");
  }
  const event = JSON.parse(raw);
  // ... handle event (idempotently) ...
  res.sendStatus(200);
});

Python (Flask)

python
import hmac, hashlib, time
from flask import Flask, request

app = Flask(__name__)
SECRET = "whsec_..."

def verify(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if not t or abs(time.time() - t) > 300:  # replay guard
        return False
    message = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

@app.post("/caibo/webhook")
def webhook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    if not verify(SECRET, request.headers.get("Caibo-Signature", ""), raw):
        return "invalid signature", 400
    event = request.get_json()
    # ... handle event (idempotently) ...
    return "", 200

Go

go
func verify(secret, header string, body []byte) bool {
	var t int64
	var sig string
	for _, part := range strings.Split(header, ",") {
		kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
		if len(kv) != 2 {
			continue
		}
		switch kv[0] {
		case "t":
			t, _ = strconv.ParseInt(kv[1], 10, 64)
		case "v1":
			sig = kv[1]
		}
	}
	if t == 0 || math.Abs(float64(time.Now().Unix()-t)) > 300 { // replay guard
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	fmt.Fprintf(mac, "%d.", t)
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(sig))
}

Responding

Return a 2xx status as soon as you've stored the event. Do the real work asynchronously — don't block the response on slow downstream calls. Any non-2xx response (or a timeout) is treated as a failed delivery and retried.

Retries & delivery

Delivery is at least once. If your endpoint doesn't return 2xx, Caibo retries with exponential backoff — after roughly 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours — for up to 5 attempts. After that the delivery is marked failed and won't be retried automatically.

Idempotency on your side

Because delivery is at-least-once, your endpoint may receive the same event more than once (for example, if your 2xx was lost on the way back). Make handling idempotent: dedupe on the pair of event + payout id, and treat an already-processed event as a no-op.

javascript
const key = `${event.event}:${event.id}`;   // e.g. "payout.completed:po_9f8e7d6c5b4a"
if (await alreadyProcessed(key)) return res.sendStatus(200);
await markProcessed(key);
await handle(event);

Best practices

  • Always verify the signature and the timestamp before acting on a payload.
  • Use the raw body for verification — re-serializing parsed JSON changes the bytes and breaks the HMAC.
  • Keep the secret secret. It's shown once at creation; store it in your secret manager. If it leaks, delete the endpoint and register a new one.
  • Subscribe narrowly. Only request the events you handle.
  • Return fast, process async. Acknowledge quickly, then do the work in a queue.
  • Don't trust payload contents alone. For anything critical, re-fetch the payout by id to confirm its current state.