Docs

Ory

Ory web hook

Add the web hook to your registration flow, then check on your dashboard that it works.

Install in Ory

Ory calls a web hook after someone sends the registration form and before it creates the identity. SignupBouncer answers that hook. The hook works on Ory Network and on self-hosted Ory Kratos, for each sign-up method you add it to.

  1. Sign up at https://signupbouncer.com/signup, then create an API key for the app on its Integration tab.
  2. In the Ory Console, open Authentication, then Actions & Webhooks. Create an action for the registration flow, after, for each sign-up method you use. On self-hosted Kratos, add the web hook from the config below.
  3. Set the URL to https://signupbouncer.com/v1/hooks/ory and the method to POST.
  4. Set authentication to an API key in the Authorization header. The value is "Bearer " and then your key.
  5. Paste signupbouncer.jsonnet as the request body.
  6. Turn on the options that let the action interrupt the flow and parse its response (can_interrupt and response.parse). Ory then calls us before it stores the identity, and shows the block message on the form.
  7. Register with a test address. The check appears in your dashboard within seconds.

The request body, signupbouncer.jsonnet:

// SignupBouncer signup check. Docs: https://signupbouncer.com/docs/ory
// Body of the Ory web hook that asks SignupBouncer about a new registration (POST https://signupbouncer.com/v1/hooks/ory).
// Ory runs it with the flow's context (ctx) after the user submits the registration form, before it stores the identity.
//
// ipHeader:    the one request header we read the user's IP address from. The user can send any header, so name one
//              that the server in front of Kratos overwrites with the client's address. Ory Network: keep
//              True-Client-Ip, the header on Ory's default allowlist. Self-hosted Kratos: name the header your proxy
//              overwrites (X-Real-Ip behind nginx with `proxy_set_header X-Real-Ip $remote_addr`, Cf-Connecting-Ip
//              behind Cloudflare) and add it to `clients.web_hook.header_allowlist`, because Ory passes only the
//              headers on that list. Set it to null to send no IP. Without an IP, an identity with no email is let
//              through unchecked.
// email:       the identity's `email` trait. If your identity schema names it differently, change `traits.email`.
// identity_id: Ory's id for the identity (not set yet while the hook can still refuse it).
// flow_id:     the registration flow's id: lets us tell Ory's retry of a call from a new registration when there
//              is no email (a phone identity), so a retry is one check, charged once.
//
// Every value is cut to a fixed length, because the user controls them: the whole body stays far below the 64 KB we
// read, and an oversized body would be let through unchecked.
local ipHeader = 'True-Client-Ip';

function(ctx)
  local cut(v, n) = if std.isString(v) then std.substr(v, 0, n) else null;
  local traits = if std.isObject(ctx.identity.traits) then ctx.identity.traits else {};
  local headers = if std.objectHas(ctx, 'request_headers') && std.isObject(ctx.request_headers) then ctx.request_headers else {};
  // Header names in any case: Kratos passes them in canonical form (X-Real-Ip), ipHeader may say X-Real-IP.
  local header(name) =
    local keys = [k for k in std.objectFields(headers) if std.asciiLower(k) == std.asciiLower(name)];
    if std.length(keys) > 0 && std.isArray(headers[keys[0]]) && std.length(headers[keys[0]]) > 0 then headers[keys[0]][0] else null;
  {
    email: if std.objectHas(traits, 'email') then cut(traits.email, 320) else null,
    ip: if std.isString(ipHeader) then cut(header(ipHeader), 64) else null,
    user_agent: cut(header('User-Agent'), 512),
    identity_id: cut(ctx.identity.id, 100),
    flow_id: if std.objectHas(ctx, 'flow') && std.isObject(ctx.flow) && std.objectHas(ctx.flow, 'id') then cut(ctx.flow.id, 100) else null,
  }

On self-hosted Kratos, add the hook to your config, and put the Jsonnet file where Kratos can read it:

selfservice:
  flows:
    registration:
      after:
        password: # and oidc, code, passkey: one copy per sign-up method you use
          hooks:
            - hook: web_hook
              config:
                url: https://signupbouncer.com/v1/hooks/ory
                method: POST
                body: file:///etc/config/kratos/signupbouncer.jsonnet
                can_interrupt: true
                response:
                  parse: true
                auth:
                  type: api_key
                  config:
                    name: Authorization
                    value: Bearer sb_live_...
                    in: header
            - hook: session # keep the hooks you already have after it

With Docker Compose, you can keep your API key out of the Kratos config. Leave out the value line, and put the key in .env:

# SignupBouncer API key from your app's Integration tab at https://signupbouncer.com/app/integration?via=ory#keys
SIGNUPBOUNCER_API_KEY=sb_live_***

Then pass the key to Kratos as the hook's Authorization value, in the environment list of the Kratos service in compose.yml:

      - SELFSERVICE_FLOWS_REGISTRATION_AFTER_PASSWORD_HOOKS_0_CONFIG_AUTH_CONFIG_VALUE=Bearer ${SIGNUPBOUNCER_API_KEY:?put your sb_live_ key in .env}

