# Guide to PropelAuth Webhooks: Features and Best Practices

PropelAuth webhooks tell your application when something changes in your users and organisations: someone signs up, joins an organisation, has their role changed or is deleted. If you use PropelAuth, the authentication and user management platform for B2B products, webhooks are how you keep a copy of users and organisations in your own database and react to changes, such as updating billing when seats change.

This guide covers how PropelAuth webhooks are configured and signed, the events you can subscribe to, what PropelAuth does and does not document about delivery, and the best practices for running a PropelAuth receiver in production.

## What are PropelAuth webhooks?

PropelAuth sends an HTTP request to your endpoint when a subscribed event happens in your project. It does not run its own delivery system: its docs say "We use Svix for our Webhooks", and link to Svix's guide for verifying them. In practice that means the signing scheme, the headers and the secret format are Svix's, and the endpoint management screens in the PropelAuth dashboard are Svix's portal.

You can configure different endpoints for your test and production environments, and each endpoint has its own signing secret.

## PropelAuth webhook features

| Feature | Details |
| --- | --- |
| Configuration | PropelAuth dashboard, **Integrations > Webhooks** |
| Environments | Separate endpoints for test and production |
| Delivery platform | Svix |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` (Svix can white-label these as `webhook-*`) |
| Scheme | Base64 HMAC-SHA256, sent as `v1,<signature>` |
| Signed content | `{svix-id}.{svix-timestamp}.{raw body}` |
| Secret | One `whsec_` secret per endpoint |
| Event catalogue | 41 types on PropelAuth's docs, with example payloads in the dashboard's event catalogue |
| Retries | Failed webhooks are retried. The schedule is not documented by PropelAuth |
| Ordering | Not guaranteed. `org.updated` and `user.updated` carry only an ID for this reason |
| Source IPs | Not published by PropelAuth |

## Common events

PropelAuth's docs list 41 event types: 12 for organisations, 17 for users and 12 for emails.

| Family | Event types | Fires when |
| --- | --- | --- |
| Users | `user.created`, `user.updated`, `user.deleted`, `user.login`, `user.logged_out`, `user.locked`, `user.disabled`, `user.enabled`, `user.impersonated`, `user.deleted_personal_api_key`, `user.send_mfa_phone_code` | A user signs up, changes, is deleted, logs in or out, is locked, disabled or re-enabled, is impersonated by your team, deletes a personal API key or requests an SMS MFA code |
| User and organisation membership | `user.added_to_org`, `user.removed_from_org`, `user.invited_to_org`, `user.role_changed_within_org`, `user.added_to_scim_group`, `user.removed_from_scim_group` | A user joins, leaves or is invited to an organisation, changes role within it, or is added to or removed from a SCIM group |
| Organisations | `org.created`, `org.updated`, `org.deleted`, `org.api_key_deleted`, `org.saml_setup`, `org.saml_went_live`, `org.saml_removed`, `org.scim_key_created`, `org.scim_key_revoked`, `org.scim_group_created`, `org.scim_group_updated`, `org.scim_group_deleted` | An organisation is created, changed or deleted, deletes an API key, sets up, launches or removes SAML, or creates or revokes a SCIM key or group |
| Emails | `email.confirmation_email`, `email.reset_password`, `email.passwordless_login`, `email.otp_login`, `email.org_invite`, `email.update_email`, `email.account_locked`, `email.mfa_account_locked`, `email.merge_account_with_sso`, `email.org_now_requires_2fa`, `email.waitlist_user_confirmation`, `email.waitlist_user_invitation` | PropelAuth needs a transactional email sent, for an email you have switched to Webhook delivery |

The `email.*` events are opt-in per email. On the Emails page of the dashboard you switch an email from "PropelAuth" to "Webhook", and from then on PropelAuth stops sending that email itself and fires the matching webhook instead, so your handler is now responsible for delivering it.

Two events are deliberately thin. For `org.updated` and `user.updated`, PropelAuth says it "only record[s] the org_id" (or `user_id`) and asks you to fetch the latest record from its API, so that a webhook received out of order cannot overwrite newer data.

PropelAuth says every payload includes "any relevant information as well as the event type", but it does not publish a payload or name the field that holds the type. The examples are in the event catalogue inside the dashboard. Check one there, or log a real delivery, before you write routing code against a field name.

## Setting up PropelAuth webhooks

1. In the PropelAuth dashboard, open **Integrations**, then **Webhooks**.
2. Add an endpoint for the environment you are configuring (test or production) and choose the events it should receive.
3. Open the endpoint and copy its signing secret, which starts with `whsec_`. Store it server-side, for example as `PROPELAUTH_WEBHOOK_SECRET`. Test and production endpoints have different secrets, so keep one per environment.
4. To receive `email.*` events, switch the relevant emails to **Webhook** on the Emails page.

Trigger a real event to test, such as signing up a test user in your test environment.

## Securing PropelAuth webhooks

PropelAuth's webhooks are signed the way Svix signs every webhook. Strip `whsec_` from the secret and base64-decode the rest to get the key bytes. Concatenate the `svix-id` header, a period, the `svix-timestamp` header, a period and the raw body. Compute an HMAC-SHA256, base64-encode it, and compare it in constant time with each `v1,` entry in `svix-signature`.

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

function verifyPropelAuthSignature(rawBody, headers, secret) {
  // Svix's default header names; a white-labelled account sends webhook-* instead.
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signatureHeader = headers[`${prefix}-signature`];
  if (!id || !timestamp || !signatureHeader) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]))
    .digest();

  return signatureHeader.split(' ').some((entry) => {
    const [version, value] = entry.split(',');
    if (version !== 'v1' || !value) return false;
    const received = Buffer.from(value, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/propelauth', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.PROPELAUTH_WEBHOOK_SECRET;
  if (!secret || !secret.startsWith('whsec_')) return res.sendStatus(500); // fail closed

  if (!verifyPropelAuthSignature(req.body, req.headers, secret)) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on the svix-id header, then route on the event type.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_propelauth_signature(raw_body: bytes, headers, secret: str) -> bool:
    prefix = "svix" if headers.get("svix-id") else "webhook"
    msg_id = headers.get(f"{prefix}-id")
    timestamp = headers.get(f"{prefix}-timestamp")
    signature_header = headers.get(f"{prefix}-signature")
    if not (msg_id and timestamp and signature_header):
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())
    for entry in signature_header.split(" "):
        version, _, value = entry.partition(",")
        if version == "v1" and hmac.compare_digest(value.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_propelauth_signature(raw_body, request.headers, os.environ["PROPELAUTH_WEBHOOK_SECRET"])

```

