Verifying Hygraph Webhook Signatures
Within Hygraph GraphQL Content Federation, this guide secures the webhooks that tell a frontend to revalidate content. It covers configuring webhooks with a secret key, what the gcms-signature header contains, verifying it with Hygraph’s utilities, rejecting stale requests, keeping payloads lean, and turning publish, unpublish and delete operations into cache tag revalidation.
Hygraph webhooks are configured per project environment, with a URL, the models and stages they apply to, the operations that trigger them, custom headers and whether to include the payload. When a secret key is set, each request carries a gcms-signature header with three parts: the signature, the environment name and a timestamp. The signature is an HMAC over the body, environment and timestamp, which binds the request to a moment and an environment. Verifying it proves that the request came from Hygraph and that nobody altered it; checking the timestamp limits replays.
The Problem
A retailer’s webhook endpoint compared a static token in the query string with an environment variable. The URL, including the token, appeared in logs of a third-party monitoring tool, and months later someone used it to trigger revalidation of every product page repeatedly during a sale, slowing the site when traffic was highest. The endpoint also accepted webhooks from the development environment, because both environments’ webhooks pointed at the same URL, so developers testing schema changes regularly purged production caches.
How Hygraph Signs Webhooks
Secret key. In the webhook’s settings, set a secret key. Without one, Hygraph sends no signature.
The header. gcms-signature contains sign=<signature>, env=<environment>, t=<timestamp>. The environment is the project environment that triggered the webhook, such as master.
Verification. Hygraph’s utilities package, @hygraph/utils, exports verifyWebhookSignature, which recomputes the signature from the body, the environment, the timestamp and your secret, and compares it. Using the helper avoids reproducing the exact canonical form by hand.
Freshness and environment. After verification, compare the timestamp with the current time and reject requests older than a few minutes. Check that the environment is the one this deployment serves.
Operations and payload. The payload contains the operation, such as publish, unpublish or delete, and the entry’s data, including its __typename, id and fields. Disable payload inclusion if the handler needs only ids and you can identify the entry otherwise, or keep payloads small by limiting what the webhook includes.
Implementation
Install the utilities package and verify in the route handler:
// app/api/hygraph/webhook/route.ts
import { verifyWebhookSignature } from "@hygraph/utils";
import { revalidateTag } from "next/cache";
type HygraphWebhook = {
operation: "publish" | "unpublish" | "delete" | "create" | "update";
data: { __typename: string; id: string; slug?: string; stage?: string; [k: string]: unknown };
};
const MAX_AGE_MS = 5 * 60 * 1000;
export async function POST(req: Request) {
const raw = await req.text();
const signature = req.headers.get("gcms-signature") ?? "";
const body = JSON.parse(raw) as HygraphWebhook;
if (!verifyWebhookSignature({ body, signature, secret: process.env.HYGRAPH_WEBHOOK_SECRET! })) {
return new Response("invalid signature", { status: 401 });
}
const parts = Object.fromEntries(signature.split(",").map((p) => p.trim().split("=") as [string, string]));
if (parts.env !== process.env.HYGRAPH_ENVIRONMENT) return new Response("wrong environment", { status: 400 });
if (!parts.t || Math.abs(Date.now() - Number(parts.t)) > MAX_AGE_MS) return new Response("stale", { status: 401 });
const type = body.data.__typename.toLowerCase();
const tags = [`${type}:${body.data.id}`, `${type}:list`];
if (body.data.slug) tags.push(`${type}:slug:${body.data.slug}`);
for (const t of tags) revalidateTag(t);
console.log(JSON.stringify({ kind: "hygraph_webhook", op: body.operation, type, id: body.data.id, tags }));
return Response.json({ revalidated: tags });
}
The frontend’s queries use the same tags: an article page tags its fetch with article:{id} and the ids of related entries it renders, listings use article:list. With that mapping, publishing an article refreshes its page and every list that includes it.
Timestamp units
Check the unit of the timestamp for your project against a real request, milliseconds or seconds, and compare accordingly. The signature covers the timestamp, so it cannot be altered without failing verification, but a captured request could be replayed within the allowed window; keep the window short and make handlers idempotent, as described in idempotent webhook handlers.
Related entries
When an author’s name changes, pages showing the author must refresh. Either configure the webhook for the author model too and tag pages with the author’s id, or keep a small mapping of which models appear on which lists. The first is more precise; the second is simpler for small sites.
Configuration Reference
| Item | Recommendation | Why |
|---|---|---|
| Secret key | set on every webhook | Enables gcms-signature. |
| Verification | verifyWebhookSignature from @hygraph/utils |
Correct canonical form. |
| Environment | compare env with the deployment’s environment |
No cross-environment purges. |
| Timestamp | reject older than a few minutes | Limits replays. |
| Triggers | publish, unpublish, delete on PUBLISHED | Only reader-visible changes. |
| Tags | entry, list, related entries | Precise revalidation. |
| Secret in URL | never | URLs end up in logs. |
Gotchas & Edge Cases
- Payload size. Webhooks that include large rich text or many relations produce big payloads; limit included content when the handler only needs ids.
- Delete operations. Deleted entries may carry only minimal data; tag by id rather than by slug for deletes.
- Bulk publishing. Releases publish many entries at once and send many webhooks; tag revalidation handles bursts, build hooks need debouncing.
- Failed deliveries. Hygraph shows webhook logs with failed deliveries; monitor them, and keep a scheduled revalidation of recent changes as a backstop.
- Local development. Test with signed fixture requests rather than disabling verification in development.
Worked Example
The retailer set secret keys on its webhooks, replaced the query-string token with signature verification, and checked environment and timestamp. It also split webhooks per environment, so the development environment’s webhook pointed at a staging deployment. The replayed revalidation requests were rejected with 401, and developers’ schema tests stopped purging production caches. During the next sale, revalidations matched publishes one to one, and cache hit rates stayed high throughout.
Testing Webhook Handling
Webhook handlers are easy to test once signatures are reproducible. Capture real payloads from a development environment for each model and operation you handle, and store them as fixtures. In tests, sign fixtures with a test secret using the same algorithm as the utilities package, or with a helper that generates signed test requests, and assert that valid requests revalidate the expected tags, that wrong signatures and wrong environments are rejected, and that stale timestamps fail. Add a test for each model whenever the webhook configuration gains a new one, so changes to the model list and the handler stay in step. In staging, a small smoke test after each deployment publishes a test entry and checks the handler’s log line, which catches misconfigured secrets immediately rather than at the next real publish.
Securing the Endpoint Beyond Signatures
Signature verification decides whether to trust a request, but the endpoint still receives every request that anyone sends to it. Keep the cost of rejected requests low: verify before doing any other work, and never log full bodies of unverified requests. Rate-limit the endpoint at the edge, generously enough for bursts of real publishes but low enough that a flood of forged requests cannot consume meaningful resources. Keep the endpoint’s path out of client bundles, sitemaps and public documentation; it is not secret, but there is no reason to advertise it. Respond with plain status codes and no detail beyond what an operator needs, so probing reveals nothing about the implementation. Finally, alert on a rising rate of signature failures: a sudden increase means either a changed secret that nobody rolled out to the handler, or someone probing the endpoint, and both deserve attention the same day.
Rollout Checklist
- Set a secret key on every webhook and remove tokens from URLs.
- Verify
gcms-signaturewith the utilities package. - Check the environment and timestamp after verification.
- Trigger only on publish, unpublish and delete for PUBLISHED content.
- Map operations to entry, list and related tags.
- Monitor webhook logs and keep a backstop revalidation.
Frequently Asked Questions
Why use the utilities package instead of my own HMAC?
The signature covers a specific combination of body, environment and timestamp; the package reproduces it exactly, so verification does not break on formatting details.
Can I verify in an edge runtime?
Check whether the package supports your runtime; otherwise run the webhook handler in a Node.js runtime, which is fine for an endpoint called only on publishes.
Should webhooks include the payload?
Include it when the handler needs the slug or type; keep it small by limiting fields. Ids and type names are usually enough for tag revalidation.
How do I rotate the secret?
Accept the old and new secrets in the handler briefly, change the key in Hygraph, confirm deliveries succeed in the webhook logs, then remove the old one from the handler.