New Ory Agent Security is now live! Claim your complimentary test drive. Get Started!

Skip to main content

Trigger custom logic and integrate with external systems with webhooks

Ory Actions supports webhooks, which are HTTP callbacks that can be triggered by specific events in your Ory-powered application. Webhooks allow Ory Actions to notify external systems, such as Hubspot, Mailchimp, when certain events occur, such as a user registration or profile update. Aside from integrating with external systems, webhooks allow you to use custom, external business logic in Ory Actions.

Should you use webhooks? They work perfectly for use cases like these:

Integration with third-party systems

If you want to integrate your Ory Actions-powered application with another system, such as a CRM, an email marketing platform, or an analytics tool, you can use webhooks to trigger updates in that system when events occur in your Ory Actions application.

Automated workflows

You can use webhooks to automate workflows and processes that span multiple systems. For example, you can use webhooks to automatically add a new lead to your CRM when a user registers in your application.

User data modification

You can use webhooks to modify user data when certain events occur. For example, you can use webhooks to update user data in your application when a user updates their profile information in another system.

Passing additional data on registration

When building your own UI you can pass additional data when completing the registration flow that gets forwarded to any configured webhooks. This can be used to solve more complex use cases that would be out of scope for identity traits.

danger

Always put security first! When using webhooks, ensure that the data you send is secure and that the external system you are integrating with is trustworthy. Additionally, make sure to consider the data privacy laws and regulations that may apply to your use case.

tip

If you would like to configure IP allow list in your firewall setup for webhooks please read about our Ory Network IP Addresses

Creating Actions on the Ory Network​

The Ory Network Console offers a simple and intuitive tool to manage your Actions.

Follow the steps below:

  1. Navigate to Authentication → Actions & Webhooks in the Ory Console. There, click on + Create new Action.
  2. Choose the flow type that best suits your need and determine the execution time. For the after phase of the login, registration, and settings flows you can also select one or more authentication methods that trigger the webhook. Provide the endpoint URL and HTTP method.
  3. Click on Next. The ensuing screen allows you to establish authentication parameters and input request body details. To learn more about tailoring your request body, see the relevant section below.
  4. Once you have all adjusted settings, click on Create action. Your new Ory Action has now been created and is ready to operate.
note

A single webhook can target several authentication methods at once. The legacy Fallback option is deprecated: a fallback webhook only runs when no method-specific hook (built-in or webhook) exists for the flow, so prefer selecting explicit methods.

Configuration overview​

Ory Action webhooks have several configuration options:

hook: web_hook # To use webhooks, you must set 'hook' to 'web_hook'
config:
url: https://test.hook.site.sh/before_login_hook # Webhook URL.
method: POST # HTTP method used to send request to the webhook URL.
body: base64://ENCODED_JSONNET # Encoded Jsonnet template used to render payload.
response:
ignore: false # Defines if the webhook response should be ignored and run async. Boolean. OPTIONAL
parse: false # Defines if the webhook response should be parsed and interpreted. Boolean. OPTIONAL
auth:
type: # Can be one of 'basic_auth' or 'api_key'
config: # Additional auth config for the hook. Read next section for details.

Define HTTP request​

Webhooks trigger HTTP requests to the webhook URL. You can configure the request method, URL, authorization, and body.

Customizing request body with Jsonnet​

Webhooks bind the flow, as well as request headers (request_headers), request method (request_method), the request URL (request_url), and the caller's approximate location (location) of the flow into the Jsonnet template for all methods and execution paths (before and after). For the after execution path of all flows, it binds the identity and the transient_payload object into the Jsonnet template as well. These objects are available through a ctx object.

Accessing request headers​

Request headers are available via ctx.request_headers. By default, only the following headers are exposed:

Accept
Accept-Encoding
Accept-Language
Content-Length
Content-Type
Origin
Priority
Referer
Sec-Ch-Ua
Sec-Ch-Ua-Mobile
Sec-Ch-Ua-Platform
Sec-Fetch-Dest
Sec-Fetch-Mode
Sec-Fetch-Site
Sec-Fetch-User
True-Client-Ip
User-Agent
X-Forwarded-Host
Ory-Base-Url-Rewrite
Ory-Base-Url-Rewrite-Token
X-Ory-Original-Host
Ory-No-Custom-Domain-Redirect
Cf-Ipcountry