Kratos reads any config value from an environment variable. The 0 in the variable's name stands for the first hook in the list, so keep our hook first.

What your users see

In enforce mode, we refuse a signup marked block with your block message. Ory shows the message above the registration form and creates no identity. While you keep our default message, we word it for the reason, as in the Supabase hook.

By default, we allow challenged signups. With "Treat as block" on the Rules tab, we refuse them. We still allow one whose score has any velocity points, unless a check you set to "Always challenge" made the challenge.

If SignupBouncer is down

When our server hits an error, is overloaded or takes longer than 1.2 seconds, it answers allow and the registration goes through unchecked. A missing or wrong API key is answered the same way.

The hook URL goes through a small Cloudflare Worker, as the Supabase hook does. When our server is down, restarting or takes longer than 1.5 seconds, the Worker answers allow and the registration goes through unchecked. If Cloudflare fails, or Ory cannot reach the Worker, Ory refuses the registration, as it does for any hook it cannot reach.

Which signups we check

We check signups from each sign-up method you add the action to. We look at the identity's email trait, and the IP when Ory passes one.

The Jsonnet reads the IP from the header named on its ipHeader line. On Ory Network, keep True-Client-Ip. On self-hosted Kratos, name the header your proxy overwrites with the client's address, such as X-Real-Ip, and add it to clients.web_hook.header_allowlist, the list of headers Ory passes to the hook.

The person signing up can set any header your proxy doesn't overwrite, so they could send a false IP in it. Without an IP, an identity without an email, such as a phone signup, goes through unchecked.

If you run self-hosted Kratos and copied the Jsonnet before Oct 22, 2026, copy it again and set ipHeader to your proxy's header. The older copy took the IP from True-Client-Ip first whenever your allowlist had that header, and the person signing up can set that header themselves.

We tested the hook with password registrations on self-hosted Ory Kratos v26.2.0. We have not tested other sign-up methods or Ory Network yet.

Check that the Ory hook works

Register with an address at mailinator.com. Within seconds, the signup shows on the Checks tab of your dashboard.

The label in the Result column says what the score marked the signup: Allow, Challenge or Block. When the hook did something else with the signup, a line under the label says so. In shadow mode, the mailinator.com signup shows Block with "Let through, Shadow" under it, and Ory created the identity all the same.

In the REST API, verdict holds the label and action holds what to do with the signup. A reason with a dashed border counts only on a higher plan, so it adds no points to your score.

If no check appears

  • You registered with a sign-up method that has no action. Each method, such as password or social sign-in, needs an action of its own. In the Ory Console, open Authentication, then Actions & Webhooks, and look for an action with our URL on the registration flow, after, for that method.
  • The API key is missing, wrong or revoked. We let those registrations through unchecked and log nothing. Create a key on the Integration tab of the app you're looking at, and paste it with "Bearer " in front.
  • The API key belongs to another of your apps. We log the check under that app, so open that app's Checks tab.
  • The identity has no email, and Ory passed no IP. We have nothing to check, so the registration goes through with no check.
  • Your test didn't create an identity. We check registrations only, so signing in to an account that already exists adds no check.

Unchecked signups

On the Free plan, after 1,000 checks in a month, the Result column shows Not checked, with "Over quota" under it. Ory creates those identities without a check until the month ends.

On a monthly paid plan, signups go through unchecked once you reach your cap on extra checks. On a yearly plan, they go through unchecked once you've used the allowance.

In enforce mode, the Result "Let through, answer too late" means our answer came after the hook's time limit. Your Checks tab misses signups during our outages.

In enforce mode, Ory creates no identity for a signup marked Block. If Ory creates one and no line under the label says why, turn on can_interrupt and response.parse in the action.

Turn the hook off

Ory creates every identity unchecked once you delete the SignupBouncer action. Remove the action straight away if registrations fail because Ory can't reach our hook. Your SignupBouncer app keeps its settings and its check log.

  1. In the Ory Console, open Authentication, then Actions & Webhooks. Find each action whose URL is https://signupbouncer.com/v1/hooks/ory, and delete it from the ⋮ menu at the end of its row.
  2. On self-hosted Kratos, open your config. Under selfservice.flows.registration.after, delete the web_hook entry with our URL from the hooks of each sign-up method, such as password. Keep the session hook and any other hooks after our entry.
  3. If you pass the API key to Kratos in compose.yml, delete the SELFSERVICE_FLOWS_REGISTRATION_AFTER_PASSWORD_HOOKS_0_CONFIG_AUTH_CONFIG_VALUE line from it too. Delete the line before you restart Kratos. With our web_hook entry gone, the 0 in the variable's name points at the session hook, and Kratos refuses to start.
  4. Restart self-hosted Kratos. With Docker Compose, run docker compose up -d, which starts Kratos with the changed environment list. A docker compose restart keeps the old environment list.
  5. Register with a test address. Ory creates the identity, and your Checks tab shows no new check.

To check registrations again, add the SignupBouncer action again.

Ory never created identities for the signups we refused, so removing the hook doesn't restore them. Those people have to register again.