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-webhookswith Bearer auth. Test:POST /api/merchant-webhooks/{id}?action=test. Response includesdata.secret(whsec_…) once; store it immediately. - App UI: Sidebar → Payments → Get paid → Webhooks. 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
- Register an endpoint (Treasury UI, MCP
create_merchant_webhook, orPOST /api/merchant-webhooks). Store thewhsec_…secret (shown once). - Create a per-customer checkout:
POST https://fortfiapp.com/api/v1/checkout/sessions(MCPcreate_checkout_session). - Customer pays via checkout link or x402. FortFi settles on-chain and POSTs signed events to your URL.
- Provision on
payment_request.paid. Dedupe onfortfi-webhook-id(event id).
Event types
| Event | When |
|---|---|
| payment_request.created | Checkout session minted (open) |
| payment_request.paid | Fully settled. Provision here. |
| payment_request.payment_failed | Payment rejected (e.g. expired invoice) |
| payment_request.expired | TTL passed unpaid |
| webhook.test | You 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 onfortfi-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 Gonestops retries. - Dead-letter rows are kept when the retry budget is exhausted (not deleted).
- Manual replay:
POST /api/merchant-webhooks/deliveries/{id}?action=replayre-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
checkoutSessionIdand 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.