# Guide to Authgear Webhooks: Features and Best Practices

Authgear handles sign-up, login and account management for your apps, which means the moment a user registers, verifies an email, links a Google account or schedules their account for deletion happens inside Authgear, not in your database. Authgear's non-blocking event webhooks report those changes to your backend after they happen, so you can create a matching customer record, sync profile changes to a CRM, or clean up data when an account is deleted.

Below you will find the difference between Authgear's blocking and non-blocking hooks, the user and identity events, how to configure hooks in the portal, how to check `x-authgear-body-signature`, and the gaps in Authgear's delivery documentation that a production receiver has to plan around.

## What are Authgear webhooks?

Authgear has two kinds of hook, and they behave very differently.

**Blocking events** run during an operation, such as a sign-up, and wait for your hook's response. Your response can change what Authgear does next, for example by rejecting the sign-up. A receiver that only acknowledges and queues the request cannot make that decision.

**Non-blocking events** are delivered asynchronously after the operation has finished. Authgear ignores the response body and only checks that you returned a `2xx` within 60 seconds. These are the events this guide covers, and the only ones Hookdeck's Authgear source supports.

Either kind can be delivered to a webhook URL or to a JavaScript or TypeScript hook that runs inside Authgear. Webhook deliveries are `POST` requests over HTTPS, each signed with one secret that is shared by every hook in the project.

## Authgear webhook features

| Feature | Details |
| --- | --- |
| Configuration | Portal, **Advanced > Hooks**, under **Non-Blocking Events** |
| Transport | `POST` over HTTPS |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-authgear-body-signature` |
| Signed content | The raw request body |
| Secret | One per project, under **Advanced > Hooks > Webhook Signature** |
| Body | JSON with `type` and `payload` |
| Event routing | Body field `type` |
| Time limit | 60 seconds, and the response must be `2xx` |
| Retries | Not documented |
| Source IPs | Not published |

## Events and payloads

Route on the top-level `type`. The nested `identities[].type` describes an identity kind, such as `login_id`, `oauth` or `biometric`. The documentation has one inconsistent example: the `identity.phone.added` section shows `user.phone.added`. Confirm that event name from a test delivery before relying on it.

The user events describe the account as a whole:

| Event | Meaning |
| --- | --- |
| `user.created` | Occurs after a new user is created. A user can be created by user signup, user signup as an anonymous user, or created by the admin via the Portal or Admin API. |
| `user.profile.updated` | Occurs when the user profile is updated. |
| `user.authenticated` | Occurs after the user logged in. |
| `user.disabled` | Occurs when the user was disabled. |
| `user.reenabled` | Occurs when the user was re-enabled. |
| `user.anonymous.promoted` | Occurs whenever an anonymous user is promoted to a normal user. |
| `user.deletion_scheduled` | Occurs when an account deletion was scheduled. |
| `user.deletion_unscheduled` | Occurs when an account deletion was unscheduled. |

Authgear also documents `user.deleted` and 17 identity events, which fire when a user's sign-in methods change:

| Identity | Events |
| --- | --- |
| Email | `identity.email.added`, `identity.email.removed`, `identity.email.updated`, `identity.email.verified`, `identity.email.unverified` |
| Phone | `identity.phone.added`, `identity.phone.removed`, `identity.phone.updated`, `identity.phone.verified`, `identity.phone.unverified` |
| Username | `identity.username.added`, `identity.username.removed`, `identity.username.updated` |
| OAuth | `identity.oauth.connected`, `identity.oauth.disconnected` |
| Biometric | `identity.biometric.enabled`, `identity.biometric.disabled` |

Every body has the same two top-level keys. `payload.user` holds the user, and identity events add the affected `identity` (user events such as `user.created` carry an `identities` array instead). This is the `identity.biometric.disabled` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/authgear/latest/identity.biometric.disabled.json):

```json
{
  "type": "identity.biometric.disabled",
  "payload": {
    "user": {
      "id": "338deafa-400b-4589-a922-2c92d670b757",
      "created_at": "2006-01-02T03:04:05.123456Z",
      "updated_at": "2006-01-02T03:04:05.123456Z",
      "last_login_at": "2006-01-02T03:04:05.123456Z",
      "is_anonymous": false,
      "is_verified": true,
      "is_disabled": false,
      "is_deactivated": false,
      "can_reauthenticate": true,
      "standard_attributes": {
        "email": "user@example.com",
        "email_verified": true,
        "updated_at": 1136171045
      }
    },
    "identity": {
      "id": "5cb77960-634b-4c0e-8a0e-6c2c73fb8f47",
      "created_at": "2006-01-02T03:04:05.123456Z",
      "updated_at": "2006-01-02T03:04:05.123456Z",
      "type": "biometric",
      "claims": {
        "https://authgear.com/claims/biometric/device_info": {
          "ios": {
            "NSBundle": {
              "CFBundleDisplayName": "Authgear demo iOS",
              "CFBundleExecutable": "ios_example",
              "CFBundleIdentifier": "com.authgear.exampleapp.ios",
              "CFBundleName": "ios_example",
              "CFBundleShortVersionString": "1.0",
              "CFBundleVersion": "1653565975"
            },
            "NSProcessInfo": {
              "isMacCatalystApp": false,
              "isiOSAppOnMac": false
            },
            "UIDevice": {
              "model": "iPhone",
              "name": "iPhone",
              "systemName": "iOS",
              "systemVersion": "16.1.1",
              "userInterfaceIdiom": "phone"
            },
            "uname": {
              "machine": "iPhone13,3",
              "nodename": "Users-iPhone",
              "release": "22.1.0",
              "sysname": "Darwin",
              "version": "Darwin Kernel Version 22.1.0: Thu Oct  6 19:34:22 PDT 2022; root:xnu-8792.42.7~1/RELEASE_ARM64_T8101"
            }
          }
        },
        "https://authgear.com/claims/biometric/formatted_device_info": "iPhone 12 Pro",
        "https://authgear.com/claims/biometric/key_id": "1CC9D95A-6578-4557-8279-C4D5699D3549"
      }
    }
  }
}

