# Guide to Langfuse Webhooks: Features and Best Practices

Langfuse is an open-source LLM engineering platform for tracing, evaluating and managing the prompts behind AI applications. Its prompt management lets teams version prompts and promote a version to `production` with a label, without a code deploy. Webhooks close the loop: when someone publishes, relabels or deletes a prompt version, Langfuse can tell your application to refresh its prompt cache, run an evaluation suite or record the change.

This guide covers Langfuse's prompt-version Automations, why a single label move sends two webhooks, verifying `x-langfuse-signature`, and what Langfuse leaves undocumented about retries.

## What are Langfuse webhooks?

Webhooks in Langfuse are one of two actions an Automation can run (the other posts to Slack). You create an Automation in a project, choose which prompt-version changes trigger it, optionally limit it to particular prompts, and pick **Webhook Call** with your URL and any static headers you want added.

When a matching change happens, Langfuse sends a JSON `POST` describing the prompt version and the `action` (`created`, `updated` or `deleted`). The payload includes the whole version, including the prompt text, labels, tags and config, so a receiver can update a cache without calling the Langfuse API.

Each request is signed. `x-langfuse-signature` uses the same `t=...,v1=...` layout as Stripe: a timestamp, and a hex HMAC-SHA256 of the timestamp and body.

## Langfuse webhook features

| Feature | Details |
| --- | --- |
| Configuration | Project **Prompts > Automations**, action **Webhook Call** |
| Triggers | Prompt version Created, Updated (labels or tags), Deleted |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-langfuse-signature` (`t=<timestamp>,v1=<hex>`) |
| Signed content | `<t>.<raw body>` |
| Key | Signing secret per Automation, shown at creation and regenerable |
| Body | JSON |
| Event routing | Body field `action` |
| Custom headers | Static headers configurable per Automation |
| Retries | Exponential back-off until a `2xx`; no limit or timeout documented |

## Events and payloads

| `action` | Triggered When | Common Use Cases |
| --- | --- | --- |
| `created` | A prompt version is created | Sync prompts to a repo, notify a channel, trigger CI evals |
| `updated` | A version's labels or tags change | Redeploy when `production` moves, refresh a prompt cache |
| `deleted` | A prompt version is deleted | Remove cached copies, audit trail |

When a label such as `production` moves from version 3 to version 4, Langfuse sends two `updated` deliveries: one for version 4, which gained the label, and one for version 3, which lost it. Process both, and work out the result from the `labels` array on each rather than from the order they arrive in.

Langfuse documents a body only for `created`. The lowercase `updated` and `deleted` values follow the UI labels (Created, Updated, Deleted), so log any `action` you don't expect and still return `2xx`.

### Published payload example

This `created` delivery comes from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/langfuse/v1/created.json). `type` is always `prompt-version` today, and `apiVersion` is `v1`, though Langfuse doesn't describe that field.

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "timestamp": "2024-07-10T10:30:00Z",
  "type": "prompt-version",
  "apiVersion": "v1",
  "action": "created",
  "prompt": {
    "id": "prompt_abc123",
    "name": "movie-critic",
    "version": 3,
    "projectId": "xyz789",
    "labels": [
      "production",
      "latest"
    ],
    "prompt": "As a {{criticLevel}} movie critic, rate {{movie}} out of 10.",
    "type": "text",
    "config": {
      "key": "value"
    },
    "commitMessage": "Improved critic persona",
    "tags": [
      "entertainment"
    ],
    "createdAt": "2024-07-10T10:30:00Z",
    "updatedAt": "2024-07-10T10:30:00Z"
  }
}

```

## Setting up Langfuse webhooks

### Prerequisites

* A Langfuse project (Langfuse Cloud or self-hosted) with permission to manage prompt automations
* A public HTTPS endpoint for your handler

### How Do I Register an Endpoint?

1. In Langfuse, open your project and go to **Prompts** > **Automations**.
2. Create an Automation and choose the prompt-version events to watch: **Created**, **Updated** and/or **Deleted**. Optionally restrict it to specific prompts.
3. Choose the **Webhook Call** action and enter your endpoint URL. Add any static headers your receiver needs.
4. Save.

### How Do I Get the Signing Secret?

Langfuse shows the signing secret when you create the webhook Automation. Copy it into `WEBHOOK_SECRET` exactly as shown; it's used as UTF-8 bytes, not decoded. You can regenerate it later, which invalidates the old one, so update your receiver at the same time.

Langfuse's UI labels can vary between versions; the [official guide](https://langfuse.com/docs/prompt-management/features/webhooks-slack-integrations) has the current wording.

### Test a delivery

Start a local receiver and forward requests with the [Hookdeck CLI](/docs/cli):

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

