# Guide to Ory Webhooks: Features and Best Practices

Ory is an identity and authentication platform: Ory Network, and the open-source Kratos it is built on, run registration, login, account settings, recovery and verification for your users. Ory Actions let those flows call out to your own systems, so a new sign-up can reach your CRM or a changed email address can update your billing records without polling the identity API.

This guide covers how Ory Actions webhooks are configured, the difference between blocking and non-blocking webhooks, how to shape the body with Jsonnet, how to authenticate requests with an `api_key` header, and the practices that keep an Ory receiver reliable in production.

## What are Ory webhooks?

An Ory Action is a hook that runs before or after a self-service flow. A webhook action makes an HTTP request to a URL you choose, with a method, authentication and a body you configure. For the `after` phase of the login, registration and settings flows, you can also pick which authentication methods trigger the webhook.

Ory webhooks come in two kinds, and the difference matters:

* **Non-blocking** webhooks set `response.ignore: true`. Ory sends the request, runs it asynchronously and ignores the response. This is the kind for notifying other systems, such as adding a new user to a newsletter tool.
* **Blocking** webhooks wait for your response. With `response.parse: true`, your response can modify the identity before it is saved or interrupt the flow with validation messages shown to the user.

The body is a Jsonnet template you write over a `ctx` object, so Ory defines no event list and adds no event field. A webhook knows which flow fired it only because you configured it on that flow.

Ory does not sign webhook requests. You can configure `basic_auth` with a username and password, or `api_key` with a header name and value of your choice, and Ory sends those credentials with every request.

## Ory webhook features