To customize allowed headers, use the actions.web_hook.header_allowlist configuration option.

Accessing the caller's location​

The caller's approximate location is available via ctx.location:

FieldTypeExample
citystringMunich
regionstringBY
countrystringDE
latitudenumber or null48.117634
longitudenumber or null11.603917

ctx.location and all of its fields are always present, so you can read ctx.location.country without checking first. When Ory can't determine a value, strings are empty and coordinates are null:

function(ctx) {
country: ctx.location.country,
// Guard before doing arithmetic: coordinates can be null.
coordinates: if ctx.location.latitude != null then [ctx.location.latitude, ctx.location.longitude],
}

std.prune removes null values, so a pruned payload drops latitude and longitude when they're unknown.

There are two ways to block a flow based on the location. Raise an error in the template, which fails the hook without calling the webhook target. This example limits sign-ups to a bounding box around the DACH region. It reads only ctx.location and ctx.flow, so it works on the registration before position, where ctx.identity isn't bound yet:

function(ctx)
local loc = ctx.location;
local inRegion =
loc.latitude != null && loc.longitude != null &&
loc.latitude >= 45.8 && loc.latitude <= 55.1 &&
loc.longitude >= 5.9 && loc.longitude <= 17.2;
if !inRegion then
error 'sign-up is not available near ' + (if loc.city != '' then loc.city else 'your location')
else
{ flow_id: ctx.flow.id, country: loc.country }

Decide deliberately what an unknown location means for your rule. This example rejects the flow when the coordinates are null, which also rejects every caller on a deployment where nothing sets the geolocation headers. Invert the check to allow those callers through instead.

Use any error message other than "cancel". Raising error "cancel" skips the webhook and lets the flow continue, as described in Canceling webhooks.

Alternatively, send the location to your webhook target and let it decide. The target rejects the flow by responding with a 4xx status and a message payload, as described in Flow-interrupting webhooks.

Unlike ctx.request_headers, ctx.location isn't filtered by the actions.web_hook.header_allowlist setting.

On the Ory Network, Ory resolves the location at the edge. You don't have to configure anything.

On self-hosted deployments, including the Ory Enterprise License, there is no such edge and Ory ships no geolocation database. Ory reads the values from the Cf-* request headers, so filling them is your responsibility: the fields stay empty until a reverse proxy in front of Ory sets them. Use a proxy that can resolve an IP address to a location, such as nginx with the ngx_http_geoip2 module and a MaxMind database.

Expand to see an example nginx configuration.
geoip2 /etc/nginx/GeoLite2-City.mmdb {
$geoip2_city city names en;
$geoip2_subdivision_code subdivisions 0 iso_code;
$geoip2_country_code country iso_code;
$geoip2_latitude location latitude;
$geoip2_longitude location longitude;
}

server {
location / {
proxy_set_header Cf-Ipcity $geoip2_city;
proxy_set_header Cf-Region-Code $geoip2_subdivision_code;
proxy_set_header Cf-Ipcountry $geoip2_country_code;
proxy_set_header Cf-Iplatitude $geoip2_latitude;
proxy_set_header Cf-Iplongitude $geoip2_longitude;

proxy_pass http://kratos;
}
}

Set all five headers this way, including any you can't resolve. proxy_set_header replaces whatever the client sent, and nginx omits a header whose value is empty, so a caller can't inject their own location. If you skip a header instead, the value the client sent reaches Ory unchanged, and you must not use these fields for authorization decisions.

Jsonnet templating​

To send { user_id: {some-id} } in the request body, create the following the Jsonnet template:

