> For the complete documentation index, see [llms.txt](https://docs.flora.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.flora.ai/api/platform/webhooks.md).

# Webhooks

Get a signed callback from FLORA when a generation or Technique run finishes, and verify its signature before you act on it.

Instead of polling a run until it finishes, give FLORA an HTTPS URL. When the run reaches a terminal state, FLORA sends one signed JSON `POST` to that URL. Webhooks work for generations (`POST /generate`) and Technique runs (`POST /techniques/{slug}/runs`).

Every delivery is signed with HMAC-SHA256, so you can prove it came from FLORA and wasn't changed. Verify the signature before you act on it.

## Register a callback

Opt in per run by passing `callback_url` when you start it. The URL must use `https://`; a plain `http://` URL is rejected with `input_validation_error`.

{% tabs %}
{% tab title="TypeScript" %}

```ts
import { FloraClient } from "@flora-ai/flora"

const client = new FloraClient()

const run = await client.generations.create({
  type: "image",
  prompt: "A ceramic mug on a sunlit table",
  workspace_id: "ws_abc123",
  project_id: "prj_abc123",
  callback_url: "https://api.example.com/flora-webhook",
})
```

{% endtab %}

{% tab title="curl" %}

```bash
curl -X POST https://app.flora.ai/api/v1/techniques/your-technique-slug/runs \
  -H "Authorization: Bearer $FLORA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inputs": [{ "id": "headline", "type": "text", "value": "Smart living, simple" }],
    "mode": "async",
    "callback_url": "https://api.example.com/flora-webhook"
  }'
```

{% endtab %}
{% endtabs %}

`callback_url` is per request, not a workspace setting, so different runs can report to different URLs.

## Events

A run delivers exactly one terminal event:

| Event           | When                          |
| --------------- | ----------------------------- |
| `run.completed` | The run finished successfully |
| `run.failed`    | The run ended with an error   |

## Payload

`Content-Type: application/json`. The body is identical on every retry of a delivery:

```json
{
  "id": "whd_abc123",
  "type": "run.completed",
  "api_version": "2026-06-11",
  "created_at": 1733952000000,
  "data": {
    "run_id": "run_abc123",
    "run_type": "generation",
    "workspace_id": "ws_abc123",
    "status": "completed"
  }
}
```

| Field                                   | Notes                                               |
| --------------------------------------- | --------------------------------------------------- |
| `id`                                    | Unique per delivery. Dedupe on it.                  |
| `type`                                  | `run.completed` or `run.failed`                     |
| `api_version`                           | Payload contract version (a date)                   |
| `created_at`                            | Unix milliseconds                                   |
| `data.run_type`                         | `generation` or `technique`                         |
| `data.status`                           | `completed` or `failed`                             |
| `data.error_code`, `data.error_message` | Present on failure (`error_message` when available) |

The payload carries the run's identity and final status, not its outputs. Fetch the full result from the run endpoint, keyed by `data.run_id`:

```ts
// Generations
const result = await client.generations.retrieve({ runId: event.data.run_id })

// Technique runs
const techniqueRun = await client.techniques.runs.retrieve({
  techniqueId: "your-technique-slug",
  runId: event.data.run_id,
})
```

## Headers

| Header             | Example                     | Purpose                                   |
| ------------------ | --------------------------- | ----------------------------------------- |
| `Flora-Signature`  | `t=1733952000,v1=9f86d0...` | Timestamp and HMAC-SHA256 hex of the body |
| `Flora-Webhook-Id` | `whd_abc123`                | Delivery ID (same as `id`)                |
| `Flora-Event`      | `run.completed`             | Event type (same as `type`)               |

The signed message is the Unix-seconds timestamp `t`, a literal `.`, then the exact raw request body: `` `${t}.${rawBody}` ``. Binding the timestamp into the signature lets you reject replays.

## Get your signing secret

Each workspace has one signing secret (`whsec_...`), shared by all of that workspace's API keys. A delivery is signed with the secret of the workspace its run belongs to.

The secret appears in the **API Key Created** dialog when you [create an API key](/api/platform/authentication.md#create-a-key), one per workspace the key covers. Store it like any other secret, for example as `FLORA_WEBHOOK_SECRET`, one per workspace. Lost it? Create another key that covers the workspace; the dialog shows the same secret again.

### Rotate the secret

If the secret leaks, a workspace owner or admin can replace it under **Settings → API Keys → Webhook signing secret → Rotate secret**.

* The old secret stops working **immediately** for every webhook in the workspace, including deliveries that are already retrying.
* The new secret is shown once, right after you rotate. Update every service that verifies this workspace's webhooks before new deliveries arrive.
* Any workspace member with API access can still see the current secret, for example when creating an API key.

## Verify a webhook

{% hint style="warning" %}
Verify against the **raw request body bytes**. Parsing the JSON and serializing it again changes the bytes, and the signature won't match.
{% endhint %}

{% tabs %}
{% tab title="TypeScript (SDK)" %}
The SDK checks the signature in constant time, enforces the replay window, and returns the parsed event. It makes no network call, and the client doesn't need an API key for it.

```ts
import express from "express"
import { FloraClient } from "@flora-ai/flora"

const client = new FloraClient()
const app = express()

// Use the raw body on this route, not express.json()
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 {
    // Bad signature, tampered body, 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) // acknowledge with 2xx, or FLORA retries
})
```

`unwrap(body, headers, secret, options?)` takes the body as a string or bytes, and the headers as a `Headers` object, a plain record, or the raw `Flora-Signature` value. It throws `WebhookVerificationError` on any failure. The replay window defaults to 5 minutes; pass `{ toleranceSeconds: 0 }` to turn it off.
{% endtab %}

{% tab title="Python" %}
Without the SDK, recompute the HMAC yourself and compare in constant time:

```python
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)

@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}.".encode() + raw,
        hashlib.sha256,
    ).hexdigest()

    if not t or not hmac.compare_digest(sig, expected) or abs(time.time() - int(t)) > 300:
        abort(400)

    event = request.get_json()
    return "", 200  # acknowledge with 2xx, or FLORA retries
```

{% endtab %}
{% endtabs %}

## Delivery and retries

FLORA sends each delivery as a `POST` with a **10-second** 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 at most) on a fixed schedule:

| Attempt | Wait before it |
| ------- | -------------- |
| Retry 1 | 10 seconds     |
| Retry 2 | 1 minute       |
| Retry 3 | 5 minutes      |

After the last retry fails, the delivery is marked failed in the workspace's usage view. **Respond with `2xx` quickly** and do heavy work asynchronously; a slow handler eats into the timeout. If you rely on webhooks, keep a fallback that reads the run directly for anything you haven't heard back about.

## Handle duplicates

Deliveries are at-least-once: a retry can arrive after your handler already processed the first attempt (for example, it succeeded but your `2xx` came back too slowly). Dedupe on the delivery `id` (also in `Flora-Webhook-Id`):

```ts
if (await alreadyProcessed(event.id)) return res.sendStatus(200)
await markProcessed(event.id)
```

## Security

* **HTTPS only.** `http://` URLs are rejected when you start the run.
* **No internal targets.** FLORA won't deliver to `localhost`, `*.local` or `*.internal` hosts, cloud metadata hostnames, private, loopback or link-local IP addresses, or URLs with embedded credentials. The run still starts, but its delivery is recorded as failed and never sent.
* **No redirects.** FLORA doesn't follow `3xx` responses, so a redirect can't bounce a delivery to another host.
* **Always verify the signature.** The URL isn't a secret; the signature is what proves the request came from FLORA.

## Related

* [Authentication](/api/platform/authentication.md): where the `whsec_...` secret appears.
* [Idempotency](/api/platform/idempotency.md): make your own retries safe on the way in.
* [Errors](/api/platform/errors.md): the `error_code` values a failed run reports.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.flora.ai/api/platform/webhooks.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
