Developers

Payment webhooks

Signed HTTPS POSTs when payments move through their lifecycle. Built for platforms embedding FortFi: per-customer vaults, buyer identity in every payload, HMAC verification with FortFi headers. REST API reference: OpenAPI docs.

Register an endpoint

Three ways, all requiring payment_requests:write on your API key. The signing secret (whsec_…) is shown once at creation; store it immediately.

  • REST: POST /api/merchant-webhooks with Bearer auth. Test: POST /api/merchant-webhooks/{id}?action=test. Response includes data.secret (whsec_…) once; store it immediately.
  • App UI: Sidebar → Payments Get paidWebhooks. Subscribe to lifecycle events and send a test delivery.
  • MCP: create_merchant_webhook, send_merchant_webhook_test, list_merchant_webhooks.
curl -X POST https://fortfiapp.com/api/merchant-webhooks \
  -H "Authorization: Bearer frtfi_hu_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/fortfi",
    "events": [
      "payment_request.created",
      "payment_request.paid",
      "payment_request.payment_failed",
      "payment_request.expired"
    ],
    "description": "Production"
  }'

HTTPS required. Production webhook URLs must be public https:// endpoints (no private IPs). Local dev against localhost allows http://127.0.0.1 for smoke tests only.

Default if events omitted: payment_request.paid only. maxAttempts caps automatic retries (default 10, ~72h window).

Quick start

  1. Register an endpoint (Treasury UI, MCP create_merchant_webhook, or POST /api/merchant-webhooks). Store the whsec_… secret (shown once).
  2. Create a per-customer checkout: POST https://fortfiapp.com/api/v1/checkout/sessions (MCP create_checkout_session).
  3. Customer pays via checkout link or x402. FortFi settles on-chain and POSTs signed events to your URL.
  4. Provision on payment_request.paid. Dedupe on fortfi-webhook-id (event id).

Event types

EventWhen
payment_request.createdCheckout session minted (open)
payment_request.paidFully settled. Provision here.
payment_request.payment_failedPayment rejected (e.g. expired invoice)
payment_request.expiredTTL passed unpaid
webhook.testYou pressed Send test

Headers & verification

Every delivery includes FortFi headers only. Signature = HMAC-SHA256 over "{timestamp}.{rawBody}" with your whsec_… secret.

  • fortfi-webhook-id: event id (dedupe key)
  • fortfi-webhook-timestamp: unix seconds
  • fortfi-webhook-event: event type
  • fortfi-webhook-signature: v1=<hex>

5-minute tolerance applies only when you verify a delivery. It rejects replay attacks. It is unrelated to how long FortFi retries if your server is down (see Delivery guarantees).

Node.js

const crypto = require("crypto");

function verifyFortFiWebhook(rawBody, headers, secret) {
  const ts = headers["fortfi-webhook-timestamp"];
  const sig = headers["fortfi-webhook-signature"].replace("v1=", "");
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"));
}

Python

import hmac, hashlib, time

def verify_fortfi_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
    ts = headers["fortfi-webhook-timestamp"]
    sig = headers["fortfi-webhook-signature"].removeprefix("v1=")
    if abs(time.time() - int(ts)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, expected)

Rust

// Cargo.toml: hmac = "0.12", sha2 = "0.10", hex = "0.4"
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};

type HmacSha256 = Hmac<Sha256>;