function(ctx) { user_id: ctx.identity.id }
Expand to see an example of all properties of the ctx object for a registration flow.
{
"ctx": {
"flow": {
"expires_at": "2023-01-31T12:19:35.782238Z",
"id": "cec1c06e-48eb-4f9d-abf1-2e287371f4eb",
"issued_at": "2023-01-31T11:19:35.782238Z",
"oauth2_login_challenge": null,
"request_url": "https://playground.projects.oryapis.com/self-service/registration/browser?return_to=",
"transient_payload": {
"custom_data": "test"
},
"type": "browser",
"ui": {
"action": "http://localhost:4455/self-service/registration?flow=cec1c06e-48eb-4f9d-abf1-2e287371f4eb",
"method": "POST",
"nodes": [
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden",
"value": "P91A1RzvL4xHAls2Gl76cbaXVMhBdpAj3c4vaRMckYY7JmGswmBHuul/+mZguOsQUOBmeJMOJWoa5xY2bd81CQ=="
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "email",
"disabled": false,
"name": "traits.email",
"node_type": "input",
"required": true,
"type": "email"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1070002,
"text": "Your E-Mail",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"autocomplete": "new-password",
"disabled": false,
"name": "password",
"node_type": "input",
"required": true,
"type": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"node_type": "input",
"type": "submit",
"value": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"context": {},
"id": 1040001,
"text": "Sign up",
"type": "info"
}
},
"type": "input"
}
]
}
},
"identity": {
"created_at": "0001-01-01T00:00:00Z",
"id": "3a5293f1-f4d6-49f4-b34f-6da62c360604",
"metadata_public": null,
"recovery_addresses": [
{
"created_at": "0001-01-01T00:00:00Z",
"id": "00000000-0000-0000-0000-000000000000",
"updated_at": "0001-01-01T00:00:00Z",
"value": "[email protected]",
"via": "email"
}
],
"schema_id": "default",
"schema_url": "",
"state": "active",
"state_changed_at": "2023-01-31T11:19:37.096674Z",
"traits": {
"email": "[email protected]"
},
"updated_at": "0001-01-01T00:00:00Z",
"verifiable_addresses": [
{
"created_at": "0001-01-01T00:00:00Z",
"id": "00000000-0000-0000-0000-000000000000",
"status": "pending",
"updated_at": "0001-01-01T00:00:00Z",
"value": "[email protected]",
"verified": false,
"via": "email"
}
]
},
"location": {
"city": "Munich",
"region": "BY",
"country": "DE",
"latitude": 48.117634,
"longitude": 11.603917
},
"request_cookies": {
"__cypress.initial": "true",
"csrf_token_9eaa18484d26154fc69bb6b144100bec9a99a10709387620e8e4f0b7395e97ec": "BPshed6PaDaufaFQeuYRYeZ3MrDSeLVJxyk5X37DpI8="
},
"request_headers": {
"Accept": [
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
],
"Accept-Encoding": ["gzip"],
"Accept-Language": ["en-GB,en-US;q=0.9,en;q=0.8"],
"Cache-Control": ["max-age=0"],
"Connection": ["keep-alive"],
"Content-Length": ["180"],
"Content-Type": ["application/x-www-form-urlencoded"],
"Cookie": [
"csrf_token_9eaa18484d26154fc69bb6b144100bec9a99a10709387620e8e4f0b7395e97ec=BPshed6PaDaufaFQeuYRYeZ3MrDSeLVJxyk5X37DpI8=; __cypress.initial=true"
],
"Origin": ["http://localhost:4455"],
"Proxy-Connection": ["keep-alive"],
"Referer": ["http://localhost:4455/registration?flow=cec1c06e-48eb-4f9d-abf1-2e287371f4eb"],
"Sec-Ch-Ua": ["\"Not_A Brand\";v=\"99\", \"Google Chrome\";v=\"109\", \"Chromium\";v=\"109\""],
"Sec-Ch-Ua-Mobile": ["?0"],
"Sec-Ch-Ua-Platform": ["\"macOS\""],
"Sec-Fetch-Dest": ["iframe"],
"Sec-Fetch-Mode": ["navigate"],
"Sec-Fetch-Site": ["same-origin"],
"Upgrade-Insecure-Requests": ["1"],
"User-Agent": [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"
]
},
"request_method": "POST",
"request_url": "https://playground.projects.oryapis.com/self-service/registration?flow=cec1c06e-48eb-4f9d-abf1-2e287371f4eb"
}
}

Use the ctx object to access these fields in your Jsonnet template, for example:

