Webhooks
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 { FloraClient } from "@flora-ai/flora"
const client = new FloraClient({ 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({ runId: 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 secrets (whsec_…) are shown once, in the API Key Created dialog, when you create an API key. Each workspace has its own secret, and the dialog lists one for every workspace the key covers; a delivery is signed with the secret of the workspace its run belongs to. Store them like any other secret (e.g. FLORA_WEBHOOK_SECRET, one per workspace).
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 { FloraClient } from "@flora-ai/flora"
const client = new FloraClient()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.