| Feature | Details |
| --- | --- |
| Configuration | Ory Console: **Authentication > Actions & Webhooks**, or project config (`hook: web_hook`) |
| Flows | Registration, login, settings, recovery and verification, before or after |
| Method | Configurable (`POST` in Ory's examples) |
| Body | A base64-encoded Jsonnet template rendered over `ctx` |
| `ctx` | `flow`, `request_headers`, `request_method`, `request_url`, `location`, `ip_labels`, plus `identity` and `transient_payload` on `after` hooks |
| Authentication | `basic_auth` (user, password) or `api_key` (name, value, `in: header` or `cookie`). No signature |
| Response handling | `ignore` (non-blocking, async) or `parse` (can modify the identity or interrupt the flow) |
| Retries | Up to three delivery attempts, 30 seconds apart |
| Cancelling | Raising an error in the Jsonnet template cancels the request |
| Source IPs | Not published |

## What a request looks like

You write the body. Ory's simplest example sends the new identity's ID:

```jsonnet
function(ctx) { user_id: ctx.identity.id }

```

A more useful template for a non-blocking `after` registration hook names the event, since Ory won't, and sends the traits you need:

```jsonnet
function(ctx) {
  event: 'registration.after',
  identity_id: ctx.identity.id,
  email: ctx.identity.traits.email,
}

```

`event` here is your own convention. Traits depend on your identity schema, and `ctx.identity` is only bound for `after` hooks, so a template for a `before` hook has to use `ctx.flow` and the request fields instead. Only a small set of request headers is exposed by default.

To skip the request for some users, raise an error in the template. Ory's example cancels the webhook for test accounts:

```jsonnet
function(ctx)
  if std.startsWith(ctx.identity.traits.email, "test-") then
    error "cancel"
  else
    { user_id: ctx.identity.id }

```

> **Inspect Ory webhooks as they arrive.** Point an action at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to see the body your Jsonnet template renders and the headers Ory sends, with no account or setup required.

## Setting up Ory webhooks

Generate a long random secret first, for example with `openssl rand -hex 32`, and decide which header it will travel in.

1. In the Ory Console, go to **Authentication > Actions & Webhooks** and click **+ Create new Action**.
2. Choose the flow and when the action runs, and for `after` login, registration or settings, the authentication methods that trigger it.
3. Enter your endpoint URL and the HTTP method, and click **Next**.
4. Set authentication to API key: the header name, such as `X-Ory-Secret`, your secret as the value, and header (not cookie) as the location. Whatever you enter is sent as-is, so include `Bearer ` if you use `Authorization` and want a bearer token.
5. Enter your Jsonnet body template.
6. Make the webhook non-blocking (`response.ignore: true`) and click **Create action**.

In project configuration, the same webhook looks like this:

```yaml
hook: web_hook
config:
  url: https://example.com/webhooks/ory
  method: POST
  body: base64://ENCODED_JSONNET
  response:
    ignore: true
  auth:
    type: api_key
    config:
      name: X-Ory-Secret
      value: YOUR_SECRET
      in: header

```

To put Hookdeck in front of your service, create a Hookdeck source with the **Ory** type and use its URL as the webhook URL. The source asks for an **API Key Header** and an **API Key**: enter the same name and the whole value you set in Ory. Header names match case-insensitively, but the value must match exactly, prefix included. The source pre-selects `POST`. If your action uses another method, allow it on the source too, or Hookdeck will not accept the requests.

Only route non-blocking webhooks through Hookdeck. Hookdeck answers Ory with its own response, not your service's, so a blocking webhook that expects your response to change the identity or stop the flow can't work through it. The source checks `api_key` credentials sent in a header. A webhook using `basic_auth`, or an `api_key` sent as a cookie, won't pass verification on this source.

For local development, `hookdeck listen 3000 ory --path /webhooks/ory` gives you a public URL that forwards to your machine.

## Securing Ory webhooks

Read the header you chose and compare its whole value with your stored secret in constant time. Reject the request if the header is missing or the secret isn't configured.

```javascript
const crypto = require('crypto');
const express = require('express');

const HEADER_NAME = process.env.ORY_WEBHOOK_HEADER || 'x-ory-secret';
const SECRET = process.env.ORY_WEBHOOK_SECRET;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// The action's method is configurable, so accept the ones you use.
app.all('/webhooks/ory', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get(HEADER_NAME); // case-insensitive lookup
  if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }

  const raw = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '';
  let payload = {};
  try {
    payload = raw ? JSON.parse(raw) : {};
  } catch {
    return res.sendStatus(400);
  }

  // Route on the event key your Jsonnet template sets.
  res.sendStatus(200); // Non-blocking webhooks ignore the response.
});

```

The same check in Python:

```python
import hmac
import os

HEADER_NAME = os.environ.get("ORY_WEBHOOK_HEADER", "X-Ory-Secret")
SECRET = os.environ.get("ORY_WEBHOOK_SECRET", "")

def verify_ory_request(headers) -> bool:
    if not SECRET:
        return False  # never accept when unconfigured
    received = headers.get(HEADER_NAME)  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))

```

A static key proves the sender knows the secret. It does not cover the body and carries no timestamp, so a modified or replayed request still passes, and identity data is exactly what an attacker would want to fake. Serve the endpoint over HTTPS, use a long random value, and rotate it if it might have leaked.

> **Make Ory webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `api_key` header on non-blocking webhooks, stores every request, and keeps retrying delivery to your service after Ory's three attempts are spent.

## Ory webhook limitations and pain points

### Three attempts, 30 seconds apart

**The Problem:** An outage or deploy longer than about a minute loses non-blocking webhook requests, such as the sign-up that should have reached your CRM.

**Why It Happens:** Ory attempts delivery up to three times with a 30-second delay between attempts. A non-blocking webhook's response is ignored, so the user's flow continues either way and nothing surfaces the loss.

**Workarounds:**

* Keep the endpoint highly available and fast.
* Reconcile periodically against the Ory identities API for anything that must not be missed.

**How Hookdeck Can Help:** Hookdeck accepts each request as it arrives and stores it, then delivers to your service with [retries](/docs/retries) you configure. [Issues](/docs/issues) alert you when deliveries fail, and you can replay any request after a fix.

### A static key instead of a signature

**The Problem:** Anyone who learns the key can send identity events your endpoint will accept.

**Why It Happens:** Ory's webhook authentication is `basic_auth` or `api_key`. Both are static, and nothing is computed over the body.

**Workarounds:**

* Use a long random value over HTTPS only, and keep it out of logs.
* For sensitive actions, look the identity up through the Ory API by ID before acting on the webhook's contents.

**How Hookdeck Can Help:** Hookdeck's Ory source compares the header you name with the exact value you enter. Hookdeck still answers with a `200`, but a request with a missing or wrong value is recorded as failed verification and never reaches your destination.

### Blocking webhooks couple sign-in to your uptime

**The Problem:** A slow or failing endpoint behind a blocking webhook slows down or breaks registration and login for your users.

**Why It Happens:** A blocking webhook waits for your response, and with `response.parse` the flow depends on it. Ory reports timeouts and failed deliveries as flow errors such as `webhook_timeout` and `webhook_request_failed`.

**Workarounds:**

* Use non-blocking webhooks for anything that is a notification, and keep blocking webhooks for checks that genuinely need to stop a flow.
* Keep blocking endpoints small and fast.

**How Hookdeck Can Help:** For notifications, a non-blocking webhook through Hookdeck keeps your users' sign-in path independent of your downstream services, since Hookdeck stores the request and handles delivery from there.

### No event name unless you add one

**The Problem:** One endpoint receiving several actions can't tell a registration from a settings change.

**Why It Happens:** The body is only what your Jsonnet template renders, and Ory adds no event header.

**Workarounds:**

* Put an `event` key in every template, or give each action its own path.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route requests by a body field or path, so each flow's webhooks reach the service that handles them.

## Best practices

### Default to non-blocking

Set `response.ignore: true` on every webhook that only informs another system. Your users' flows stay fast, and your endpoint's availability stops being part of your login path.

### Deduplicate on identity and event

Retries mean the same request can arrive more than once. Include `ctx.flow.id` in the template, build a key from it, the event name and `ctx.identity.id`, and store it before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Send only what you need

`ctx.identity` includes every trait. Template only the fields your receiver uses, which keeps personal data out of places it doesn't need to be.

### Acknowledge fast

Return `200` once the key checks out and process the request from a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Ory Actions call your webhook from identity flows with a body you write in Jsonnet, authenticated by a static `basic_auth` or `api_key` credential and retried only three times. Use non-blocking webhooks for notifications, send the key in a header and compare its whole value in constant time, name the event in your template, and deduplicate.

[Hookdeck Event Gateway](https://hookdeck.com) checks the `api_key` header on non-blocking webhooks and holds each request until your service has processed it, so an outage on your side doesn't lose sign-ups.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Ory webhooks reliably in minutes.