Guide to Hasura Webhooks: Features and Best Practices
Hasura webhooks turn database changes into events. When a row is inserted, updated or deleted in a table that Hasura tracks, an Event Trigger sends the change to a webhook, so you can send a welcome email when a user signs up, sync an order to a fulfilment system, or rebuild a search index without writing database triggers or polling.
This guide covers how Hasura v2 Event Triggers are created and what their payload contains, how to authenticate requests that Hasura does not sign, how retries and the Retry-After header work, and the best practices for running a receiver in production. Hasura DDN (v3) is a separate engine and isn't covered here.
What are Hasura webhooks?
An Event Trigger is attached to one table in a Postgres or MS SQL Server database connected to Hasura GraphQL Engine. You choose which operations fire it (insert, update, delete, and optionally manual invocation from the Console) and a webhook URL. When a matching change happens, Hasura sends a POST with a JSON body describing the change to that URL. Hasura's docs say the webhook URL "must be a POST handler".
Event Triggers are sent by your own GraphQL Engine, whether you run it yourself or on Hasura Cloud. That means the engine must be able to reach your URL: if Hasura runs in Docker, a localhost URL points at the container, not your machine.
Hasura doesn't sign Event Trigger requests. To authenticate them, add a custom header to the trigger. The value can be static, or resolved at send time from an environment variable in the GraphQL Engine, which keeps the secret out of your metadata files.
Hasura webhook features
| Feature | Details |
|---|---|
| Configuration | Console (Events tab), metadata YAML with hasura metadata apply, or the Metadata API (pg_create_event_trigger, mssql_) |
| Databases | Postgres and MS SQL Server |
| Operations | INSERT, UPDATE, DELETE, MANUAL |
| Update filtering | Listen columns: only changes to selected columns fire the trigger |
| Method | POST with Content-Type: application/json |
| Authentication | Custom headers, static or value_from_env |
| Signature | None |
| Delivery guarantee | At least once, possibly out of order |
| Retries | retry_conf: num_retries (default 0), interval_sec (default 10) |
| Timeout | timeout_sec, default 60 seconds |
| Success | Any 2xx status |
Retry-After | On a non-2xx response, the event is redelivered once more after the given number of seconds |
Common events
There are four operations, given in event.op:
event.op | Fires when | event.data.old | event.data.new |
|---|---|---|---|
INSERT | A row is inserted | null | The inserted row |
UPDATE | A row is updated and the new data differs from the old | Values before the update | Values after the update |
DELETE | A row is deleted | The deleted row | null |
MANUAL | The trigger is invoked manually | null | The current row |
event.op tells you what happened, not which trigger fired. If one endpoint serves several triggers, route on trigger.name (unique per trigger) or on table.schema and table.name first, then on event.op.
Here is Hasura's example body for an insert:
{
"created_at": "2020-07-29T10:00:00.000Z",
"delivery_info": { "current_retry": 0, "max_retries": 0 },
"event": {
"data": {
"new": { "id": 1, "name": "John" },
"old": null
},
"op": "INSERT",
"session_variables": { "x-hasura-role": "admin" },
"trace_context": { "span_id": "123", "trace_id": "456" }
},
"id": "123e4567-e89b-12d3-a456-426614174000",
"table": { "name": "users", "schema": "public" },
"trigger": { "name": "insert_user" }
}
Hasura's docs print this example inside a payload key but tell you to read the new row as req.body.event.data.new.id, so event sits at the top level of the request body, as shown above.
A few details to plan for:
event.session_variablesholds thex-hasura-*variables of the user who made the change, and is only available for Postgres.- Computed fields are not included in the payload.
- An
UPDATEis only delivered if the new row is distinct from the old one. - On MS SQL Server,
UPDATEtriggers only work on tables with a primary key, and if the primary key itself changes,event.data.oldisnullbecause the old and new rows can't be correlated. delivery_andinfo.current_ retry max_retriestell you which attempt this is.
See Hasura webhook payloads in action. Inspect and replay sample Hasura webhook payloads in Hookdeck Inspect, with no account or setup required.
Setting up Hasura webhooks
Generate a long random secret (openssl rand -hex 32 works). Set it as an environment variable on your GraphQL Engine, for example EVENT_WEBHOOK_SECRET, and in your receiver's environment.
In the Console, go to the Events tab, click Create, and fill in the trigger name, the database, schema and table, the operations and the webhook URL. Then expand Advanced Settings:
- Under Listen columns for update, pick the columns whose changes should fire the trigger.
- Under Retry Logic, raise num_retries from its default of
0, otherwise a failed delivery is never retried. Set interval_sec and timeout_sec to suit your receiver. - Under Headers, add your secret header. Choose from env variable and enter the variable's name so the value is resolved by the engine and never written to metadata.
The same trigger through the Metadata API:
POST /v1/metadata HTTP/1.1
Content-Type: application/json
X-Hasura-Role: admin
{
"type": "pg_create_event_trigger",
"args": {
"name": "user_events",
"source": "default",
"table": { "schema": "public", "name": "users" },
"webhook": "https://example.com/webhooks/hasura",
"insert": { "columns": "*" },
"update": { "columns": ["email", "plan"] },
"delete": { "columns": "*" },
"retry_conf": { "num_retries": 5, "interval_sec": 30, "timeout_sec": 60 },
"headers": [
{ "name": "X-Webhook-Secret", "value_from_env": "EVENT_WEBHOOK_SECRET" }
]
}
}
Use mssql_ for MS SQL Server. In metadata files, the same retry_conf and headers go under the table's event_triggers entry, applied with hasura metadata apply.
For local development, use the Hookdeck CLI: hookdeck listen 3000 hasura --path /webhooks/hasura gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required. A public URL also sidesteps Docker networking, since the engine can reach it from inside the container.
With Hookdeck. Create a Hasura source in Hookdeck, enter the Header Name (for example X-Webhook-Secret) and the Header Value your engine sends, including any Bearer prefix, and use the source URL as the trigger's webhook URL.
Securing Hasura webhooks
Compare your secret header with the stored value. Look it up case-insensitively, compare the whole value in constant time, and check lengths first, because crypto.timingSafeEqual throws on buffers of different lengths.
const crypto = require('crypto');
const express = require('express');
const app = express();
function safeEqual(a, b) {
const x = Buffer.from(String(a));
const y = Buffer.from(String(b));
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
function verifyHasuraRequest(headers) {
const secret = process.env.HASURA_WEBHOOK_SECRET;
if (!secret) return false; // fail closed when unconfigured
const name = (process.env.HASURA_WEBHOOK_HEADER || 'X-Webhook-Secret').toLowerCase();
const received = headers[name];
return typeof received === 'string' && safeEqual(received, secret);
}
app.post('/webhooks/hasura', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyHasuraRequest(req.headers)) {
return res.status(401).json({ error: 'unauthorized' });
}
let body;
try {
body = JSON.parse(req.body.toString('utf8'));
} catch {
return res.status(400).json({ error: 'invalid json' });
}
// Hasura recommends returning a JSON object
res.status(200).json({ received: body.id });
const { op, data } = body.event;
switch (`${body.trigger.name}:${op}`) {
case 'user_events:INSERT':
// data.new is the inserted row
break;
case 'user_events:UPDATE':
// compare data.old and data.new
break;
case 'user_events:DELETE':
// data.old is the deleted row
break;
}
});
The same check in Python:
import hmac
import os
def verify_hasura_request(headers) -> bool:
secret = os.environ.get("HASURA_WEBHOOK_SECRET")
if not secret:
return False
name = os.environ.get("HASURA_WEBHOOK_HEADER", "X-Webhook-Secret")
received = headers.get(name) # case-insensitive in Starlette and Flask
if not received:
return False
return hmac.compare_digest(received.encode("utf-8"), secret.encode("utf-8"))
A static header authenticates the sender but not the body, and there's no timestamp or signature to stop a captured request being replayed. Use HTTPS for the webhook URL, prefer value_from_env over a static value in metadata, and rotate the secret by updating the environment variable on both sides if it leaks. Because the request comes from your own engine, you can also restrict the endpoint to your engine's egress addresses where your network allows it.
Make Hasura webhooks production-ready. Hookdeck Event Gateway checks your secret header on every request, stores each database event durably, and retries delivery to your service on its own schedule, so a trigger left on the default
num_retriesof0no longer loses events.
Hasura webhook limitations and pain points
Retries are off by default
The Problem: A new trigger that fails to deliver an event gives up immediately. A deploy or a brief outage loses every change made while your receiver was down.
Why It Happens: retry_conf.num_retries defaults to 0. Retries happen only if you configure them, at a fixed interval_sec.
Workarounds:
- Set
num_retriesandinterval_secon every trigger, sized to your longest expected outage. - Return a non-2xx with
Retry-Afterwhen you're overloaded, so Hasura redelivers once more after the delay. - After an incident, reconcile changed rows from the database rather than assuming every event arrived.
How Hookdeck Can Help: Hookdeck accepts each event as soon as Hasura sends it, so the trigger's attempt succeeds even when your service is down. Hookdeck then delivers to your service with automatic retries on a schedule you set, and Issues alert you when deliveries fail.
No signature on the request
The Problem: Anyone who learns your webhook URL can post a body that looks like a database change.
Why It Happens: Event Triggers support custom headers and nothing else for authentication.
Workarounds:
- Always configure a secret header, and compare it in constant time.
- Keep the value in an environment variable with
value_from_env. - For sensitive changes, re-read the row through Hasura before acting on it.
How Hookdeck Can Help: Hookdeck's Hasura source compares the header you name against the exact value you enter. A request without a matching header still gets a 200 response, but Hookdeck marks it as failed verification and creates no event, so it never reaches your destination. Every request is stored with its headers and body.
At-least-once, out-of-order delivery
The Problem: The same change can arrive twice, and an UPDATE can arrive before the INSERT it follows.
Why It Happens: Hasura says event notifications "will be delivered at least once, and may arrive out of order with respect to the underlying event."
Workarounds:
- Deduplicate on the body's
id, which Hasura describes as the invocation's UUID. - Compare
created_at, or a version orupdated_atcolumn inevent.data.new, with what you've stored before overwriting.
How Hookdeck Can Help: Hookdeck deduplication can drop repeated deliveries based on the body's id before they reach your handler.
A 60-second default timeout
The Problem: A handler that calls slow downstream services can exceed timeout_sec, and the delivery counts as a failure.
Why It Happens: Hasura closes the connection after timeout_sec seconds, 60 by default.
Workarounds:
- Acknowledge quickly and process in a background job.
- Raise
timeout_secif a handler genuinely needs longer, but prefer moving the work out of the request.
How Hookdeck Can Help: Hookdeck answers Hasura immediately and gives you a separately configured delivery timeout for your own service.
Best practices
Configure retries on every trigger
Don't rely on the default of 0. Pick num_retries and interval_sec that cover a realistic outage, and review them when you add triggers.
Keep secrets out of metadata
Use from env variable (value_from_env) for the secret header so the value lives in the engine's environment, not in YAML committed to source control.
Route on the trigger, then the operation
Use trigger.name or table.schema/table.name to identify the source of the change, and event.op to identify the change. Treat MANUAL as a re-sync of the current row.
Narrow update triggers with listen columns
Listening to every column fires the trigger on changes you don't care about. Choose only the columns your handler uses.
Acknowledge fast and make handlers idempotent
Return a 2xx with a small JSON object once the header checks out, then process asynchronously. Deduplicate on id and check row versions before writing. See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
Hasura Event Triggers send an unsigned JSON POST for each insert, update, delete or manual invocation on a table, with the old and new row in event.data. Authenticate with a custom header (ideally from an environment variable), turn on retries because they default to zero, and expect duplicates and out-of-order events.
Hookdeck Event Gateway checks the header, stores every change as it arrives and retries delivery to your service, so database events survive deploys and outages on your side.
Get started with Hookdeck for free and handle Hasura webhooks reliably in minutes.