> ## Documentation Index
> Fetch the complete documentation index at: https://www.fluenterp.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook delivery and security

> Verify signatures and safely handle retries, duplicates, ordering, and endpoint failures.

Fluent delivers webhooks at least once. Your receiver must be idempotent, tolerate out-of-order events, and acknowledge accepted requests promptly.

## Verify the signature

Every attempt has a `Fluent-Signature` header:

```text theme={null}
t=<unix_seconds>,v1=<hex_hmac>
```

The `v1` value is:

```text theme={null}
HMAC-SHA256(signing_secret, "<timestamp>.<raw_request_body>")
```

Verify the original bytes before parsing JSON. Re-serializing a parsed object can change whitespace or property ordering and invalidate the signature.

The timestamp is generated separately for each attempt, so a legitimate retry can still pass the replay window.

### Node.js and TypeScript

This example uses only Node.js built-ins. Pass it the original body bytes, not a parsed object.

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyFluentSignature({
  body,
  header,
  secret,
  toleranceSeconds = 300,
}: {
  body: Buffer;
  header: string | null;
  secret: string;
  toleranceSeconds?: number;
}): boolean {
  if (!header || toleranceSeconds < 0) return false;

  const parts = header.split(",").map((part) => part.trim());
  const timestampText = parts.find((part) => part.startsWith("t="))?.slice(2);
  const signatures = parts.filter((part) => part.startsWith("v1=")).map((part) => part.slice(3));
  if (!timestampText || signatures.length === 0) return false;

  const timestamp = Number(timestampText);
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isInteger(timestamp) || Math.abs(now - timestamp) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret)
    .update(Buffer.from(`${timestampText}.`, "utf8"))
    .update(body)
    .digest();

  return signatures.some((signature) => {
    if (!/^[0-9a-f]{64}$/i.test(signature)) return false;
    const actual = Buffer.from(signature, "hex");
    return actual.length === expected.length && timingSafeEqual(actual, expected);
  });
}

