Gareth Wilson Gareth Wilson

Guide to Ory Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationOry Console: Authentication > Actions & Webhooks, or project config (hook: web_hook)
FlowsRegistration, login, settings, recovery and verification, before or after
MethodConfigurable (POST in Ory's examples)
BodyA base64-encoded Jsonnet template rendered over ctx
ctxflow, request_headers, request_method, request_url, location, ip_labels, plus identity and transient_payload on after hooks
Authenticationbasic_auth (user, password) or api_key (name, value, in: header or cookie). No signature
Response handlingignore (non-blocking, async) or parse (can modify the identity or interrupt the flow)
RetriesUp to three delivery attempts, 30 seconds apart
CancellingRaising an error in the Jsonnet template cancels the request
Source IPsNot published

What a request looks like

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

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:

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:

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

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.

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:

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 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 you configure. 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 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.

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.

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 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 for free and handle Ory 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.