# Guide to Glean Webhooks: Features and Best Practices

Glean webhooks let an application react to what happens in a company's connected tools: a review is requested from you, a call you were on has been indexed, a meeting starts in half an hour. Glean is an enterprise search and AI assistant, and its Triggers API turns those content events into signed webhooks, so you can build meeting prep, review reminders or agent workflows without polling Glean's search.

This guide covers how Glean triggers are created and signed, how delivery and retries work, the experimental status of the API, and the practices that keep a Glean receiver reliable.

## What are Glean webhooks?

A Glean trigger subscribes your application to one kind of content event. You don't write a query. Instead, you pick a **preset**, a curated event definition for one datasource, and fill in the inputs it declares, such as a meeting title or a time offset. When a matching event happens, Glean sends a signed HTTP `POST` to the trigger's webhook URL.

Delivery follows the Standard Webhooks specification: `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, with an HMAC-SHA256 signature keyed with a `whsec_` secret.

The Triggers API is experimental. Glean introduced it in July 2026, and experimental APIs "may change in backwards-incompatible ways, or be removed entirely, without notice". Every Triggers API call needs the `X-Glean-Include-Experimental: true` header.

## Glean webhook features

| Feature | Details |
| --- | --- |
| Configuration | Platform API: `POST /api/triggers`, with presets from `GET /api/trigger-presets` |
| API status | Experimental, opt-in with `X-Glean-Include-Experimental: true` |
| Scopes | `triggers:read` to read presets and triggers, `triggers:write` to change them |
| Subscription | One preset and its inputs per trigger |
| Signature headers | `webhook-id`, `webhook-timestamp`, `webhook-signature` |
| Scheme | Base64 HMAC-SHA256, sent as `v1,<signature>` |
| Signed content | `{webhook-id}.{webhook-timestamp}.{raw body}` |
| Secret | `whsec_...` per trigger, returned only when the trigger is created |
| Extra credential | Optional `Authorization: Bearer <secret>`, sent with the signature |
| Delivery | At least once |
| Retried | Connection failures, timeouts, `408`, `429`, `5xx` |
| Not retried | Any other non-`2xx`, including redirects. The event is dropped |
| Pause | `PATCH /api/triggers/{trigger_id}` with `status: DISABLED` keeps the trigger and secret |
| Preview | Search the last seven days of matches without delivering anything |

Glean doesn't publish the retry schedule, the response timeout or source IPs.

## Presets and events

Glean has no fixed list of event types. Presets are per deployment: built-in datasources have a fixed set, custom datasources indexed through the Indexing API get "New `<object>`" and "New or Updated `<object>`" presets from their schema, and the catalogue is filtered to what the calling user can access. Two users on the same deployment can see different presets, so discover them at runtime rather than hard-coding preset IDs.

Glean doesn't document the delivery body on its public pages. The create-trigger reference names the callback but doesn't render its schema. Create a trigger against a test endpoint, let a real event fire, and build against what you receive.

Two more details shape what arrives:

* Deliveries are filtered against the subscribing user's document permissions, so a trigger never sends a document that user couldn't open.
* For custom datasources, a trigger fires only after Glean indexes the pushed document. Re-pushing an identical document, or changing only its permissions, doesn't fire.

## Setting up Glean webhooks

1. List presets with `GET /api/trigger-presets`, optionally filtered by `datasource`.
2. Read the one you want with `GET /api/trigger-presets/{preset_id}` to see its `inputs`, which are required, and any allowed `time_offset` values.
3. Optionally preview with `POST /api/trigger-presets/{preset_id}/events/search`, which shows recent events the preset would have matched. Creating a trigger checks the inputs but can't tell you whether anything will ever match.
4. Create the trigger:

```bash
curl -X POST "https://your-instance-be.glean.com/api/triggers" \
  -H "Authorization: Bearer $GLEAN_TOKEN" \
  -H "X-Glean-Include-Experimental: true" \
  -H "Content-Type: application/json" \
  -d '{
    "preset_id": "<preset id from step 1>",
    "inputs": {},
    "delivery": { "webhook_url": "https://example.com/webhooks/glean" }
  }'

```

1. Copy `trigger.signing_secret` from the response and store it, for example as `GLEAN_SIGNING_SECRET`. It starts with `whsec_`, and Glean never returns it again.

Use an `https://` URL. The signature proves who sent a delivery and that it wasn't changed, but only TLS keeps the content private. Glean can't deliver to `localhost`, so use a tunnel when testing locally.

If you receive Glean webhooks through Hookdeck, create a source with the Glean type, paste the whole `whsec_` signing secret, and use the source URL as `delivery.webhook_url`.

### The optional Bearer credential

If you set `delivery.auth` with `type: BEARER`, Glean also sends `Authorization: Bearer <secret>` on every delivery, "in addition to the HMAC signature". The credential is write-only, and it has two side effects: you can't remove it without deleting and recreating the trigger, and while it is set you can't change `webhook_url`. Decide where the trigger will point before you add one.

## Securing Glean webhooks

Strip `whsec_` from the signing secret and base64-decode the rest to get the key. Concatenate `webhook-id`, a period, `webhook-timestamp`, a period and the raw body. Compute an HMAC-SHA256, base64-encode it, and compare it in constant time with each `v1,` entry in `webhook-signature`. Glean sends one signature today but tells you to parse the space-delimited list anyway.

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