```

Use the CLI's URL in the Automation, then save a new version of a test prompt. Moving a label afterwards shows the paired `updated` deliveries.

## Securing Langfuse webhooks

Split `x-langfuse-signature` on commas into `t` and `v1`. Build the message as `t`, a dot and the raw body, compute HMAC-SHA256 with the signing secret, and compare the hex result with `v1` in constant time. Langfuse's Python and TypeScript samples follow the same steps; see the [webhook documentation](https://langfuse.com/docs/prompt-management/features/webhooks-slack-integrations).

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

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-langfuse-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const parts = signature.split(',').map((part) => part.trim());
  const times = parts.filter((part) => part.startsWith('t=')).map((part) => part.slice(2));
  if (times.length !== 1 || !/^[0-9]+$/.test(times[0])) return false;
  const message = Buffer.concat([Buffer.from(`${times[0]}.`, 'utf8'), rawBody]);
  const candidates = parts.filter((part) => part.startsWith('v1=')).map((part) => part.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler deduplicates on the event `id` and keeps a cache of whichever version currently holds the `production` label.

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

function createLangfuseApp({ seen, promptCache, audit }) {
  const app = express();
  app.post('/webhooks/langfuse', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
    let parsed;
    try {
      parsed = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
    const { id, action, prompt } = parsed;
    if (!id || typeof action !== 'string' || !prompt || typeof prompt !== 'object') {
      return res.sendStatus(400);
    }
    try {
      if (await seen(id)) return res.sendStatus(200);
      await audit(action, prompt.name, prompt.version);
      if (action === 'deleted') {
        await promptCache.removeVersion(prompt.name, prompt.version);
      } else if (Array.isArray(prompt.labels) && prompt.labels.includes('production')) {
        await promptCache.setProduction(prompt.name, prompt);
      } else {
        await promptCache.clearIfProduction(prompt.name, prompt.version);
      }
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

The timestamp is signed, but Langfuse documents no tolerance for it, and its retries can run for an unknown time. If you add an age check in a direct receiver, keep it loose; the event `id` is the dependable guard against repeats.

Hookdeck's Langfuse source type reads `t` and `v1`, signs `<t>.<raw body>` with your secret and compares hex digests. It returns `200` to Langfuse in every case, so a bad signature doesn't trigger Langfuse's back-off; the request is logged as failed verification and kept from your destination. Hookdeck doesn't check the age of `t`.

> **Make Langfuse webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies each Langfuse delivery, stores it, and fans prompt changes out to every service that caches prompts.

## Langfuse webhook limitations and pain points

### Two deliveries for one label move

**The Problem:** A cache briefly has no production prompt, or the wrong one, after a promotion.

**Why It Happens:** Moving a label fires one `updated` delivery for the version gaining it and another for the version losing it, and nothing guarantees their order.

**Workarounds:**

* Act on the `labels` array in each delivery, never on the assumption that the second event undoes the first.
* Only clear a cached production version if the delivery's version number matches the cached one.

**How Hookdeck Can Help:** Hookdeck's event log shows both deliveries side by side with their bodies, which makes ordering problems easy to see.

### Undocumented retry limits

**The Problem:** You can't tell how long Langfuse keeps retrying, or when a failed delivery is abandoned.

**Why It Happens:** Langfuse says it retries with exponential back-off until it gets a `2xx`, but gives no timeout, attempt count or spacing.

**Workarounds:**

* Return `2xx` quickly and do the work afterwards.
* Re-read prompts through the Langfuse API on startup so a missed event can't leave a cache stale for long.

**How Hookdeck Can Help:** Hookdeck accepts each delivery straight away and applies its own documented retry policy to your service.

### Partial payload documentation

**The Problem:** Parsers built for `created` may break on `updated` or `deleted` bodies.

**Why It Happens:** Only a `created` body is published, and `apiVersion` isn't described.

**Workarounds:**

* Capture real `updated` and `deleted` deliveries in a test project and save them as fixtures.
* Read only the fields you need and tolerate the rest.

**How Hookdeck Can Help:** Hookdeck keeps the full body of every delivery, so the undocumented shapes are visible from the first event.

### Prompt events only

**The Problem:** You can't use Langfuse webhooks to react to traces, scores or evaluations.

**Why It Happens:** Automations currently trigger on prompt versions only.

**Workarounds:**

* Use Langfuse's API or exports for trace and score data.

**How Hookdeck Can Help:** Hookdeck can still route prompt events alongside webhooks from your other AI tooling through one gateway.

## Best practices

### Use one Automation per consumer

Separate Automations, each with its own URL and secret, let a CI pipeline and a production cache subscribe to different prompts without sharing a key.

### Keep the timestamp text as received

Build the signed message from the `t` string exactly as sent. Formatting it through a number can change it.

### Mind what the payload contains

The body includes full prompt text and config. If prompts contain anything sensitive, keep the bodies out of general logs.

### Acknowledge, then act

Langfuse expects a `2xx`. Return it once the event is stored, then refresh caches or start evaluations, as described in the [asynchronous processing guide](/webhooks/guides/why-implement-asynchronous-processing-webhooks). Keep a record of handled `id` values, following the [idempotency guide](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Langfuse webhooks give prompt management a proper release signal: each version change arrives with the full prompt and a Stripe-style signature. The details to handle are the paired `updated` events on a label move, the thin documentation of `updated` and `deleted` bodies, and a retry policy with no stated limits.

[Hookdeck Event Gateway](https://hookdeck.com) stores and verifies those prompt events and delivers them to as many consumers as you need.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Langfuse webhooks reliably in minutes.