# 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_create_event_trigger`) |
| 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:

```json
{
  "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](https://inspect.hookdeck.com/webhooks/hasura), 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:

```http
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](/docs/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.

```javascript
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:

```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](/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](/docs/retries) on a schedule you set, and [Issues](/docs/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](/docs/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](/docs/destinations#set-a-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-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](/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](https://dashboard.hookdeck.com/signup) for free and handle Hasura webhooks reliably in minutes.