# Langfuse Webhooks

Langfuse (the open-source LLM engineering platform) can run an **Automation** on prompt
management: when a prompt version is created, updated (labels or tags) or deleted, a "Webhook
Call" action sends a JSON notification to your URL. Each request is signed with the
`x-langfuse-signature` header, so your endpoint can verify the sender and the exact body.

## When to Use This Skill

* How do I receive Langfuse webhooks?
* How do I verify the `x-langfuse-signature` header (`t=<timestamp>,v1=<signature>`)?
* How do I handle Langfuse prompt version `created`, `updated` and `deleted` events?
* Why does my Langfuse webhook signature verification fail (raw body, timestamp prefix)?
* Why does an `updated` Langfuse webhook arrive twice?

## Verification (core)

The header is `x-langfuse-signature: t=<timestamp>,v1=<hex>`. The signature is the lowercase hex
HMAC-SHA256 of `<t>.<raw body>`, keyed with the webhook's signing secret as UTF-8 bytes.

```javascript
const crypto = require('crypto');

function verifyLangfuseSignature(rawBody /* Buffer */, header, secret) {
  let t;
  const sigs = [];
  for (const part of String(header || '').split(',')) {
    const i = part.indexOf('=');
    if (i === -1) continue;
    const k = part.slice(0, i).trim(), v = part.slice(i + 1).trim();
    if (k === 't' && t === undefined) t = v;          // keep t as the exact string sent
    else if (k === 'v1') sigs.push(v);                // may repeat (rotation); ignore v0 etc.
  }
  if (!secret || !t || sigs.length === 0) return false;
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8'))
    .update(Buffer.concat([Buffer.from(`${t}.`), rawBody])).digest();
  return sigs.some((s) =>                             // validate before decoding, then constant-time
    /^[0-9a-fA-F]{64}$/.test(s) &&
    crypto.timingSafeEqual(Buffer.from(s.toLowerCase(), 'hex'), expected));
}

```

* Sign **bytes**: the raw body exactly as received (`express.raw({ type: '*/*' })`, Next.js
  `await request.arrayBuffer()`, FastAPI `await request.body()`). Never `JSON.parse`/`stringify` first.
* Use `t` exactly as it appears in the header; never parse it to a number and re-format it.
* The secret is used as-is (UTF-8), not hex- or base64-decoded. Env var: `LANGFUSE_WEBHOOK_SECRET`.
* Accept the delivery if **any** `v1=` entry matches. A header with no `t=` or no `v1=` fails.
* Hex is case-insensitive: require exactly 64 hex characters, then compare bytes in constant time.
* Python: `hmac.compare_digest` on **bytes** (`bytes.fromhex` after a `[0-9a-fA-F]{64}` check).
* Missing header, malformed value or mismatch: `401`.
* **No timestamp window by default.** Hookdeck retries and replays resend the original headers and
  timestamp, so a fixed window would reject legitimate retries. To opt in, set
  `LANGFUSE_WEBHOOK_TOLERANCE_SECONDS` (unset or `0` = off); `t` is Unix seconds.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/langfuse-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/langfuse-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/langfuse-webhooks/examples/fastapi/).

## Event Types

Dispatch on the body's `action` field. `type` is always `"prompt-version"` today.

| `action` | Sent When |
| --- | --- |
| `created` | A prompt version is created |
| `updated` | A prompt version's labels or tags change (two deliveries: the version gaining and the version losing the label/tag) |
| `deleted` | A prompt version is deleted |

Only `created` is shown in Langfuse's documented payload; the lowercase spelling of `updated` and
`deleted` follows the automation UI (Created / Updated / Deleted). Log and acknowledge anything else.
The payload carries `id`, `timestamp`, `type`, `apiVersion`, `action` and a `prompt` object
(`name`, `version`, `labels`, `tags`, `prompt`, `config`, `commitMessage`, ...). See
[references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/langfuse-webhooks/references/overview.md).

## Delivery

POST with a JSON body. A `2xx` confirms receipt; otherwise Langfuse may retry with exponential
back-off until it succeeds. No timeout or attempt limit is documented, so make the handler
idempotent (key on the payload `id` plus `action`) and respond quickly.

## Environment Variables

```bash
LANGFUSE_WEBHOOK_SECRET=your_langfuse_webhook_secret   # shown when the webhook is created; regenerable
# LANGFUSE_WEBHOOK_TOLERANCE_SECONDS=300               # optional opt-in replay window; off by default

```

## Local Development

Langfuse can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

```bash
npx hookdeck-cli listen 3000 langfuse --path /webhooks/langfuse

```

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/langfuse-webhooks/references/overview.md) - What Langfuse webhooks are, events and payload
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/langfuse-webhooks/references/setup.md) - Create the automation and webhook action, get the signing secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/langfuse-webhooks/references/verification.md) - Signature details, gotchas, debugging
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/langfuse-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests