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.
| Event | Fires when | data |
|---|---|---|
balance.low | Your 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.completed | Your user funded their wallet, on the verified payment and never on a redirect. | userId, balanceMicros, availableMicros, currency |
connection.created | One 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}/webhook | Read 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/secret | Rotate the secret. |
DELETE /apps/{appId}/webhook | Stop 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:
- Parse
tandv1out ofx-fleeex-signature. Tolerate unknown elements and any order. - 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. - Compare in constant time.
- Only then check freshness: reject if
tis more than 300 seconds (5 minutes) from now. - 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.
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
2xxas 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-attempttells 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}/webhookthen showsstatus: "disabled"withdisabledReason: "delivery_failures". Fix the endpoint andPUTthe 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.