Receive real-time notifications when events occur in your Para integration
Para webhooks send real-time HTTP POST requests to your server when events occur in your integration — such as a user
signing up, a wallet being created, or a transaction being signed. Use webhooks to trigger backend workflows, sync data,
or send notifications without polling.
When you first save your webhook configuration, Para generates a signing secret (prefixed with whsec_). This secret is
displayed only once — copy and store it securely. You’ll use it to
verify webhook signatures.
Your webhook secret is shown only at creation and after rotation. If you lose it, you’ll need to rotate to get a new
one.
transactionId matches the id returned from POST /v1/wallets/:walletId/transfer or
POST /v1/wallets/:walletId/sign-transaction with broadcast: true and used
by GET /v1/wallets/:walletId/transactions/:transactionId.
intentKind is transfer or sign_transaction.
status is reverted (execution failed on-chain) or failed (never broadcast, rejected by the RPC, or the monitor gave up).
When status = failed, the payload also includes failureStage (mpc_sign, signature_apply,
signer_verify, broadcast, or monitor_timeout) and an optional failureCode from the broadcast helper.
Every webhook request includes headers that let you verify the request came from Para:
Header
Description
webhook-id
Unique event ID (matches payload id)
webhook-timestamp
Unix timestamp in seconds when the request was sent
webhook-signature
HMAC-SHA256 signature prefixed with v1,
To verify a webhook:
Construct the signed message: {webhook-timestamp}.{raw request body}
Compute HMAC-SHA256 using your webhook secret as the key
Base64-encode the result and compare it to the signature after the v1, prefix
import crypto from "crypto";function verifyWebhookSignature( payload: string, headers: Record<string, string>, secret: string): boolean { const timestamp = headers["webhook-timestamp"]; const signature = headers["webhook-signature"]; if (!timestamp || !signature) return false; // Reject requests older than 5 minutes const now = Math.floor(Date.now() / 1000); if (Math.abs(now - parseInt(timestamp)) > 300) return false; const message = `${timestamp}.${payload}`; const expected = crypto .createHmac("sha256", secret) .update(message) .digest("base64"); // During secret rotation, multiple signatures may be // space-delimited (one per active secret). Try each. const signatures = signature.split(" "); return signatures.some((sig) => { const received = sig.replace("v1,", ""); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(received) ); });}// Usage in an Express handlerapp.post("/webhook", (req, res) => { const rawBody = req.body; // use raw body string, not parsed JSON const isValid = verifyWebhookSignature( rawBody, req.headers, process.env.WEBHOOK_SECRET ); if (!isValid) { return res.status(401).send("Invalid signature"); } const event = JSON.parse(rawBody); switch (event.type) { case "user.created": // handle user creation break; case "wallet.created": // handle wallet creation break; // ... } res.status(200).send("OK");});
import hmacimport hashlibimport base64import timeimport jsondef verify_webhook_signature(payload: str, headers: dict, secret: str) -> bool: timestamp = headers.get("webhook-timestamp", "") signature = headers.get("webhook-signature", "") if not timestamp or not signature: return False # Reject requests older than 5 minutes if abs(time.time() - int(timestamp)) > 300: return False message = f"{timestamp}.{payload}" expected = base64.b64encode( hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest() ).decode() # During secret rotation, multiple signatures may be # space-delimited (one per active secret). Try each. signatures = signature.split(" ") return any( hmac.compare_digest(expected, sig.replace("v1,", "")) for sig in signatures )# Usage in a Flask handler@app.route("/webhook", methods=["POST"])def webhook(): raw_body = request.get_data(as_text=True) is_valid = verify_webhook_signature( raw_body, request.headers, os.environ["WEBHOOK_SECRET"], ) if not is_valid: return "Invalid signature", 401 event = json.loads(raw_body) if event["type"] == "user.created": pass # handle user creation elif event["type"] == "wallet.created": pass # handle wallet creation return "OK", 200
Always use the raw request body string for verification — not a re-serialized JSON object. Re-serialization can change
formatting and break the signature check.
If your secret is compromised or you need a new one, rotate it from the Danger Zone section of the Webhooks page.
The previous secret remains valid for 24 hours to give you time to update your server. A new secret is displayed once.
Toggle webhooks off to temporarily stop delivery without losing your configuration. To remove the webhook entirely, use
the Delete Webhook button — this removes the URL, events, and secret permanently.