# Guide to Privy Webhooks: Features and Best Practices

[Privy](https://www.privy.io) handles authentication and embedded wallets for web3 apps. Your users sign in with email, phone, social accounts or existing wallets, Privy creates wallets for them, and those wallets send transactions, swap tokens, deposit into yield vaults and act on approved intents. Privy's webhooks tell your backend about each of these as they happen: a user logs in, a wallet is created, funds arrive, a transaction confirms or reverts, an intent is authorised.

## What are Privy webhooks?

You add a Privy webhook endpoint in the dashboard and pick the event types it should receive. Privy delivers through Svix and tells you to verify the Svix signature before trusting a payload.

| Aspect | Value |
| --- | --- |
| Method | `POST`, JSON body |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` (Standard Webhooks) |
| Success | Any `2xx`; everything else, `3xx` included, is a failure |
| Delivery | At least once; no ordering guarantee is documented |
| Source IPs | `44.228.126.217`, `50.112.21.217`, `52.24.126.164`, `54.148.139.208`, `2600:1f24:64:8000::/56` |

Webhooks can be tested at no cost in development environments. Turning them on in production needs Privy's Enterprise plan.

The skill's [reference applications](https://github.com/hookdeck/webhook-skills/tree/main/skills/privy-webhooks/examples) handle the common events listed below.

## Privy webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard, **Configuration > Webhooks** |
| Plans | Free in development, Enterprise in production |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `svix-signature` |
| Secret | Per endpoint. Format not shown; `whsec_` from Svix |
| Body | JSON |
| Event routing | Body field `type` |
| Retries | Eight attempts over about 28 hours, then manual retry |
| Auto-disable | After 5 consecutive days of failures |
| Test event | `privy.test` from the **Test webhook** button |
| Source IPs | Published, IPv4 and IPv6 |

## Events and payloads

Privy doesn't wrap events in an envelope. `type` names the event, and that event's own fields sit beside it at the top level, so each type has its own schema in the [webhook reference](https://docs.privy.io/api-reference/webhooks/overview#webhook-events-reference). Some events include an `idempotency_key`.

These are the events most apps start with:

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `privy.test` | You click "Test webhook" in the dashboard | Confirm the endpoint works |
| `user.created` | A user is created in the app | Create a matching record |
| `user.authenticated` | A user logs in | Audit log, session analytics |
| `user.linked_account` | A user links a new login method | Sync profile data |
| `user.unlinked_account` | A user unlinks a login method | Sync profile data |
| `user.wallet_created` | An embedded or smart wallet is created for a user | Store the wallet address |
| `user.deleted` | A user is deleted | Deprovision |
| `mfa.enabled` / `mfa.disabled` | A user turns multi-factor authentication on or off | Security audit |
| `wallet.funds_deposited` | Funds land in an embedded wallet | Credit balances |
| `wallet.funds_withdrawn` | Funds leave an embedded wallet | Reconcile |
| `wallet.private_key_export` | A user exports their private key | Security alert |
| `transaction.broadcasted` | A transaction is submitted and awaiting confirmation | Show pending state |
| `transaction.confirmed` | A transaction is confirmed on-chain | Mark complete |
| `transaction.failed` | A transaction could not be submitted | Notify, retry |
| `transaction.execution_reverted` | A transaction confirmed but execution reverted | Notify, investigate |
| `wallet_action.swap.created` | A token swap action is created | Show in-progress swap |
| `wallet_action.swap.succeeded` | A swap completes | Mark complete |
| `wallet_action.swap.failed` | A swap fails | Notify |
| `intent.created` | An intent is proposed and awaits approval | Notify approvers |
| `intent.executed` | An approved intent completed | Mark complete |
| `user_operation.completed` | An ERC-4337 UserOperation lands on-chain | Mark complete |

Privy documents about 70 events in all:

* **User:** `user.created`, `user.deleted`, `user.authenticated`, `user.linked_account`, `user.unlinked_account`, `user.updated_account`, `user.transferred_account`, `user.wallet_created`, `user.kyc.updated`
* **MFA:** `mfa.enabled`, `mfa.disabled`
* **Wallet:** `wallet.archived`, `wallet.restored`, `wallet.funds_deposited`, `wallet.funds_withdrawn`, `wallet.private_key_export`, `wallet.seed_phrase_export`, `wallet.recovery_setup`, `wallet.recovered`, `wallet.deposit_account.deposit_started`, `wallet.deposit_account.deposit_completed`, `wallet.deposit_account.deposit_failed`
* **Transaction:** `transaction.broadcasted`, `transaction.confirmed`, `transaction.failed`, `transaction.execution_reverted`, `transaction.replaced`, `transaction.still_pending`, `transaction.provider_error`
* **Wallet action**, each with `created`, `succeeded`, `rejected` and `failed` variants: `wallet_action.swap.*`, `wallet_action.transfer.*`, `wallet_action.earn_deposit.*`, `wallet_action.earn_withdraw.*`, `wallet_action.earn_incentive_claim.*`, `wallet_action.earn_fee_collect.*`, `wallet_action.payout.*`, plus `wallet_automation.submitted`
* **Yield:** `yield.deposit.confirmed`, `yield.withdraw.confirmed`, `yield.claim.confirmed`
* **Usage charges:** `usage.gas_sponsorship.recorded`, `usage.swap_provider_fee.recorded`
* **Intent:** `intent.created`, `intent.authorized`, `intent.rejected`, `intent.executed`, `intent.failed`
* **User operation:** `user_operation.completed`
* **Organisation:** `organization.kyb.updated`

`wallet.seed_phrase_export` is listed in the overview but has no schema or example in Privy's API reference.

This is the `intent.authorized` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/privy/latest/intent.authorized.json). Timestamps in intent events are Unix milliseconds:

```json
{
  "type": "intent.authorized",
  "intent_id": "cm5abc123def456ghi789jkl",
  "intent_type": "WALLET",
  "status": "pending",
  "created_by_id": "did:privy:cfbsvtqo2c22202mo08847jdux2z",
  "created_by_display_name": "alice@example.com",
  "resource_id": "fmfdj6yqly31huorjqzq38zc",
  "created_at": 1772193600000,
  "expires_at": 1772452800000,
  "authorized_at": 1772202600000,
  "member": {
    "type": "user",
    "user_id": "did:privy:xyzabc123def456ghi789",
    "signed_at": 1709042400000
  }
}

```

## Setting up Privy webhooks

1. Create an endpoint in your backend that accepts `POST`, for example `https://example.com/webhooks/privy`. Privy requires `https://`.
2. In the [Privy Dashboard](https://dashboard.privy.io), go to **Configuration > Webhooks**.
3. Add the URL and select event types. A wallet app usually starts with `user.created`, `user.wallet_created`, `transaction.confirmed`, `transaction.failed`, `wallet.funds_deposited` and `wallet.funds_withdrawn`.
4. Open the endpoint and copy its signing secret into `PRIVY_WEBHOOK_SIGNING_SECRET`, the name Privy's own samples use.

Privy doesn't show the secret's format, but Svix endpoint secrets start with `whsec_`. Keep the whole value. Each endpoint has its own secret, so development and production endpoints need separate variables.

If you restrict inbound traffic, allow `44.228.126.217`, `50.112.21.217`, `52.24.126.164`, `54.148.139.208` and `2600:1f24:64:8000::/56`, and still verify signatures.

To put Hookdeck in front, create a source with the **Privy** type, add the secret, and use the source URL as the endpoint. The [Hookdeck CLI](/docs/cli) forwards it to your machine:

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

```

Click **Test webhook** in the dashboard to send a `privy.test` event through it.

## Securing Privy webhooks

Privy offers `privy.webhooks().verify` in `@privy-io/node` (which also needs your app ID and secret) and `Webhooks.Verify` in Go, while its Python and Ruby SDKs only parse and expect you to verify first. Privy otherwise sends you to Svix's manual verification guide: HMAC-SHA256 over `<svix-id>.<svix-timestamp>.<raw body>` with the base64-decoded part of the secret after `whsec_`, compared with each `v1,` entry. This function does that and also accepts `webhook-*` headers:

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

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The Svix libraries enforce a five-minute timestamp window. Hookdeck has none and resends the original `svix-timestamp` when it retries or replays, so a receiver behind Hookdeck should check the signature only.

Wallet events can trigger balance updates and notifications, so the receiver stores them first:

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

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// walletEvents.write(messageId, event) stores the event and resolves once it is durable.
function createPrivyReceiver(walletEvents) {
  const app = express();
  app.post('/webhooks/privy', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.PRIVY_WEBHOOK_SIGNING_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const event = parseJsonObject(req.body);
    if (!event) return res.sendStatus(400); // signed, but not a JSON object
    if (event.type === 'privy.test') return res.sendStatus(200);
    try {
      await walletEvents.write(req.headers['svix-id'] || req.headers['webhook-id'], event);
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200); // anything else, 3xx included, is retried
  });
  return app;
}

```

A Hookdeck Privy source responds `200` to Privy no matter what. Requests with a bad signature are saved as failed verification and not forwarded.

> **Make Privy webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies each Privy event and can route wallet, transaction and security events to separate services.

## Privy webhook limitations and pain points

### Production webhooks need Enterprise

**The Problem:** Webhooks work in development, then can't be enabled for production.

**Why It Happens:** Privy only enables production webhooks on its Enterprise plan.

**Workarounds:**

* Confirm the plan before you build on webhooks, or design around polling the API.

**How Hookdeck Can Help:** In development you can still use a Hookdeck source to inspect payloads and replay them while you build.

### Gas data can lag

**The Problem:** `transaction.confirmed` arrives with `gas_credits_charged` set to `null`.

**Why It Happens:** Privy may not have recorded the final gas data when it sends the event, and says so for both `transaction.confirmed` and `transaction.execution_reverted`.

**Workarounds:**

* Fetch the transaction from the API when you need final gas figures.

**How Hookdeck Can Help:** Hookdeck can [delay delivery](/docs/connections) of these events to the service that books gas costs, giving Privy time to record them.

### Failed messages need a manual retry

**The Problem:** After a long outage, failed wallet events don't come back on their own.

**Why It Happens:** After the last of eight attempts, about 28 hours in, the message is marked failed and must be retried from the dashboard. Endpoints failing for five consecutive days are disabled.

**Workarounds:**

* Retry failed messages from the dashboard after an incident, and re-enable a disabled endpoint.

**How Hookdeck Can Help:** Hookdeck accepts each event on Privy's first attempt and [retries](/docs/retries) your service for as long as you set, without dashboard clicks.

### Wallet actions can fail in two ways

**The Problem:** You treat every failed swap the same and retry one that already moved funds.

**Why It Happens:** `rejected` means the action failed before anything happened on-chain and is safe to retry. `failed` means it failed after at least one transaction was broadcast, so there may be on-chain changes.

**Workarounds:**

* Retry on `rejected`. On `failed`, inspect the action's steps before doing anything.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can send `*.failed` wallet actions to a review queue and `*.rejected` ones to automatic retry.

## Best practices

### Alert on key exports

`wallet.private_key_export` and `wallet.seed_phrase_export` mean a user took their keys out of Privy. Log them for security review.

### Deduplicate on `idempotency_key` or `svix-id`

Privy may deliver an event more than once. Use `idempotency_key` where the event has one, and `svix-id` otherwise. Our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency) explains how to store them.

### Watch for stuck transactions

`transaction.still_pending` and `transaction.replaced` tell you a transaction is slow or was sped up or cancelled. Update the UI so users don't resubmit.

### Separate test traffic

`privy.test` events aren't real activity. Acknowledge and drop them before your business logic.

## Conclusion

Privy's webhooks follow users and wallets through sign-in and on-chain settlement, signed by Svix and sent from published IP addresses. Production use needs the Enterprise plan, so confirm that before you build.

[Hookdeck Event Gateway](https://hookdeck.com) adds longer retries, delays and routing for Privy's wallet and transaction events. [Get started for free](https://dashboard.hookdeck.com/signup).