Gareth Wilson Gareth Wilson

Guide to Privy Webhooks: Features and Best Practices

Published


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

AspectValue
MethodPOST, JSON body
Signature headerssvix-id, svix-timestamp, svix-signature (Standard Webhooks)
SuccessAny 2xx; everything else, 3xx included, is a failure
DeliveryAt least once; no ordering guarantee is documented
Source IPs44.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 handle the common events listed below.

Privy webhook features

FeatureDetails
ConfigurationDashboard, Configuration > Webhooks
PlansFree in development, Enterprise in production
SignatureStandard Webhooks HMAC-SHA256
Signature headersvix-signature
SecretPer endpoint. Format not shown; whsec_ from Svix
BodyJSON
Event routingBody field type
RetriesEight attempts over about 28 hours, then manual retry
Auto-disableAfter 5 consecutive days of failures
Test eventprivy.test from the Test webhook button
Source IPsPublished, 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. Some events include an idempotency_key.

These are the events most apps start with:

EventTriggered WhenCommon Use Cases
privy.testYou click "Test webhook" in the dashboardConfirm the endpoint works
user.createdA user is created in the appCreate a matching record
user.authenticatedA user logs inAudit log, session analytics
user.linked_accountA user links a new login methodSync profile data
user.unlinked_accountA user unlinks a login methodSync profile data
user.wallet_createdAn embedded or smart wallet is created for a userStore the wallet address
user.deletedA user is deletedDeprovision
mfa.enabled / mfa.disabledA user turns multi-factor authentication on or offSecurity audit
wallet.funds_depositedFunds land in an embedded walletCredit balances
wallet.funds_withdrawnFunds leave an embedded walletReconcile
wallet.private_key_exportA user exports their private keySecurity alert
transaction.broadcastedA transaction is submitted and awaiting confirmationShow pending state
transaction.confirmedA transaction is confirmed on-chainMark complete
transaction.failedA transaction could not be submittedNotify, retry
transaction.execution_revertedA transaction confirmed but execution revertedNotify, investigate
wallet_action.swap.createdA token swap action is createdShow in-progress swap
wallet_action.swap.succeededA swap completesMark complete
wallet_action.swap.failedA swap failsNotify
intent.createdAn intent is proposed and awaits approvalNotify approvers
intent.executedAn approved intent completedMark complete
user_operation.completedAn ERC-4337 UserOperation lands on-chainMark 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. Timestamps in intent events are Unix milliseconds:

{
  "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, 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 forwards it to your machine:

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:

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:

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 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 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 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 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 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 adds longer retries, delays and routing for Privy's wallet and transaction events. Get started for free.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.