The official `svix` library (`npm i svix`, `pip install svix`) does the same work and is what PropelAuth points to. It also rejects any request whose timestamp is more than 5 minutes old, which you may not want if requests can reach you late through a queue or proxy. The hand-written version above leaves that check out; add one if your receiver is always reached directly.

> **Make PropelAuth webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Svix signature on every PropelAuth delivery, whichever header prefix it uses, and durably queues each user and organisation event before your service sees it.

## PropelAuth webhook limitations and pain points

### The scheme is documented by Svix, not PropelAuth

**The Problem:** PropelAuth's own page tells you to verify webhooks but does not say how: no header names, algorithm or secret format.

**Why It Happens:** PropelAuth delegates delivery to Svix and links to Svix's documentation. Svix can also white-label its headers as `webhook-id`, `webhook-timestamp` and `webhook-signature`, and PropelAuth does not say which prefix its deliveries use.

**Workarounds:**

* Follow Svix's verification guide, or use the `svix` library.
* Accept either header prefix, as the samples above do. The signed content is the same.
* Log the headers of your first delivery to confirm what you receive.

**How Hookdeck Can Help:** Hookdeck's PropelAuth source type reads `svix-*` headers and falls back to `webhook-*`, so either prefix verifies without code changes.

### Undocumented payloads

**The Problem:** You cannot write a router from the public docs alone.

**Why It Happens:** PropelAuth publishes the event names but no example bodies. The examples live in the event catalogue behind the dashboard login, and the public page does not name the field that holds the event type.

**Workarounds:**

* Read the event catalogue in the dashboard before you write handlers.
* Capture a real delivery from your test environment and build against that.
* Log and acknowledge anything your router does not recognise.

**How Hookdeck Can Help:** Hookdeck stores every request it receives, so the first deliveries from your test environment are there to inspect in full, headers and body, before you write any routing logic.

### Update events need a follow-up read

**The Problem:** A `user.updated` handler has nothing to update with.

**Why It Happens:** `user.updated` and `org.updated` carry only the ID, by design, so that an older delivery cannot overwrite newer data.

**Workarounds:**

* Fetch the current user or organisation from the PropelAuth API when these arrive.
* Coalesce bursts: several updates to one user in quick succession need only one fetch.

**How Hookdeck Can Help:** Hookdeck can [deduplicate](/docs/deduplication) on the user or organisation ID within a time window, so a burst of updates becomes one fetch.

### Delivery details are unstated

**The Problem:** You cannot plan for outages because you do not know how long PropelAuth keeps retrying.

**Why It Happens:** PropelAuth says webhooks "are retried if they fail" but publishes no schedule, timeout or success criteria. Svix publishes a default schedule, but PropelAuth does not say whether it uses those defaults.

**Workarounds:**

* Return a `2xx` quickly and treat anything slow as background work.
* Deduplicate on `svix-id`, which Svix keeps the same across resends of a message.
* After an outage, reconcile against the PropelAuth API rather than assuming every event will arrive.

**How Hookdeck Can Help:** Hookdeck acknowledges each delivery as it arrives and retries towards your service on a schedule you set, so PropelAuth's undocumented retry window stops mattering.

## Best practices

### Verify against the raw body

Svix signs the exact bytes it sends. In Express, mount `express.raw()` on the webhook route and parse only after verification. In Next.js, use `await request.text()`. In FastAPI, read `await request.body()`.

### Keep test and production apart

Use separate endpoints, and separate secrets, for the two environments. A test secret in production fails every delivery, and a shared URL mixes test users into production data.

### Treat email webhooks as part of sign-in

Once an email is switched to Webhook delivery, PropelAuth no longer sends it. If your handler drops `email.confirmation_email` or `email.reset_password`, users cannot confirm their address or reset a password. Alert on failures for these events and process them first.

### Deduplicate and process asynchronously

Store the `svix-id` of each processed message and skip repeats, then do the real work in a background job. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency) and [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

PropelAuth hands webhook delivery to Svix, so verification follows Svix's scheme: a base64 HMAC-SHA256 over the message ID, the timestamp and the raw body, keyed with the decoded `whsec_` secret for that endpoint. Keep test and production endpoints separate, fetch the latest record when an `*.updated` event arrives, and handle email webhooks with the same care as the rest of your sign-in flow.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every PropelAuth delivery, and holds each event until your service has processed it.

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