Gareth Wilson Gareth Wilson

Guide to Hasura Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationConsole (Events tab), metadata YAML with hasura metadata apply, or the Metadata API (pg_create_event_trigger, mssql_create_event_trigger)
DatabasesPostgres and MS SQL Server
OperationsINSERT, UPDATE, DELETE, MANUAL
Update filteringListen columns: only changes to selected columns fire the trigger
MethodPOST with Content-Type: application/json
AuthenticationCustom headers, static or value_from_env
SignatureNone
Delivery guaranteeAt least once, possibly out of order
Retriesretry_conf: num_retries (default 0), interval_sec (default 10)
Timeouttimeout_sec, default 60 seconds
SuccessAny 2xx status
Retry-AfterOn 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.opFires whenevent.data.oldevent.data.new
INSERTA row is insertednullThe inserted row
UPDATEA row is updated and the new data differs from the oldValues before the updateValues after the update
DELETEA row is deletedThe deleted rownull
MANUALThe trigger is invoked manuallynullThe 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_variables holds the x-hasura-* variables of the user who made the change, and is only available for Postgres.
  • Computed fields are not included in the payload.
  • An UPDATE is only delivered if the new row is distinct from the old one.
  • On MS SQL Server, UPDATE triggers only work on tables with a primary key, and if the primary key itself changes, event.data.old is null because the old and new rows can't be correlated.
  • delivery_info.current_retry and max_retries tell 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_create_event_trigger 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_retries of 0 no 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_retries and interval_sec on every trigger, sized to your longest expected outage.
  • Return a non-2xx with Retry-After when 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 or updated_at column in event.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_sec if 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.