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
| Event | Fires when |
|---|---|
payout.created | A payout has been created and accepted. |
payout.processing | The payout is being broadcast on-chain. |
payout.completed | The payout settled. tx_hash is set. |
payout.failed | The broadcast failed; funds were refunded to your balance. |
payout.blocked | Screening 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.
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"]
}'{
"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:
{
"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:
| Header | Value |
|---|---|
Caibo-Event | The event name, e.g. payout.completed. |
Caibo-Signature | The 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:
- Read the raw request body — the exact bytes received, before any JSON parsing.
- Parse
tandv1from the header. - Reject replays: if
|now − t| > 5 minutes, discard the request. - Compute the expected HMAC and compare it to
v1in constant time.
Node.js (Express)
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)
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 "", 200Go
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.
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.