```

There is no event ID, sequence number or event timestamp in the body. The `updated_at` values belong to the user and identity records, not to the delivery.

## Setting up Authgear webhooks

1. In the Authgear portal, go to **Advanced > Hooks**.
2. Under **Non-Blocking Events**, add a webhook for each event you want and enter your HTTPS endpoint URL. Several events can point at the same URL.
3. Click **Save**.
4. Open **Webhook Signature** on the same page to reveal the project's secret. Authgear may ask you to re-authenticate first. Store it on your server, for example as `AUTHGEAR_WEBHOOK_SECRET`.

Only non-blocking events are supported. Blocking events expect a synchronous response from the receiver.

That limit applies to Hookdeck's Authgear source: add its URL under **Non-Blocking Events** only, and paste the Webhook Signature secret into the source's verification settings. If you also use blocking events, point them at a service that can make the allow or reject decision itself. The source manages the method as `POST`, which is the only method Authgear uses.

For local development, forward deliveries to your machine with the [Hookdeck CLI](/docs/cli) and trigger an event, such as signing up a test user:

```bash
npx hookdeck-cli listen 3000 authgear --path /webhooks/authgear

```

## Securing Authgear webhooks

Authgear puts a bare hex HMAC-SHA256 of the request body in `x-authgear-body-signature`, keyed with the Webhook Signature secret as a string. Recompute it over the raw bytes and compare it in constant time. Authgear's samples in Go, Python, Node.js, Java and PHP on the [webhooks page](https://docs.authgear.com/customization/events-hooks/webhooks#verifying-signature) all follow that pattern.

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-authgear-body-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

A receiver for non-blocking events then reads `type` and the user ID:

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

const app = express();

app.post('/webhooks/authgear', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.AUTHGEAR_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  let parsed;
  try {
    parsed = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
  const { type, payload } = parsed;
  try {
    const userId = payload?.user?.id;

    if (type === 'user.created') {
      await createCustomerRecord(userId, payload?.user?.standard_attributes);
    } else if (type === 'user.deleted') {
      await removeCustomerData(userId);
    } else if (typeof type === 'string' && type.startsWith('identity.')) {
      await refreshLoginMethods(userId);
    }

    // Authgear ignores the body; only the 2xx status matters.
  } catch {
    return res.sendStatus(503);
  }

  res.sendStatus(204);
});

```

The three handler functions are placeholders for your own code. Keep each one short, or hand the work to a queue, because the whole request must finish inside Authgear's 60-second limit.

