FFiber Offers
Documentation / Build

Merchant operations

Merchants publish one signed offer, then use resolutions, webhooks, receipts, and reconciliation records to operate the many payment attempts that offer may produce.

Merchant checkout model

For a fixed-price checkout, create an offer with pricing_type: "fixed" and one amount. Share its payment link or link QR at checkout. The merchant does not need to generate a one-time invoice before the payer begins.

POST /demo/offers

{
  "username": "coffee",
  "description": "Coffee checkout",
  "pricing_type": "fixed",
  "amount": "1000",
  "assets": [{ "asset_type": "ckb", "symbol": "CKB" }]
}

Scenario: a physical checkout counter

The merchant displays the stable payment link QR. Every customer scans the same code, selects the expected amount in their wallet, and receives a fresh invoice only after readiness succeeds. The merchant tracks individual resolution IDs, not a constantly replaced QR.

Resolution lifecycle

Every successful invoice request creates one resolution. A resolution records the amount, asset, invoice data, history, settlement metadata, and associated webhook events.

StatusMeaningMerchant action
invoice_createdThe resolver issued an invoice.Await payer and payment status.
invoice_receivedThe invoice lifecycle advanced at its source.Keep waiting or show pending confirmation.
invoice_paidPayment was settled.Fulfill the order and persist the receipt.
invoice_expired, invoice_failed, invoice_cancelledThe attempt is terminal without settlement.Keep the record; allow a new valid payment attempt where appropriate.
GET  /offers/:offer_id/resolutions
GET  /offers/:offer_id/resolutions/:resolution_id
POST /offers/:offer_id/resolutions/:resolution_id/sync

Subscribe to webhooks

Register an HTTPS endpoint per offer. An omitted event list subscribes to all supported invoice events. The resolver generates a high-entropy signing secret and returns it only in the creation response; store it immediately in your secret manager.

curl -X POST https://resolver.example/offers/0x.../webhooks \
  -H 'content-type: application/json' \
  -H 'Authorization: Bearer change-me' \
  -d '{
    "url": "https://merchant.example/webhooks/fiber",
    "events": ["invoice.paid", "invoice.failed"]
  }'
{
  "id": "wh_...",
  "url": "https://merchant.example/webhooks/fiber",
  "events": ["invoice.paid", "invoice.failed"],
  "disabled": false,
  "secret_hint": "whsec...9Kp2",
  "signing_secret": "whsec_..."
}

Each POST includes x-fiber-offers-event-id, x-fiber-offers-delivery-id, x-fiber-offers-event-type, x-fiber-offers-timestamp, and x-fiber-offers-signature. A successful 2xx response marks the attempt delivered.

Manage endpoints

OperationEndpointEffect
ListGET /offers/:offer_id/webhooksReturns URLs, event filters, status, secret hints, and timestamps. Secrets are never returned.
TestPOST /offers/:offer_id/webhooks/:webhook_id/testSends a signed webhook.test event to one active endpoint.
Pause or resumePATCH /offers/:offer_id/webhooks/:webhook_idSet { "disabled": true } to stop future events without losing configuration or history.
Rotate secretPOST /offers/:offer_id/webhooks/:webhook_id/rotate-secretInvalidates the old secret immediately and returns the replacement once.
DeleteDELETE /offers/:offer_id/webhooks/:webhook_idPermanently removes the subscription. Historical delivery records remain.

The local console defaults to /demo/webhook-receiver. It is an inspectable development inbox, not a production receiver: use GET /demo/webhook-receiver to see what arrived, but deploy your own HTTPS endpoint with signature verification for real payments.

Receive and verify events

The signature is sha256=HMAC(secret, timestamp + "." + rawBody). Read the untouched request bytes, reject stale timestamps, compare signatures safely, and deduplicate by event ID before queuing work.

import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const secret = process.env.FIBER_OFFERS_WEBHOOK_SECRET;
const processedEventIds = new Set(); // Use a database uniqueness constraint in production.

createServer(async (request, response) => {
  if (request.method !== "POST" || request.url !== "/webhooks/fiber") {
    response.writeHead(404).end();
    return;
  }

  const chunks = [];
  for await (const chunk of request) chunks.push(chunk);
  const rawBody = Buffer.concat(chunks);
  const timestamp = request.headers["x-fiber-offers-timestamp"];
  const signature = request.headers["x-fiber-offers-signature"];

  if (!verifySignature(secret, timestamp, rawBody, signature)) {
    response.writeHead(401).end();
    return;
  }

  const event = JSON.parse(rawBody.toString("utf8"));
  if (processedEventIds.has(event.id)) {
    response.writeHead(204).end();
    return;
  }
  processedEventIds.add(event.id);

  response.writeHead(204).end(); // Acknowledge before fulfillment work.
  queueMicrotask(() => handleEvent(event));
}).listen(3000);

function verifySignature(secret, timestamp, rawBody, signature) {
  if (!secret || !timestamp || !signature) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = `sha256=${createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex")}`;
  const expectedBytes = Buffer.from(expected);
  const receivedBytes = Buffer.from(signature);
  return expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes);
}

async function handleEvent(event) {
  if (event.type === "invoice.paid") {
    // Fulfill once using event.id or payload.resolution.id as an idempotency key.
  }
}
Delivery semanticsEvents can be duplicated, delayed, or arrive out of order. Treat the webhook as a notification, make fulfillment idempotent, and query the resolution when current state matters.

Receipts and reconciliation

GET /offers/:offer_id/resolutions/:resolution_id/receipt.json
GET /offers/:offer_id/reconciliation.json
GET /offers/:offer_id/reconciliation.csv

Use the receipt as the individual settlement record and the reconciliation export for accounting, support, or close-of-day reporting. The output deliberately keeps offer ID, resolution ID, status, amount, asset, and settlement context together.

Scenario: fulfillment after webhook delay

A merchant receives a successful webhook but its order system is temporarily unavailable. The webhook is redelivered or the merchant calls the resolution endpoint and receipt endpoint later. The resolution ID gives both systems a stable reference for idempotent fulfillment.

Manual and background delivery

During development, operators can inspect and deliver the outbox manually. In a live deployment, enable workers to poll invoice settlement state and retry pending webhook deliveries.

POST /offers/:offer_id/webhook-events/deliver
POST /offers/:offer_id/webhook-events/:event_id/deliver

RESOLVER_WORKERS_ENABLED=true \
RESOLVER_PUBLIC_URL=https://resolver.example \
FIBER_RPC_URL=http://127.0.0.1:8227 \
npm run dev