export async function POST(request: Request) {
  const rawBody = Buffer.from(await request.arrayBuffer());
  const secret = process.env.FLUENT_WEBHOOK_SECRET;

  if (!secret) {
    return new Response("Webhook secret is not configured", { status: 500 });
  }

  const valid = verifyFluentSignature({
    body: rawBody,
    header: request.headers.get("Fluent-Signature"),
    secret,
  });

  if (!valid) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(rawBody.toString("utf8"));
  // Persist event.id with a unique constraint, then enqueue business work.
  await persistWebhookEvent(event);
  return new Response(null, { status: 204 });
}
```

### Language-independent procedure

1. Split the header on commas.
2. Extract `t` and every `v1` value.
3. Parse `t` as Unix seconds and reject timestamps more than five minutes from your current time.
4. Construct the signed bytes as UTF-8 `t`, then `.`, then the unmodified request-body bytes.
5. Calculate HMAC-SHA256 using the signing secret as UTF-8 bytes.
6. Encode the result as lowercase hexadecimal.
7. Compare it with each `v1` value using a constant-time function. Accept if any match.

Allowing multiple `v1` values makes the parser compatible with future signing transitions even though Fluent currently sends one.

<Warning>
  Verify the signature before logging or parsing the payload. Store the signing secret in a secret manager, and never log the secret or full document payloads.
</Warning>

## Idempotency

Automatic retries send the same `Fluent-Event-Id` and `Fluent-Delivery-Id` with a higher `Fluent-Attempt`. Store the event ID in a table with a unique constraint before performing side effects.

If the event ID already exists, return `2xx` without repeating the work. Do not treat a duplicate as an error; an error response causes more retries.

Manual retry starts the completed delivery again. Business idempotency must therefore remain keyed by event ID, even after the automatic retry window ends.

## Ordering

Delivery order is not guaranteed. Retries, multiple workers, and concurrent transactions can make a newer state arrive before an older one.

Treat each payload as a snapshot. For each document or run, store the greatest applied `sequence` and ignore an event with a lower sequence. Sequence values are global and gappy, so do not use missing numbers to detect lost events.

Before an irreversible action, fetch current state from `GET /api/v1/documents/{id}` or `GET /api/v1/runs/{id}`. This protects against both stale delivery and a state transition that occurred after the event was created.

## Response and retry behavior

Fluent gives each request 10 seconds and accepts any `2xx` response as success.

| Result                     | Fluent behavior                                                             |
| -------------------------- | --------------------------------------------------------------------------- |
| `2xx`                      | Delivery succeeds and the endpoint's consecutive-failure counter resets.    |
| `408`, `429`, or `5xx`     | Retry, up to six total attempts.                                            |
| Network failure or timeout | Retry, up to six total attempts.                                            |
| `3xx`                      | Redirect is not followed; delivery fails without another automatic attempt. |
| Other `4xx`                | Delivery fails without another automatic attempt.                           |

Fluent stores at most the first 2,048 bytes of the response body for endpoint diagnostics.

Return `2xx` after durably recording the event, then do expensive work asynchronously. A slow synchronous receiver can complete its own operation but still exceed Fluent's timeout and receive the same event again.

<Tip>
  Use `429` only when retrying later can succeed. Authentication or validation failures should return their appropriate non-retryable `4xx` response so Fluent does not repeat an invalid request.
</Tip>

## Attempts and endpoint health

Each delivery can make up to six automatic attempts. Fluent records the current attempt, response status, response snippet, error, and duration in the endpoint's delivery log.

A terminal delivery failure increments the endpoint's consecutive-failure count when the failure came from the customer endpoint. A successful delivery resets the count to zero. Fluent-side preparation errors are retried but do not count against endpoint health.

After 20 consecutive terminal failures, Fluent:

* Disables the endpoint.
* Marks pending deliveries as failed.
* Stops recording and delivering new events for that endpoint.
* Records the disable reason.
* Emails team administrators once.

A team administrator can fix the receiver and re-enable the endpoint, which resets the failure count. Events that occurred while it was disabled are not recreated.

## Manual retries

Team administrators can open a completed delivery and select **Retry**. Both succeeded and failed deliveries can be retried while the endpoint is enabled. This resets delivery-attempt diagnostics and places the same event back in the delivery queue.

Your event-ID idempotency remains authoritative: retrying a succeeded delivery should not repeat a business side effect unless you intentionally remove or override your deduplication record.

## Secret rotation

Rotating a signing secret is an immediate cutover. The next delivery uses the new secret, and the previous secret stops verifying without an overlap window.

Use this sequence:

1. Prepare the receiver to read the new secret from your secret manager.
2. Rotate the secret in Fluent and copy the newly displayed value.
3. Save the value in your secret manager.
4. Restart or refresh the receiver so it reads the new value.
5. Send a test event.

Because Fluent does not reveal a future secret before rotation, a zero-failure cutover requires temporarily disabling the endpoint during steps 2–4. Events occurring while disabled are not backfilled, so choose a quiet maintenance window or tolerate and manually retry deliveries that fail during rotation.

## Network requirements

Webhook destinations must:

* Use HTTPS.
* Have no username or password in the URL.
* Resolve to public IPv4 or IPv6 addresses only.
* Accept the request without an HTTP redirect.
* Respond within 10 seconds.

Fluent validates DNS when the endpoint is created or changed and immediately before delivery. It pins delivery to the validated addresses while retaining the hostname for TLS certificate validation.

If your firewall uses an allowlist, coordinate the required network configuration with Fluent support before enabling production delivery.

## Receiver checklist

* Preserve the raw body and verify `Fluent-Signature` first.
* Keep clocks synchronized so the five-minute replay window is reliable.
* Enforce a unique constraint on `Fluent-Event-Id`.
* Persist first, acknowledge with `2xx`, and process asynchronously.
* Apply only snapshots newer than the resource's stored sequence.
* Ignore unknown fields and event types.
* Reconcile current API state before irreversible work.
* Alert on terminal failures and disabled endpoints.
* Avoid logging signing secrets or sensitive fetched `document_data`.
