---
title: Webhooks | FLORA API
description: 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 `POST`s 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

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

```
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

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"
  }'
```

Different runs can point at different URLs — `callback_url` is per request, not a workspace-wide setting.

## 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

`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

| 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

Your signing secret (`whsec_…`) is shown once, in the **API Key Created** dialog, when you [create an API key](/platform/authentication/index.md). It’s per workspace — one secret verifies every delivery. Store it like any other secret (e.g. `FLORA_WEBHOOK_SECRET`).

Verify against the **raw request body bytes**. Parsing and re-serializing the JSON changes the bytes and the signature will no longer match.

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

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
```

## 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

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

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

## Related

- **[Authentication](/platform/authentication/index.md)** — where the `whsec_…` signing secret is revealed.
- **[Idempotency](/platform/idempotency/index.md)** — making your own retries safe on the way in.
- **[Errors](/platform/errors/index.md)** — the error body referenced by `error_code` / `error_message`.