Nothing in the signature is tied to time, so a recorded request stays valid indefinitely. Hookdeck's Authgear source checks the same HMAC and applies no timestamp window of its own. Hookdeck returns `200` to Authgear when it receives the request and records a verification failure on the request afterwards, rather than returning the `401` your own receiver would.

> **Make Authgear webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `x-authgear-body-signature` on every non-blocking event, stores each user and identity change, and retries delivery to your backend on a schedule you control.

## Authgear webhook limitations and pain points

### No documented retry policy

**The Problem:** You don't know whether an event that failed is ever sent again.

**Why It Happens:** Authgear documents that a non-`2xx` response or a request over 60 seconds counts as a failed delivery, but says nothing about retries after that.

**Workarounds:**

* Assume a failed delivery may be lost, and keep your endpoint's availability high.
* Reconcile periodically with the Admin API, for example by listing recently updated users.

**How Hookdeck Can Help:** Hookdeck accepts each delivery on Authgear's first attempt and stores it, then retries your service as many times as you configure, so a failure on your side no longer depends on Authgear's undocumented behaviour.

### Blocking hooks can't be queued

**The Problem:** Pointing a blocking event at a queue or gateway breaks sign-up or login flows.

**Why It Happens:** Blocking events wait for your response and use it to decide what to do. A gateway that answers `200` immediately cannot return that decision.

**Workarounds:**

* Use blocking events only with a service that answers synchronously, or with Authgear's JavaScript or TypeScript hooks.
* Send everything that is only a notification through non-blocking events.

**How Hookdeck Can Help:** Hookdeck's Authgear source is built for non-blocking events only, and its configuration alert says so, which keeps the two kinds of hook apart.

### No event ID to deduplicate on

**The Problem:** You cannot tell a repeated delivery from a new event with the same content.

**Why It Happens:** The body has only `type` and `payload`, and Authgear documents no delivery ID header.

**Workarounds:**

* Make handlers idempotent by design: upsert records by user ID instead of inserting them.
* For events where order matters, compare the record's `updated_at` with what you have stored and ignore older data.

**How Hookdeck Can Help:** Hookdeck's deduplication can compare fields you choose, such as `type`, `payload.user.id` and `payload.user.updated_at`, and drop exact repeats before they reach your service.

### One secret for the whole project

**The Problem:** Rotating the secret affects every hook at once.

**Why It Happens:** Authgear issues a single Webhook Signature secret per project rather than one per URL.

**Workarounds:**

* Keep every receiver's copy of the secret in one place, such as a shared secret manager, so a rotation is a single change.
* During a rotation, have receivers accept either the old or the new secret for a short period.

**How Hookdeck Can Help:** With Hookdeck in front, the secret is configured once on the source, and your downstream services don't need it at all.

## Best practices

### Verify against the raw bytes

Authgear signs the exact body it sent. In Express, use `express.raw()` on the webhook route. In a Next.js route handler, use `Buffer.from(await request.arrayBuffer())`, and in FastAPI use `await request.body()`. Parse the JSON only after the signature matches.

### Route on the top-level `type`

Don't confuse the event `type` with `identity.type` or `identities[].type`, which describe the sign-in method. Handle `user.phone.added` alongside `identity.phone.added` until a real delivery confirms which one Authgear sends.

### Respond inside the 60-second limit

Authgear ignores your response body, so there is nothing to compute before replying. Verify, store the event, return a `2xx`, and do the rest afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Treat deletion events carefully

`user.deletion_scheduled` and `user.deletion_unscheduled` can follow each other if a user changes their mind. Act on `user.deleted` for irreversible clean-up, and use the scheduling events only to warn or pause.

### Keep personal data out of logs

Payloads contain email addresses, phone numbers and device details. Log the `type` and user ID, not the full body. For handling repeats safely, see our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Authgear's non-blocking webhooks tell your backend about user and identity changes after they happen, signed with a hex HMAC-SHA256 of the body. Keep blocking events away from anything that only queues, verify against the raw body, upsert by user ID because there is no event ID, and plan for retries that Authgear does not document.

[Hookdeck Event Gateway](https://hookdeck.com) receives Authgear's non-blocking events, verifies them, and retries delivery to your services until they succeed.

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