Skip to content
FLORA DocsGo to app
Platform

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.

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.

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.

Terminal window
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"
}'

A run delivers exactly one terminal event:

EventWhen
run.completedThe run finished successfully.
run.failedThe run terminated with an error.

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);
HeaderExamplePurpose
Flora-Signaturet=1733952000,v1=9f86d0…Timestamp + HMAC-SHA256 hex of the body.
Flora-Webhook-Idwhd_abc123Stable delivery id (mirrors id).
Flora-Eventrun.completedEvent 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.

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).

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.

No SDK? Recompute the HMAC yourself and compare in constant time:

import hashlib, hmac, os, time
from 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 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:

AttemptWait before it
retry 110s
retry 21m
retry 35m

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.

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);
  • 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 3xx responses, 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.
  • 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.