./segment_identify.jsonnet
function(ctx) {
userId: ctx.identity.id,
customData: ctx.flow.transient_payload.custom_data
traits: {
email: ctx.identity.traits.email,
name: ctx.identity.traits.name,
newsletterConsent: ctx.identity.traits.consent.newsletter,
},
}

Request authentication​

Webhooks can be configured to use HTTP Basic Authentication or API Key authentication.

  • API key authentication. Type must be set to api_key. All properties are mandatory.

    hook: web_hook
    # other configuration keys
    config:
    auth:
    type: api_key
    config:
    name: Authorization
    value: { API Key value }
    in: header # alternatively "cookie"
  • "Basic Authentication" type authentication. Type must be set to basic_auth. All properties are mandatory. For basic_auth the config looks as follows:

    hook: web_hook
    # other configuration keys
    config:
    auth:
    type: basic_auth
    config:
    user: { USERNAME }
    password: { YOUR-PASSWORD }

Canceling webhooks​

You can cancel configured webhooks and not make the defined request.

This can come in handy in testing. For example, by canceling a webhook, you prevent the test accounts created during testing from being added to CRM.

To get this behavior, cancel the webhook by raising an error for all accounts with the test- name prefix:

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

Jsonnet doesn't support regular expressions yet. Follow this issue to see if the feature has been implemented: google/go-jsonnet/409.

Webhook response handling​

Webhooks can be configured to ignore the response, or to parse the response and use it to interrupt the flow or to update the identity.

Non-blocking / async webhooks​

Sometimes you need to interact with external systems without needing their response. For example, when a user signs up, a webhook is that adds their email address to the newsletter is triggered.

If you decide that the system response that indicates if the process was successful is not critical for your setup, make the webhook execution non-blocking to ignore whatever response is returned.

To do that, set response.ignore to true in the webhook config:

hook: web_hook
config:
response:
ignore: true

Modify identities​

If you want the result of webhook execution to modify the identity, you can configure the webhook to parse the response:

hook: web_hook
config:
response:
parse: true

When the webhook target returns a 200 OK response code and the response body is a JSON object with the key identity in it, the values from that object will be used to change the identity before it is saved to the database. Modifying the identity is only possible during registration and settings flows.

When updating any of the identity fields, be aware that the whole field is replaced by the value returned by the webhook. For example, if the user signed up with

Identity before hook
{
"identity": {
"traits": {
"email": "[email protected]"
}
}
}

and the webhook returns

Incorrect webhook response
{
"identity": {
"traits": {
"another_value": "example"
}
}
}

the identity will no longer have the email trait, which will break the sign up flow. Always make sure that the webhook returns complete data:

Correct webhook response
{
"identity": {
"traits": {
"email": "[email protected]",
"another_value": "example"
}
}
}

Update identity traits​

When the webhook target returns a 200 OK response code and the response body is a JSON object with the key identity.traits in it, the values from that object will be used to replace the identity traits before they are saved to the database. This method replaces the entire identity traits object. It isn't possible to update only a single trait.

{
"identity": {
"traits": {
"email": "[email protected]",
"the_webhook": "updated this value"
}
}
}

Update identity metadata​

When the webhook target returns a 200 OK response code and the response body is a JSON object with the key identity.metadata_public or identity.metadata_admin in it, the values from that object will be used to replace the identity metadata fields before they are saved to the database. This method replaces the entire metadata object. Partial updates to values in metadata_public or metadata_admin aren't supported.

{
"identity": {
"metadata_public": {
"the_webhook": "changed this value",
"and_added_this_one": "too"
},
"metadata_admin": {
"the_webhook": "updated this value",
"and_this_one": "too"
}
}
}

Update verification or recovery addresses​

When the webhook target returns a 200 OK response code and the response body is a JSON object with the key identity.verifiable_addresses in it, the values from that object will be used to replace the identity verification addresses before they are saved to the database.

Verification and recovery addresses are taken from the identity.traits object using the Identity Schema. If you add additional verification or recovery addresses, these will be deleted unless the identity traits also contain that address.

{
identity: {
traits: {
},
verifiable_addresses: [
{
status: "completed",
verified: true,
via: "email",
},
],
recovery_addresses: [
{
via: "email",
},
],
},
}

Update the identity's external ID​

