Webhooks
Receive a signed callback when a FLORA run finishes, and verify it before you trust it.
Polling a run until it finishes works, but it’s wasteful. Webhooks flip it around: you give FLORA an HTTPS URL, and when a run reaches a terminal state, FLORA POSTs a signed JSON payload to it — once, as soon as the run is done.
Every delivery is HMAC-SHA256 signed so you can prove it came from FLORA and wasn’t tampered with. Verify the signature before acting on a webhook.
Registering a callback
Section titled “Registering a callback”Opt in per run by passing callback_url on any run-creating request. The URL must be HTTPS and must not resolve to a private/internal host.
TypeScript
Section titled “TypeScript”import Flora from '@flora-ai/flora';
const client = new Flora({ apiKey: process.env['FLORA_API_KEY'] });
const run = await client.generations.create({ // …your generation inputs… callback_url: 'https://api.example.com/flora-webhook',});callback_url is accepted on POST /generate and POST /techniques/{techniqueId}/runs.
curl -X POST https://app.flora.ai/api/v1/techniques/thumbnail-v3/runs \ -H "Authorization: Bearer $FLORA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "inputs": [...], "mode": "async", "callback_url": "https://api.example.com/flora-webhook" }'Events
Section titled “Events”A run delivers exactly one terminal event:
| Event | When |
|---|---|
run.completed | The run finished successfully. |
run.failed | The run terminated with an error. |
Payload
Section titled “Payload”Content-Type: application/json. The body is byte-stable across retries, so a single recorded signature stays valid:
{ "id": "whd_abc123", // unique per delivery — dedupe on this "type": "run.completed", // or "run.failed" "api_version": "2026-06-11", // payload contract version (date-based) "created_at": 1733952000000, // unix milliseconds "data": { "run_id": "run_abc", // public, prefixed run id "run_type": "generation", // or "technique" "workspace_id": "ws_abc", "status": "completed", // or "failed" "error_code": "…", // present on failure "error_message": "…", // present on failure, when available },}The payload carries the run’s identity and terminal status, not its outputs. Fetch the full result from the run endpoint you’d otherwise poll, keyed by data.run_id:
const result = await client.generations.retrieve(event.data.run_id);Headers
Section titled “Headers”| Header | Example | Purpose |
|---|---|---|
Flora-Signature | t=1733952000,v1=9f86d0… | Timestamp + HMAC-SHA256 hex of the body. |
Flora-Webhook-Id | whd_abc123 | Stable delivery id (mirrors id). |
Flora-Event | run.completed | Event type (mirrors type). |
The signed message is `${t}.${rawBody}` — the unix-seconds timestamp, a literal ., then the exact raw request body. Binding the timestamp into the signature is what lets you reject replays.
Verifying a webhook
Section titled “Verifying a webhook”Your signing secret (whsec_…) is shown once, in the API Key Created dialog, when you create an API key. It’s per workspace — one secret verifies every delivery. Store it like any other secret (e.g. FLORA_WEBHOOK_SECRET).
TypeScript (SDK)
Section titled “TypeScript (SDK)”The SDK does the signature check, the constant-time compare, and the replay-window check for you, then returns the parsed, typed event:
import express from 'express';import Flora from '@flora-ai/flora';
const client = new Flora();const app = express();
// Capture the raw body — do not use express.json() on this route.app.post( '/flora-webhook', express.raw({ type: 'application/json' }), async (req, res) => { let event; try { event = await client.webhooks.unwrap( req.body, req.headers, process.env.FLORA_WEBHOOK_SECRET, ); } catch (err) { // Signature invalid, body tampered, or timestamp outside the replay window. return res.status(400).send('invalid signature'); }
if (event.type === 'run.completed') { // event.data.run_id, event.data.workspace_id, … }
res.sendStatus(200); // ack with 2xx, or FLORA retries },);unwrap(body, headers, secret, options?) accepts a Headers instance, a plain headers record, or the raw Flora-Signature string; a string or bytes body; and throws WebhookVerificationError on any failure. The replay window defaults to 5 minutes — pass { toleranceSeconds: 0 } to disable it.
Python (manual)
Section titled “Python (manual)”No SDK? Recompute the HMAC yourself and compare in constant time:
import hashlib, hmac, os, timefrom flask import request, abort
@app.post("/flora-webhook")def flora_webhook(): raw = request.get_data() # raw bytes — not request.json header = request.headers.get("Flora-Signature", "") parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv) t, sig = parts.get("t", ""), parts.get("v1", "")
expected = hmac.new( os.environ["FLORA_WEBHOOK_SECRET"].encode(), f"{t}.{raw.decode()}".encode(), hashlib.sha256, ).hexdigest()
if not hmac.compare_digest(sig, expected) or abs(time.time() - int(t)) > 300: abort(400)
event = request.get_json() return "", 200 # ack with 2xx, or FLORA retriesDelivery and retries
Section titled “Delivery and retries”A delivery is POST-only with a 10s timeout. A non-2xx response, a network error, or a timeout counts as a failed attempt. FLORA retries up to 3 times after the first attempt (4 deliveries max) on a fixed exponential schedule:
| Attempt | Wait before it |
|---|---|
| retry 1 | 10s |
| retry 2 | 1m |
| retry 3 | 5m |
After the last retry fails, the delivery is marked terminally failed (and shown as such in the Usage tab). Respond 2xx quickly and do any heavy processing asynchronously — a slow handler eats into the timeout.
Idempotency
Section titled “Idempotency”Deliveries are at-least-once: a retry can arrive after your handler already processed the first attempt (e.g. it succeeded but your 2xx was slow). Dedupe on the delivery id (also in Flora-Webhook-Id) so repeat deliveries are no-ops.
if (await alreadyProcessed(event.id)) return res.sendStatus(200);await markProcessed(event.id);Security
Section titled “Security”- HTTPS only. Plain
http://URLs are rejected at registration. - No internal targets. URLs that point at
localhost,*.local/*.internal, cloud-metadata hostnames, or private/loopback/link-local IP literals (IPv4 and IPv6) are rejected. - Manual redirects. FLORA does not follow
3xxresponses, so a redirect can’t bounce a delivery to an internal host. - Always verify the signature. The URL alone is not a secret; the signature is what proves authenticity.
Related
Section titled “Related”- Authentication — where the
whsec_…signing secret is revealed. - Idempotency — making your own retries safe on the way in.
- Errors — the error body referenced by
error_code/error_message.