fn verify_fortfi_webhook(raw_body: &[u8], ts: &str, sig_header: &str, secret: &str) -> bool {
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
    let ts_i = ts.parse::<i64>().unwrap_or(0);
    if (now - ts_i).abs() > 300 {
        return false;
    }
    let sig = sig_header.strip_prefix("v1=").unwrap_or(sig_header);
    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key");
    mac.update(format!("{ts}.").as_bytes());
    mac.update(raw_body);
    let expected = hex::encode(mac.finalize().into_bytes());
    sig.len() == expected.len()
        && sig.bytes().zip(expected.bytes()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
}

Webhook payload schema

Canonical event envelope, pinned at api_version = 2026-08-19. Resource fields live under data.object.

{
  "id": "evt_…",
  "object": "event",
  "type": "payment_request.paid",
  "created": 1755635523,
  "livemode": true,
  "api_version": "2026-08-19",
  "data": {
    "object": {
      "paymentRequestPid": "…",
      "status": "paid",
      "amountUsd": "24.00",
      "amountSmallestUnit": "24000000",
      "assetSymbol": "USDC",
      "tokenDecimals": 6,
      "chainId": "base-mainnet",
      "payToAddress": "0x…",
      "vaultId": "customer-vault-uuid",
      "customer": { "externalId": "your-customer-42", "namespace": "default" },
      "checkoutSessionId": "cs_…",
      "product": { "slug": "gpu-small", "name": "GPU Small VM", "externalSkuId": "sku_…" },
      "externalReference": "invoice-8841",
      "metadata": { "region": "eu-west" },
      "expiresAt": "2026-08-19T20:00:00.000Z",
      "requestCreatedAt": "2026-08-19T19:00:00.000Z",
      "paidAt": "2026-08-19T19:12:03.000Z",
      "txHash": "0x…",
      "method": "x402",
      "payer": { "address": "0x…", "method": "x402" },
      "settlement": { "txHash": "0x…", "settledAt": "2026-08-19T19:12:03.000Z", "method": "x402" }
    }
  }
}

amountUsd is the human-readable USD amount (e.g. "25.00"). For USDC, 1 USDC = $1.00 USD. Use it directly for billing logic. amountSmallestUnit is the on-chain base unit (6 decimals for USDC) for audit and chain tooling.

payment_request.created adds checkoutUrl. payment_request.payment_failed adds failure: { reason, method }.

Checkout sessions (per-customer identity)

POST https://fortfiapp.com/api/v1/checkout/sessions resolves your customer.externalId to a pre-provisioned governed vault, mints a payment request on that vault, and stamps identity into metadata. Provision customer vaults first via bulk provisioning or create_provisioning_job. Every webhook carries customer.externalId, vaultId, and checkoutSessionId. No chain parsing required to know who paid.

Poll fallback: GET https://fortfiapp.com/api/v1/payment-requests/{pid} returns effective status (open, paid, expired, …) and settlement rows.

Delivery guarantees

  • At-least-once delivery. Handlers must dedupe on event id.
  • 5-minute signature tolerance ≠ retry window. Every POST (including retries after hours of downtime) gets a fresh fortfi-webhook-timestamp. The 300s check blocks replay of a captured request; it does not expire the event. Dedupe on fortfi-webhook-id.
  • Retries: inline first attempt, then ~30s, 2m, 10m, 1h, 6h, 12h, 24h, 24h, 24h (~72h+ window, 10 attempts total). ±20% jitter. 2xx = success; 410 Gone stops retries.
  • Dead-letter rows are kept when the retry budget is exhausted (not deleted).
  • Manual replay: POST /api/merchant-webhooks/deliveries/{id}?action=replay re-signs the stored payload and re-delivers it. Use after fixing your handler.
  • Delivery log: GET /api/merchant-webhooks/deliveries?status=failed.
  • Paid checkout sessions reject further payment once settled. Dedupe credits on checkoutSessionId and event id in your handler.

Direct USDC send (checkout sessions)

x402 and FortFi checkout links are the primary paths. For checkout sessions, FortFi can also reconcile inbound USDC sent directly to the invoice address, but only when matching is unambiguous:

  • One open invoice on that customer vault: the only candidate.
  • Unique amount: multiple open invoices, but only one matches the inbound transfer amount.

If two open invoices share the same amount, direct send is skipped (no guesswork). When reconciliation succeeds, payment_request.paid fires the same way as x402 settlement. x402 remains the primary path.

Payment webhooks