Skip to main content
Outbound webhooks are how Caratuva tells your ERP about every state change on a payment intent — buyer KYC approved, transfer confirmed, settled, failed. You register a URL and an event list once, then verify the HMAC signature on each delivery.

Subscribe

curl -X POST https://api.caratuva.com/v1/webhook-subscriptions \
  -H "X-API-Key: $CARATUVA_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "url": "https://erp.example.com/webhooks/caratuva",
    "eventTypes": [
      "payment_intent.created",
      "payment_intent.buyer_kyc_approved",
      "payment_intent.on_chain_confirmed",
      "payment_intent.settled",
      "payment_intent.failed",
      "payment_intent.cancelled",
      "payment_intent.expired"
    ]
  }'
Response (the secret field is the only place the signing secret ever appears):
{
  "id": "cksub_123...",
  "orgId": "ckorg_456...",
  "url": "https://erp.example.com/webhooks/caratuva",
  "eventTypes": ["payment_intent.created", "payment_intent.settled", "..."],
  "active": true,
  "createdAt": "2026-05-02T14:00:00.000Z",
  "updatedAt": "2026-05-02T14:00:00.000Z",
  "secret": "whsec_<base64>"
}
Store secret immediately — Caratuva encrypts it at rest and cannot return it again. If you lose it, rotate it.

Event types

Two parallel namespaces:
  • payment_intent.* — fires only for invoices created via POST /v1/payment-intents. Subscribe to these if you’re a B2B integrator. You will not see noise from any dashboard activity.
  • invoice.* — fires for every invoice (both API-created and dashboard-created). Subscribe to these only if you also operate the dashboard.
The full list of payment_intent.* events:
EventWhen
payment_intent.createdIntent created via API; buyer not yet engaged.
payment_intent.buyer_kyc_approvedBuyer’s KYC submission cleared identity verification.
payment_intent.buyer_kyc_rejectedKYC rejected. Intent terminates at failed.
payment_intent.on_chain_confirmedCross-border transfer confirmed on Caratuva’s settlement layer.
payment_intent.settledPIX payout completed; funds in the seller’s BRL account. Terminal happy state.
payment_intent.failedTerminal failure (any phase). data carries the reason.
payment_intent.cancelledCancelled via POST /v1/payment-intents/:id/cancel or by ops.
payment_intent.expiredexpiresAt reached without payment.

Delivery format

POST /webhooks/caratuva HTTP/1.1
Host: erp.example.com
Content-Type: application/json
X-Caratuva-Signature: t=1714658400,v1=8c9f...sha256hex
X-Caratuva-Delivery-Id: ckdel_789...
X-Caratuva-Event-Type: payment_intent.settled

{
  "event": "payment_intent.settled",
  "data": {
    "paymentIntentId": "ckabc123...",
    "externalId": "INV-2026-00042",
    "amount": "12500.00",
    "currency": "USD",
    "metadata": { "orderId": "42" }
  }
}
Endpoints must respond with a 2xx status within 10 seconds. Anything else is treated as a delivery failure.

Signature verification

The signature header is t=<unix_ts>,v1=<sha256_hex>. The signed payload is ${t}.${rawBody} (the raw HTTP body bytes, not a re-serialized JSON object). Always verify against the raw bytes — re-serializing changes whitespace and key order and breaks the MAC.

Steps

  1. Parse t and v1 from the X-Caratuva-Signature header.
  2. Reject the request if |now - t| > 300 (5-minute replay window).
  3. Compute expected = HMAC-SHA256(secret, "${t}.${rawBody}") and compare hex-encoded against v1 with a constant-time comparator.
  4. Use X-Caratuva-Delivery-Id as an idempotency key on your side — replays with the same id should be no-ops.

Node.js example

import { createHmac, timingSafeEqual } from 'node:crypto';
import express, { Request, Response } from 'express';

const SECRET = process.env.CARATUVA_WEBHOOK_SECRET!;
const REPLAY_WINDOW_SECONDS = 300;

const app = express();

