API

Webhooks

FomoPay POSTs a signed event to your endpoint when a payment is created, submitted, confirmed or fails — and when a vault runs low. Verify the signature, answer 2xx quickly, and dedupe on the event id.

Events

TypeWhen
payment.createdA payment was recorded and the recipient snapshot taken.
payment.submittedThe transaction was broadcast to Robinhood Chain.
payment.confirmedConfirmed on-chain and verified by FomoPay. The receipt is final.
payment.failedReverted, dropped, replaced, or failed verification.
recipient.unresolvedA handle couldn't be resolved to a verified payout wallet.
vault.low_balanceA reward vault fell below its low-balance threshold.

Subscribe to specific types or "*" (every event, including types added later). Events belong to a project: payments made with that project's keys, vaults and batches.

payment.confirmed
{
  "id": "evt_5c7e9g1j3m5p7r9t1v3x",
  "type": "payment.confirmed",
  "created_at": "2026-10-12T18:04:19.000Z",
  "data": {
    "payment_id": "pay_8k2m4q7r9t1v3x5z6b0c",
    "status": "CONFIRMED",
    "type": "REWARD",
    "source": "vault",
    "recipient": "@lyra",
    "recipient_profile_id": "fp_2b4d6f8h0k2m4p6r8t0v",
    "sandbox": false,
    "amount": "500",
    "asset": "USDG",
    "network": "robinhood",
    "chain_id": 4663,
    "tx": "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2",
    "explorer_url": "https://robinhoodchain.blockscout.com/tx/0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2",
    "memo": null,
    "reason": "FomoRun #42",
    "metadata": {},
    "idempotency_key": "FOMORUN_42_WINNER",
    "receipt_number": "FP-2026-000042",
    "receipt_url": "https://fomopay.family/payment/pay_8k2m4q7r9t1v3x5z6b0c",
    "error": null,
    "bulk_batch_id": null,
    "vault_id": "vault_3n5p7r9t1v3x5z7b9d1f"
  }
}

Every payload has the envelope { id, type, created_at, data }. Payment events carry the recipient handle, amount, asset, network, tx (the transaction hash once submitted), your metadata and idempotency_key, and the receipt. They never contain the recipient's wallet address.

Signatures

Delivery headers
POST /fomopay/webhooks HTTP/1.1
Content-Type: application/json
FomoPay-Event-Id: evt_5c7e9g1j3m5p7r9t1v3x
FomoPay-Delivery-Id: whd_0b2d4f6h8k0m2p4r6t8v
FomoPay-Timestamp: 1791828259
FomoPay-Signature: t=1791828259,v1=6f1d0c…e93a
  • FomoPay-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. The HMAC key is your endpoint secret exactly as shown (whsec_…, as UTF-8).
  • During a secret rotation the header carries several v1= entries — one per active secret. Accept the delivery if any of them matches.
  • Reject deliveries whose t is more than 5 minutes from your clock: that is what makes a captured request useless for replay.
  • Sign and verify the raw bytes of the body. Parsing and re-serializing JSON changes whitespace and key order and the signature won't match.

Verify with the SDK

import http from "node:http";
import { FomoPay, FomoPayError } from "@fomopay/sdk";

const secret = process.env.FOMOPAY_WEBHOOK_SECRET!; // whsec_…

http.createServer(async (req, res) => {
  // Read the RAW body — re-serialized JSON would not match the signature.
  const chunks: Buffer[] = [];
  for await (const c of req) chunks.push(c as Buffer);
  const raw = Buffer.concat(chunks);

  try {
    const event = await FomoPay.webhooks.verify(raw, req.headers, secret);
    if (event.type === "payment.confirmed") {
      // event.data.recipient === "@lyra", event.data.tx is the transaction hash
    }
    res.writeHead(200).end("ok"); // answer 2xx within 10 s; do slow work after
  } catch (e) {
    const code = e instanceof FomoPayError ? e.code : "WEBHOOK_SIGNATURE_INVALID";
    res.writeHead(400).end(code);
  }
}).listen(4242);

