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_ 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
- List presets with
GET /api/trigger-presets, optionally filtered bydatasource. - Read the one you want with
GET /api/trigger-presets/{preset_to see itsid} inputs, which are required, and any allowedtime_offsetvalues. - Optionally preview with
POST /api/trigger-presets/{preset_, 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.id}/events/search - Create the trigger:
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" }
}'
- Copy
trigger.signing_secretfrom the response and store it, for example asGLEAN_SIGNING_SECRET. It starts withwhsec_, 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.
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:
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, which takes an optional tolerance.
Make Glean webhooks production-ready. Hookdeck 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
5xxso Glean retries. - Return
2xxas 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 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
statustoDISABLEDrather 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.
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.
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 sits in front of your receiver, verifies every Glean delivery, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Glean webhooks reliably in minutes.