Webhooks
Listen for real-time events from TxProof to automate your workflow. Webhooks allow your system to receive instant notifications when receipts are generated or transactions are processed.
Overview
Features
- Real-time Usage: Receive events as they happen, no polling required.
- Secure: All payloads are signed with HMAC-SHA256 using your unique secret.
- Resilient: Automatic retries with exponential backoff for failed delivery attempts.
- Idempotent: Unique Event IDs prevent duplicate processing.
Event Types
We currently support the following event types:
| Event Type | Description |
|---|---|
| bill.completed | Triggered when a receipt generation job completes successfully. |
| bill.failed | Triggered when a receipt generation fails (e.g. invalid transaction). |
Payload Structure
All webhook events share a common structure. The data field contains the resource-specific information.
{"event_type": "bill.completed","id": "evt_bill_test_123456_bill.completed","data": {"bill_id": "bill_123456","transaction_hash": "0x5d962...","chain_id": 8453,"status": "completed","amount": "1000000000000000000","currency": "ETH","pdf_url": "https://storage.txproof.xyz/receipts/...","billDataUrl": "https://storage.txproof.xyz/receipts/bill_123456.json"},"txHash": "0x5d962...","timestamp": 1716300000}
Security & Verification
TxProof signs all webhook events so you can verify they were sent by us. The signature is included in the X-TxProof-Signature header.
Critical: Use Raw Request Body
You MUST use the raw request body bytes for signature verification. Do NOT parse the JSON first and then re-canonicalize it — this will fail because JSON parsing is not reversible.
const payload = JSON.parse(body);
const canonical = canonicalize(payload); // ❌ Doesn't match originalconst rawBody = req.body.toString('utf8'); // ✅ Use raw bytesVerification Strategy
- Access the raw request body BEFORE any JSON parsing middleware processes it.
- Extract the
t(timestamp) andv1(signature) from theX-TxProof-Signatureheader. - Verify that the timestamp is recent (e.g., within 5 minutes) to prevent replay attacks.
- Construct the signed content string:
{timestamp}.{raw_body_string}. - Compute an HMAC-SHA256 hash using your webhook signing secret.
- Compare your computed signature with the provided
v1signature using a constant-time comparison. - Only THEN parse the JSON for processing.
Express.js Example (Recommended)
const express = require('express');const crypto = require('crypto');const app = express();// CRITICAL: Use express.raw() to preserve the raw body bytesapp.post('/webhook',express.raw({ type: 'application/json' }),(req, res) => {const rawBody = req.body.toString('utf8'); // Get raw JSON stringconst signatureHeader = req.headers['x-txproof-signature'];const secret = process.env.TXPROOF_WEBHOOK_SECRET;// 1. Parse signature header format: t={timestamp},v1={signature}const parts = signatureHeader.split(',');const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];const receivedSignature = parts.find(p => p.startsWith('v1=')).split('=')[1];// 2. Check timestamp freshness (5 minute tolerance)const now = Math.floor(Date.now() / 1000);if (Math.abs(now - parseInt(timestamp)) > 300) {return res.status(401).json({ error: 'Timestamp expired' });}// 3. Construct signed content using RAW bodyconst signedContent = `${timestamp}.${rawBody}`;// 4. Compute expected signatureconst computedSignature = crypto.createHmac('sha256', secret).update(signedContent).digest('hex');// 5. Constant-time comparisonif (!crypto.timingSafeEqual(Buffer.from(receivedSignature),Buffer.from(computedSignature))) {return res.status(401).json({ error: 'Invalid signature' });}// ✅ Signature verified! NOW parse the JSONconst payload = JSON.parse(rawBody);// Process the webhook event...console.log('Received event:', payload.event_type);res.json({ received: true });});
Quick Start: Drop-in Verification Function
Production-ready verification utility you can drop into any Node.js/Express project:
// webhook-verifier.js - Place this in your utils folderconst crypto = require('crypto');/*** Verify TxProof webhook signature* @param {string} rawBody - Raw request body as string (MUST be raw, not parsed)* @param {string} signatureHeader - Value of X-TxProof-Signature header* @param {string} secret - Your webhook signing secret* @param {number} toleranceSeconds - Timestamp tolerance (default: 300)* @returns {{valid: boolean, error?: string, payload?: object}}*/function verifyTxProofWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {try {// 1. Parse signature headerconst parts = signatureHeader.split(',');const timestampPart = parts.find(p => p.startsWith('t='));const signaturePart = parts.find(p => p.startsWith('v1='));if (!timestampPart || !signaturePart) {return { valid: false, error: 'Invalid signature header format' };}const timestamp = parseInt(timestampPart.split('=')[1]);const receivedSignature = signaturePart.split('=')[1];// 2. Check timestamp freshnessconst now = Math.floor(Date.now() / 1000);if (Math.abs(now - timestamp) > toleranceSeconds) {return { valid: false, error: 'Timestamp expired (possible replay attack)' };}// 3. Compute expected signatureconst signedContent = `${timestamp}.${rawBody}`;const computedSignature = crypto.createHmac('sha256', secret).update(signedContent).digest('hex');// 4. Constant-time comparisonif (!crypto.timingSafeEqual(Buffer.from(receivedSignature),Buffer.from(computedSignature))) {return { valid: false, error: 'Signature mismatch' };}// 5. Parse and return payloadconst payload = JSON.parse(rawBody);return { valid: true, payload };} catch (error) {return { valid: false, error: error.message };}}module.exports = { verifyTxProofWebhook };// --- USAGE EXAMPLE ---// const express = require('express');// const { verifyTxProofWebhook } = require('./utils/webhook-verifier');//// app.post('/webhook',// express.raw({ type: 'application/json' }),// (req, res) => {// const result = verifyTxProofWebhook(// req.body.toString('utf8'),// req.headers['x-txproof-signature'],// process.env.TXPROOF_WEBHOOK_SECRET// );//// if (!result.valid) {// return res.status(401).json({ error: result.error });// }//// // ✅ Verified! Process the webhook// console.log('Event:', result.payload.event_type);// res.json({ received: true });// }// );
Next.js API Routes Example
// pages/api/webhook.js (Pages Router)// OR app/api/webhook/route.js (App Router)import crypto from 'crypto';export const config = {api: {bodyParser: false, // CRITICAL: Disable automatic parsing},};export default async function handler(req, res) {if (req.method !== 'POST') {return res.status(405).json({ error: 'Method not allowed' });}// Read raw bodyconst rawBody = await new Promise((resolve) => {let data = '';req.on('data', chunk => { data += chunk; });req.on('end', () => { resolve(data); });});const signatureHeader = req.headers['x-txproof-signature'];const secret = process.env.TXPROOF_WEBHOOK_SECRET;// Parse signature headerconst parts = signatureHeader.split(',');const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];const receivedSignature = parts.find(p => p.startsWith('v1=')).split('=')[1];// Verify timestampconst now = Math.floor(Date.now() / 1000);if (Math.abs(now - parseInt(timestamp)) > 300) {return res.status(401).json({ error: 'Timestamp expired' });}// Verify signatureconst signedContent = `${timestamp}.${rawBody}`;const computedSignature = crypto.createHmac('sha256', secret).update(signedContent).digest('hex');if (!crypto.timingSafeEqual(Buffer.from(receivedSignature),Buffer.from(computedSignature))) {return res.status(401).json({ error: 'Invalid signature' });}// Parse JSON after verificationconst payload = JSON.parse(rawBody);// Process webhook...console.log('Event received:', payload.event_type);res.json({ received: true });}
Secret Management
Your webhook secret is the key to verifying the integrity of events. Keep it secure.
Rotation
If you believe your secret has been compromised, you can rotate it immediately via the Dashboard or API. Rotation invalidates the old secret instantly and generates a new cryptographically secure secret.
Impact of Rotation
- Immediate: The old secret will stop working instantly.
- Health Reset: Rotating a "Broken" webhook will reset its status to "Active".
- Zero Downtime: If you update your server configuration immediately, you miss no events.
Health Status
TxProof monitors the health of your webhook configuration.
Action: Rotate secret immediately.
Troubleshooting
❌ Problem: "Signatures don't match"
This is almost always caused by using a parsed JSON object instead of the raw request body.
- Verify you're using
express.raw()or equivalent - Check that you're NOT calling
JSON.parse()before verification - Ensure you're accessing
req.body.toString('utf8')for the raw string
💡 Debugging Tips
Log these values to debug:
console.log('Raw Body Length:', rawBody.length);console.log('Timestamp:', timestamp);console.log('Received Signature:', receivedSignature);console.log('Computed Signature:', computedSignature);console.log('Secret Length:', secret.length); // Should be 70 chars (whsec_ + 64 hex)
If your computed signature doesn't match, verify your webhook secret is correct. The secret should start with whsec_and be 70 characters long.
Retry Policy
If your server fails to respond with a 2xx status code within 5 seconds, we will attempt to redeliver the event. We use an exponential backoff strategy for retries.
- Attempt 1Immediate
- Attempt 21 second
- Attempt 32 seconds
- Attempt 44 seconds
- Attempt 58 seconds
- Attempt 616 seconds
After 5 failed retries (total 6 attempts), the event will be marked as failed and will not be retried automatically.
Best Practices
- Process Asynchronously:Your endpoint should return a 200 OK response immediately upon receiving the event, before processing complex logic. This prevents timeouts.
- Idempotency is Key:Though we aim for exactly-once delivery, network failures can result in duplicate deliveries. Always check the
idfield to key your processing logic. - Verify Signatures:Never trust the payload content blindly. Always verify the signature to ensure the request originated from TxProof.
- Use HTTPS:Your webhook URL must accept HTTPS connections to ensure payload privacy and security.