FomoPay.webhooks.verify(rawBody, headers, secret) is static (no API key needed), uses Web Crypto so it runs on Node, Bun, Deno and edge runtimes, accepts a fetch Headers or Node's req.headers, and returns the parsed event — or throws WEBHOOK_SIGNATURE_INVALID / WEBHOOK_TIMESTAMP_EXPIRED.

Verify by hand

verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";

// What FomoPay.webhooks.verify does, by hand.
export function verifyFomoPaySignature(raw: string, header: string, secret: string, toleranceSec = 300) {
  const parts = header.split(",").map((p) => p.trim().split("=") as [string, string]);
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex");
  return signatures.some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

Delivery and retries

A delivery succeeds when your endpoint answers 2xx within 10 seconds. Anything else — a non-2xx status, a timeout, a connection error — is retried on this schedule, then marked EXHAUSTED:

attempt 1+30 s+2 min+10 min+30 min+2 h+6 h+12 h+24 hEXHAUSTED

That is 9 attempts over roughly 45 hours. Every attempt — status, latency, a short response snippet — is in the delivery log under Dashboard → Webhooks, where you can also replay a delivery or send a test event (test events carry test: true and never include a real transaction).

Redirects are not followed. On mainnet, endpoint URLs must be https and resolve to public addresses — private, loopback and cloud-metadata addresses are refused when you save the endpoint and again at connect time. Locally, http://localhost receivers are allowed for testing.

Answer fast, work later

Return 2xx as soon as the signature checks out and the event is stored; do slow work (emails, game state) asynchronously. Events can arrive more than once and, rarely, out of order — use data.status and created_at, not arrival order.

dedupe.ts
// Deliveries are at-least-once: the same event can arrive more than once.
const fresh = await db.processedEvents.insertIfAbsent(event.id); // unique on event id
if (!fresh) return new Response("duplicate", { status: 200 });

Manage endpoints

POST/v1/webhooksscope webhooks:write
const endpoint = await fp.webhooks.create({
  url: "https://game.example.com/fomopay/webhooks",
  events: ["payment.confirmed", "payment.failed", "vault.low_balance"],
  description: "FomoRun payouts",
});

endpoint.secret; // "whsec_…" — store it as FOMOPAY_WEBHOOK_SECRET
201 Created
{
  "id": "we_3m5p7r9t1v3x5z7b9d1f",
  "object": "webhook_endpoint",
  "url": "https://game.example.com/fomopay/webhooks",
  "description": "FomoRun payouts",
  "events": ["payment.confirmed", "payment.failed", "vault.low_balance"],
  "status": "ENABLED",
  "secret_version": 1,
  "previous_secret_expires_at": null,
  "secret": "whsec_…",
  "created_at": "2026-10-12T18:00:00.000Z",
  "updated_at": "2026-10-12T18:00:00.000Z",
  "health": { "success_rate": null, "delivered_7d": 0, "failed_7d": 0, "last_attempt_at": null, "last_response_status": null }
}
EndpointDoes
GET /v1/webhooksList this project's endpoints (no secrets).
GET /v1/webhooks/:idOne endpoint with delivery health.
PATCH /v1/webhooks/:idChange url, events, description; status DISABLED pauses deliveries.
DELETE /v1/webhooks/:idRemove the endpoint.
POST /v1/webhooks/:id/rotateNew secret. The old one keeps signing alongside it for grace_period_hours (default 24, max 168).
TypeScript
await fp.webhooks.list();
await fp.webhooks.update("we_3m5p…", { status: "DISABLED" }); // pause deliveries
await fp.webhooks.rotate("we_3m5p…", { gracePeriodHours: 24 }); // new secret; old one keeps signing for 24 h
await fp.webhooks.delete("we_3m5p…");

Up to 16 endpoints per project. Secrets are derived on demand from a server key and never stored; the secret is returned on create and rotate, and project members who manage webhooks can reveal it again in the dashboard.