function verifyGleanSignature(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatureHeader = headers['webhook-signature'];
  if (!id || !timestamp || !signatureHeader) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]))
    .digest();

  return signatureHeader.split(' ').some((entry) => {
    const [version, value] = entry.split(',');
    if (version !== 'v1' || !value) return false;
    const received = Buffer.from(value, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/glean', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.GLEAN_SIGNING_SECRET;
  if (!secret || !secret.startsWith('whsec_')) return res.sendStatus(500); // fail closed

  // Any 4xx other than 408 or 429 is terminal for Glean: it won't retry.
  if (!verifyGleanSignature(req.body, req.headers, secret)) return res.sendStatus(401);

  // Optional: reject timestamps outside your own tolerance window here.

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on the webhook-id header, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_glean_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not (msg_id and timestamp and signature_header):
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())
    for entry in signature_header.split(" "):
        version, _, value = entry.partition(",")
        if version == "v1" and hmac.compare_digest(value.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_glean_signature(raw_body, request.headers, os.environ["GLEAN_SIGNING_SECRET"])

```

If you set a Bearer credential, check the `Authorization` header as well. It is an extra check, not a replacement for the signature.

### The timestamp window

Glean's docs tell receivers to "reject deliveries outside a tolerance window" and cite 300 seconds, the default in the `standardwebhooks` libraries. Glean regenerates `webhook-timestamp` on each of its own retry attempts, so its retries pass. A delivery retried or replayed through Hookdeck keeps its original timestamp, though, so anything retried or replayed more than 5 minutes after Glean sent it fails a 300-second check, and the libraries don't let you change their window. The verifier above has no window. If you want one, add it as your own check, or use the manual verifier in the [Glean webhook skill](https://github.com/hookdeck/webhook-skills/tree/main/skills/glean-webhooks), which takes an optional tolerance.

> **Make Glean webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Standard Webhooks signature on every Glean delivery, answers Glean with a `200`, and durably queues each event before your service sees it.

## Glean webhook limitations and pain points

### Most errors drop the event

**The Problem:** An event is lost after a single failed delivery.

**Why It Happens:** Glean retries only connection failures, timeouts, `408`, `429` and `5xx`. Any other non-`2xx` is terminal, including a `404` from a missing route, a `401` from a misconfigured verifier, and redirects such as an `http` to `https` upgrade or a trailing-slash rewrite.

**Workarounds:**

* Register the exact final URL, with `https://` and the path your framework expects.
* If your handler fails for a reason that might be temporary, return a `5xx` so Glean retries.
* Return `2xx` as soon as you've stored the event.

**How Hookdeck Can Help:** Hookdeck answers Glean with a `200` as soon as it receives a delivery, so a mistake in your service doesn't drop the event. Hookdeck records a request that fails the signature check as failed verification, so it never reaches your destination, and delivers verified events to your service with [retries](/docs/retries) you control.

### The secret is shown once

**The Problem:** You lose the signing secret and can't verify anything.

**Why It Happens:** `POST /api/triggers` returns `signing_secret` once and no read returns it again.

**Workarounds:**

* Write the secret to your secret manager in the same script that creates the trigger.
* To pause a trigger, set `status` to `DISABLED` rather than deleting it, which keeps the secret.
* If the secret is lost, delete the trigger and create a new one.

**How Hookdeck Can Help:** With Hookdeck in front, the secret is stored on the source, so your services behind it never need a copy.

### An experimental API with an undocumented body

**The Problem:** Your handler breaks after a Glean change, or you can't write it in the first place.

**Why It Happens:** The Triggers API is experimental and can change without notice, and the delivery body isn't documented publicly.

**Workarounds:**

* Capture real deliveries before you write handlers, and validate the fields you rely on.
* Pin your integration to the behaviour you've observed and alert on unknown shapes.

**How Hookdeck Can Help:** Hookdeck stores every delivery with its headers and body, so you can see exactly what Glean sends and compare it over time.

### Presets differ between users and deployments

**The Problem:** A preset ID that works in one tenant fails in another, or a trigger never fires.

**Why It Happens:** Preset IDs are per deployment and filtered by the caller's access. A trigger is only checked for valid inputs when created, not for whether it can ever match.

**Workarounds:**

* Discover presets at runtime and use the preview endpoint before creating a trigger.
* Create triggers as a user with access to the documents you care about.

**How Hookdeck Can Help:** Hookdeck's request log shows whether a trigger has ever delivered, which is a quick way to spot one that matches nothing.

## Best practices

### Verify against the raw body

Glean warns that re-serialising the JSON changes key order and whitespace. In Express, use `express.raw({ type: '*/*' })`. In Next.js, use `await request.text()`. In FastAPI, use `await request.body()`.

### Deduplicate on `webhook-id`

Delivery is at least once, and `webhook-id` stays the same across Glean's retries. Store it and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

A timeout is retried, but a slow handler still causes duplicates. Verify, store, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Keep the experimental header out of everything else

Glean's general advice is to send `X-Glean-Include-Experimental` only while evaluating experimental features, and to leave it off in production so you stay on generally available behaviour. Triggers can't work without it, so send it only on your Triggers API calls and keep it off the rest of your Glean integration.

## Conclusion

Glean Triggers deliver content events as Standard Webhooks: a base64 HMAC-SHA256 over the message ID, timestamp and raw body, keyed with the decoded `whsec_` secret that Glean returns once when you create the trigger. Store that secret immediately, register the exact final URL, deduplicate on `webhook-id`, and remember that most non-`2xx` responses drop the event for good.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Glean delivery, and holds each event until your service has processed it.

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