Fleeexdocs

Webhooks

Be told before your user is cut off. The three events, the HMAC signature scheme in enough detail to implement anywhere, and the retry contract.

Polling getBalance() puts the burden on you and still misses the moment: the request that runs out is the one that fails. Register one https endpoint for your app instead, and Fleeex calls you.

The events

Three types, and you subscribe explicitly. A type Fleeex publishes later is never sent to an endpoint that didn't ask for it.

EventFires whendata
balance.lowYour user's spendable balance crosses below the threshold you set, once per crossing rather than on every request underneath it.userId, availableMicros, thresholdMicros, currency
topup.completedYour user funded their wallet, on the verified payment and never on a redirect.userId, balanceMicros, availableMicros, currency
connection.createdOne of your users connected their wallet to your app for the first time.userId, connectedAt

balance.low watches spendable funds rather than the gross balance: what matters is what the next request can actually spend, and a large in-flight stream genuinely makes funds unavailable. A consequence worth expecting: opening a big stream can cross the threshold and the settle can cross back up, which is two truthful notifications rather than one.

connection.created fires on a first authorization. A user who disconnects and reconnects does not re-fire it. And topup.completed fires on money in only: a refund or a chargeback also moves the wallet, but telling you "your user topped up" when their card was clawed back would be a lie.

Subscribe

From the Fleeex dashboard, or with your account session:

PUT /apps/{appId}/webhook
 
{ "endpointUrl": "https://api.example.com/hooks/fleeex",
  "events": ["balance.low", "topup.completed"],
  "lowBalanceThresholdMicros": 2000000 }

lowBalanceThresholdMicros is required when balance.low is subscribed, and refused otherwise. The endpoint must be https and publicly routable, with no credentials in the URL, no localhost, and no private or link-local address. Use a tunnel in development.

The response of the call that creates the subscription contains signingSecret (whsec_…), shown once. Store it where your server can read it.

Route
GET /apps/{appId}/webhookRead it back, including status, and secretFingerprint, a non-secret handle that tells you which secret your endpoint should be verifying with.
POST /apps/{appId}/webhook/secretRotate the secret.
DELETE /apps/{appId}/webhookStop deliveries at once and destroy the secret.

Rotation has no overlap window. There is exactly one active secret, and the previous one stops verifying the moment the new one is returned. So deploy the new secret first, then rotate.

The delivery

POST /hooks/fleeex
content-type: application/json
user-agent: fleeex-webhooks/1
x-fleeex-event-id: evt_9f2c1ab04d7e5688c3a1bd40f7e21c93
x-fleeex-event-type: balance.low
x-fleeex-delivery-attempt: 1
x-fleeex-signature: t=1785240000,v1=6f1c…64 lowercase hex chars…
 
{"id":"evt_9f2c…","type":"balance.low","createdAt":"2026-07-28T11:30:00.000Z",
 "appId":"…","data":{"userId":"your-own-user-id","availableMicros":1500000,
 "thresholdMicros":2000000,"currency":"EUR"}}

userId is the id you vouched for in x-fleeex-user. A payload never contains anything you couldn't already read for that user with your own key: no prompt, no completion, no model id, no API key, no email, no amount paid, and no Fleeex-internal identity id. That last one is shared across every app the person connected, so handing it out would let two apps discover they serve the same human.

Verify the signature

Implementable in any language. The scheme:

v1 = HMAC_SHA256( key = utf8(your signing secret, "whsec_" included),
                  msg = utf8( t + "." + <raw request body> ) )

In this order:

  1. Parse t and v1 out of x-fleeex-signature. Tolerate unknown elements and any order.
  2. Recompute the HMAC over `${t}.${rawBody}` using the raw bytes of the request. If your framework parses JSON before you see it, keep the raw body, because re-serializing the parsed object gives a different digest.
  3. Compare in constant time.
  4. Only then check freshness: reject if t is more than 300 seconds (5 minutes) from now.
  5. Deduplicate on the event id.

The order of 3 and 4 is deliberate: nothing in the signed material is trusted before it has been authenticated, so a forged header with a plausible timestamp is reported as a forgery. And the timestamp is inside the signed material, so it can't be moved forward to defeat the freshness check without breaking the signature, which is what makes it a replay bound rather than decoration.

verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";
 
export function verifyFleeexWebhook(
  rawBody: string,
  header: string,
  secret: string,
): boolean {
  const parts = new Map(
    header.split(",").map((el) => {
      const i = el.indexOf("=");
      return [el.slice(0, i).trim(), el.slice(i + 1).trim()] as const;
    }),
  );
  const t = parts.get("t");
  const v1 = parts.get("v1");
  if (!t || !v1) return false;
 
  const expected = Buffer.from(
    createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"),
  );
  const actual = Buffer.from(v1);
  if (expected.length !== actual.length) return false;
  if (!timingSafeEqual(expected, actual)) return false;
 
  // Signature first, freshness second.
  return Math.abs(Math.floor(Date.now() / 1000) - Number(t)) <= 300;
}

v1 is a version label: if Fleeex ever adds a second scheme it arrives as an extra v2= element, and code that checks only v1 keeps working.

Deduplicate on the event id

You will occasionally receive the same event twice, because delivery is at-least-once.

The id (also sent as x-fleeex-event-id) is derived from the state change rather than from the attempt, so a redelivery carries the same id and a byte-identical body; only the signature timestamp differs. createdAt is likewise the instant of the state change, not of the attempt.

Store the ids you have handled, treat a repeat as already done, and answer 2xx.

Retries, and what "we gave up" looks like

  • Answer 2xx as soon as you have durably accepted the event: put it on your own queue and do the work afterwards. Fleeex waits at most 5 seconds per attempt.
  • Any non-2xx, a timeout or a connection error is retried: six attempts in all, on a fixed backoff of 10s, 20s, 40s, 80s, 160s between them, so an event's last attempt lands about 5 minutes after its first. x-fleeex-delivery-attempt tells you which one you're looking at. There is no jitter, because retries are per-message, so the schedule is predictable on purpose.
  • Redirects are not followed. A redirect is a second URL nobody validated.
  • After the sixth attempt the event is dropped and never delivered. Nothing will re-deliver it later, so an endpoint that is down for an hour loses those events.
  • If events keep exhausting their retries, Fleeex disables your subscription and stops sending. GET /apps/{appId}/webhook then shows status: "disabled" with disabledReason: "delivery_failures". Fix the endpoint and PUT the subscription again to turn it back on, which is the same gesture as "I fixed it" and resets the counter. A successful delivery resets it too.

So webhooks are a timely signal, not a durable log. If a number has to be right, read it back with getBalance(). The event is what tells you when to look.

Not for sandbox traffic

A sandbox key touches no real wallet, so there is nothing to notify about and no webhook is ever produced for it. The exclusion is structural rather than a filter, which also means webhook handling is the one path you can't rehearse with a test key.