// Capture raw body — express.json() throws away the bytes we need to verify.
app.post(
  '/webhooks/caratuva',
  express.raw({ type: 'application/json' }),
  (req: Request, res: Response) => {
    const header = req.header('x-caratuva-signature');
    if (!header) return res.status(401).json({ error: 'missing signature' });

    const parts = Object.fromEntries(
      header.split(',').map((p) => p.trim().split('=', 2)),
    ) as { t?: string; v1?: string };
    if (!parts.t || !parts.v1) return res.status(401).json({ error: 'malformed signature' });

    const t = Number(parts.t);
    if (!Number.isFinite(t)) return res.status(401).json({ error: 'malformed signature' });

    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - t) > REPLAY_WINDOW_SECONDS) {
      return res.status(401).json({ error: 'timestamp out of window' });
    }

    const rawBody = (req.body as Buffer).toString('utf8');
    const expected = createHmac('sha256', SECRET)
      .update(`${t}.${rawBody}`)
      .digest('hex');

    const a = Buffer.from(expected, 'hex');
    const b = Buffer.from(parts.v1, 'hex');
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return res.status(401).json({ error: 'bad signature' });
    }

    const event = JSON.parse(rawBody) as { event: string; data: unknown };
    // Idempotency: dedupe on x-caratuva-delivery-id before mutating state.
    handleEvent(req.header('x-caratuva-delivery-id')!, event);

    res.status(204).end();
  },
);

Python example

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

SECRET = os.environ["CARATUVA_WEBHOOK_SECRET"].encode()
REPLAY_WINDOW = 300

app = Flask(__name__)

@app.post("/webhooks/caratuva")
def handle():
    header = request.headers.get("X-Caratuva-Signature", "")
    parts = dict(p.strip().split("=", 1) for p in header.split(","))
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        abort(401)
    if abs(int(time.time()) - int(t)) > REPLAY_WINDOW:
        abort(401)

    raw = request.get_data()
    expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        abort(401)

    event = request.get_json(force=True)
    delivery_id = request.headers["X-Caratuva-Delivery-Id"]
    handle_event(delivery_id, event)
    return "", 204

Retries and backoff

Caratuva attempts immediate delivery, then retries on any non-2xx or transport error with exponential backoff:
AttemptDelay since previous
1(immediate)
230s
32m
410m
51h
66h
724h
After the seventh attempt the delivery flips to dead_letter and Caratuva stops retrying. Use GET /v1/webhook-subscriptions/:id/deliveries to inspect failed deliveries and replay them manually if needed.

List subscriptions

curl https://api.caratuva.com/v1/webhook-subscriptions \
  -H "X-API-Key: $CARATUVA_API_KEY"

Inspect deliveries

curl https://api.caratuva.com/v1/webhook-subscriptions/<id>/deliveries?limit=50 \
  -H "X-API-Key: $CARATUVA_API_KEY"
Each row carries attempt, status (pending | delivered | failed | dead_letter), the upstream responseStatus, and the next nextAttemptAt. In practice the values you’ll see today are pending, delivered, and dead_letter — a row stays pending between retries and flips to dead_letter after the final attempt; failed is reserved.

Rotate the secret

curl -X POST https://api.caratuva.com/v1/webhook-subscriptions/<id>/rotate-secret \
  -H "X-API-Key: $CARATUVA_API_KEY"
The response carries the new secret exactly once. Rotation is immediate and atomic — the API stores only one secret per subscription, so the old secret stops verifying on the very next delivery and there is no dual-secret overlap window. Deploy the new secret to your verifier first (or atomically with the rotate call); any in-flight or retried deliveries are re-signed with the new secret when they’re sent. Don’t configure your verifier to accept “either” secret expecting an overlap — there isn’t one.

Delete (deactivate)

curl -X DELETE https://api.caratuva.com/v1/webhook-subscriptions/<id> \
  -H "X-API-Key: $CARATUVA_API_KEY"
Soft-deletes the subscription (active=false). No further deliveries fire; historical deliveries rows remain queryable.