Gareth Wilson Gareth Wilson

Guide to Authgear Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPortal, Advanced > Hooks, under Non-Blocking Events
TransportPOST over HTTPS
Signaturehex HMAC-SHA256
Signature headerx-authgear-body-signature
Signed contentThe raw request body
SecretOne per project, under Advanced > Hooks > Webhook Signature
BodyJSON with type and payload
Event routingBody field type
Time limit60 seconds, and the response must be 2xx
RetriesNot documented
Source IPsNot 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:

EventMeaning
user.createdOccurs 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.updatedOccurs when the user profile is updated.
user.authenticatedOccurs after the user logged in.
user.disabledOccurs when the user was disabled.
user.reenabledOccurs when the user was re-enabled.
user.anonymous.promotedOccurs whenever an anonymous user is promoted to a normal user.
user.deletion_scheduledOccurs when an account deletion was scheduled.
user.deletion_unscheduledOccurs 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:

IdentityEvents
Emailidentity.email.added, identity.email.removed, identity.email.updated, identity.email.verified, identity.email.unverified
Phoneidentity.phone.added, identity.phone.removed, identity.phone.updated, identity.phone.verified, identity.phone.unverified
Usernameidentity.username.added, identity.username.removed, identity.username.updated
OAuthidentity.oauth.connected, identity.oauth.disconnected
Biometricidentity.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:

{
  "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 and trigger an event, such as signing up a test user:

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 all follow that pattern.

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:

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

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.

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 receives Authgear's non-blocking events, verifies them, and retries delivery to your services until they succeed.

Get started with Hookdeck for free and handle Authgear 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.