When the webhook target returns a 200 OK response code and the response body is a JSON object with the key identity.external_id in it, the value from that object will be used to replace the identity's external ID before it is saved to the database. This method replaces the previous external ID, if any.

{
"identity": {
"external_id": "external-id-from-webhook"
}
}

Require a second factor​

A webhook on the login after position can require the user to reach AAL2 for that sign-in. Use this when an external risk engine decides that a login looks risky and should be confirmed with a second factor.

ctx.location is a common input for that decision — for example, stepping up a sign-in from a country the account has never used before. Decide what an unresolvable location means for your rule: an empty country is not a match for any country you compare it against, so a rule written as "step up unless the country is known-good" steps up, while "step up only when the country is known-bad" doesn't.

This is off by default. Turn it on first under Project settings → Advanced in the Ory Console by enabling the webhook_response_directives feature flag, or in the configuration:

feature_flags:
webhook_response_directives: true

The webhook must parse the response. Return required_aal with the value aal2:

{
"required_aal": "aal2"
}

What the user sees depends on what they have set up:

  • A user with a second factor is sent to the two-factor login screen and finishes signing in there.
  • A user without a second factor is sent to the settings flow to set one up. They are never let through without it.

You can update the identity in the same response:

{
"identity": {
"metadata_admin": { "risk": "high" }
},
"required_aal": "aal2"
}

aal2 is the only supported value — any other value fails the login rather than being quietly dropped. required_aal returned from a webhook on another position is logged and ignored, because there is no login to require a second factor for. While the feature flag is off, required_aal is ignored everywhere.

Require sign-in with a specific OpenID Connect provider​

A webhook on the login after position can require the user to sign in again with one specific OpenID Connect provider. Use this when an external check has to confirm who the user is before they can continue — for example a bank ID broker or a provider that verifies government-issued IDs.

This uses the same feature flag as required_aal. Turn on the webhook_response_directives feature flag first:

Go to Project settings → Advanced in the Ory Console and enable the webhook_response_directives feature flag.

The webhook must parse the response. Return force_credential with the method and the ID of the provider from your social sign-in configuration:

{
"force_credential": {
"method": "oidc",
"provider": "my-oidc-provider"
}
}

oidc is the only supported method. Other methods, such as password, code, or totp, are not supported yet.

A force_credential value that names another method, or that has no provider, fails the login rather than being quietly dropped. So does a provider that isn't configured, or that is scoped to an organization, or the oidc method being disabled: no sign-in could ever satisfy that requirement, so the login fails with a configuration error instead of issuing a session that can't be used. If two webhooks on the same position require different providers, the login fails with a configuration error that names both webhooks — change the configuration so that at most one webhook requires a credential. force_credential returned from a webhook on another position is logged and ignored, because there is no login to attach the requirement to. While the feature flag is off, force_credential is ignored everywhere.

You can combine the directive with required_aal and update the identity in the same response. The two requirements are independent, and the user has to satisfy both. Signing in with the required provider issues a new session that keeps the AAL floor, so the user still has to complete the second factor:

{
"identity": {
"metadata_admin": { "risk": "high" }
},
"required_aal": "aal2",
"force_credential": {
"method": "oidc",
"provider": "my-oidc-provider"
}
}

What the user sees​

The requirement is stored on the session, so it outlasts the login that created it. While it is pending, the session checks refuse the session: /sessions/whoami, GET /sessions, and the settings flow respond with 403 Forbidden and the error ID session_credential_required.

{
"error": {
"id": "session_credential_required",
"code": 403,
"details": {
"redirect_browser_to": "https://$PROJECT_SLUG.projects.oryapis.com/self-service/login/browser?refresh=true"
}
}
}

If the session still owes a second factor, the session checks first answer with session_aal2_required instead, and the user completes the second factor before the required sign-in. The required sign-in can link a new provider account to the identity, so it takes the same Authenticator Assurance Level as linking an account in the settings flow (selfservice.flows.settings.required_aal).

Send the user to the URL in error.details.redirect_browser_to. A browser request to the settings flow goes there on its own, with a redirect instead of the error. That login flow shows only the required provider's sign-in button, with no password field and no other provider. It is a refresh sign-in, so where the provider supports it, Ory sends prompt=login to ask the provider to authenticate the user again rather than reuse the session the user may still have there. Whether the user re-authenticated is the provider's decision, and Ory relies on the provider's answer.

The login the webhook ran in ends the same way:

  • A browser is redirected to that login flow with a 303 See Other.
  • A single-page app receives 422 Unprocessable Entity with the error ID browser_location_change_required and the URL to open.
  • A native app receives 200 OK with a session token but without the identity. Its next call to /sessions/whoami returns the 403 above.

Signing in with the required provider is the only thing that clears the requirement. Signing in with another credential — the password, for example — does not: the refreshed session keeps the requirement. If the webhook requires the provider the user has just signed in with, the requirement is already met and that login finishes normally.

The login flow is tied to the requirement it was created for. If that requirement is cleared or replaced, or the session is revoked, while the flow is open, completing the flow fails and the user starts the sign-in again.

Providers the identity hasn't used before​

The user doesn't need an account at the provider yet. If the identity has never signed in with it, completing the required sign-in links that provider account to the identity, together with the tokens the provider issued, including the ID token. Read them with Get social sign-in provider tokens. This makes the directive work for providers that exist to assert something about the user, such as a verified legal name, rather than to sign them in.

If the identity already has an account at that provider, only that account satisfies the requirement. Signing in with a different account at the same provider fails the login, and so does an account that is already linked to another identity.

Flow-interrupting webhooks​

If you want the result of webhook execution to have the potential of interrupting the flow, you can configure the webhook to parse the response:

hook: web_hook
config:
response:
parse: true

The external service called in the flow decides if it allows the flow to continue, or if it interrupts the flow. For example:

  • For HTTP response codes below 400, the flow continues unless processing the webhook response returns an error.
  • For HTTP response codes 4xx other than 429, or 501, the external service can interrupt the flow with validation messages using the payload below. Responses with status 429 or other 5xx codes are retried; if retries are exhausted, the flow fails with webhook_request_failed instead of returning their validation messages.

Example payload:

{
"messages": [
{
"instance_ptr": "#/traits/foo/bar", // Points to the field that failed validation.
"messages": [
{
"id": 123, // Unique numeric ID of the error that helps the frontend to interpret this message, doesn't have to be unique.
"text": "field must be longer than 12 characters",
"type": "error", // One of "error", "info", "success".
"context": {
// Contextual information about the error. Is schemaless, and can be used to provide additional information to the frontend.
// Typically used to provide fields that are used in internationalization messages.
"value": "short value",
"any": "additional information"
}
}
]
}
]
}

When using an after registration hook, you can define the specific point in the flow where this webhook is called:

  • When the webhook parses the response, the logic is called before the system creates a new identity.
  • When the webhook does not parse the response, the logic is called after the system creates a new identity.

You can call a webhook before and after the system creates an identity. To do that, define two webhooks with different parse values:

- hook: web_hook
config:
response:
parse: true
- hook: web_hook
config:
response:
parse: false
tip

Flow-interrupting webhooks work best if you give users meaningful information about the status of their flow. The best place to trigger these hooks is after flows.

Webhook retries​

Sometimes webhook delivery can fail due to issues such as network problems or server errors. To mitigate this, Ory Network implements a retry policy that attempts to deliver the payload up to three times, with a 30-second timeout between each attempt. This means that if delivery fails on the first attempt, Ory Network will automatically retry the delivery two more times, with a 30-second delay between each retry.

Webhook errors​

For webhooks whose response isn't ignored, the following request failures and error-status responses produce an error.id:

Error IDHTTP statusCause
webhook_timeout504The webhook didn't respond in time.
webhook_request_failed502The request couldn't be delivered, or retries were exhausted on 429 or a 5xx status other than 501.
webhook_unexpected_status502The webhook responded with a 4xx status other than 429, or with 501, and response parsing is disabled or the body has no valid validation messages.

If the webhook parses the response and responds with a 4xx status other than 429, or with 501, and valid messages, the flow returns those messages as validation errors instead. See Flow-interrupting webhooks.

These IDs don't cover every webhook failure. For example, an empty or malformed HTTP 200 response with response.parse enabled still fails without a webhook error ID.