CR
Docs

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.

HTTPS Only
HMAC-SHA256 Signed

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 TypeDescription
bill.completedTriggered when a receipt generation job completes successfully.
bill.failedTriggered 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.

❌ Wrong (will fail):
const payload = JSON.parse(body);
const canonical = canonicalize(payload); // ❌ Doesn't match original
✅ Correct:
const rawBody = req.body.toString('utf8'); // ✅ Use raw bytes

Verification Strategy

  1. Access the raw request body BEFORE any JSON parsing middleware processes it.
  2. Extract the t (timestamp) and v1 (signature) from the X-TxProof-Signature header.
  3. Verify that the timestamp is recent (e.g., within 5 minutes) to prevent replay attacks.
  4. Construct the signed content string: {timestamp}.{raw_body_string}.
  5. Compute an HMAC-SHA256 hash using your webhook signing secret.
  6. Compare your computed signature with the provided v1 signature using a constant-time comparison.
  7. 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 bytes
app.post('/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8'); // Get raw JSON string
const 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 body
const signedContent = `${timestamp}.${rawBody}`;
// 4. Compute expected signature
const computedSignature = crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('hex');
// 5. Constant-time comparison
if (!crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(computedSignature)
)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// ✅ Signature verified! NOW parse the JSON
const 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

Copy & Paste Ready

Production-ready verification utility you can drop into any Node.js/Express project:

// webhook-verifier.js - Place this in your utils folder
const 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 header
const 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 freshness
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > toleranceSeconds) {
return { valid: false, error: 'Timestamp expired (possible replay attack)' };
}
// 3. Compute expected signature
const signedContent = `${timestamp}.${rawBody}`;
const computedSignature = crypto
.createHmac('sha256', secret)
.update(signedContent)
.digest('hex');
// 4. Constant-time comparison
if (!crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(computedSignature)
)) {
return { valid: false, error: 'Signature mismatch' };
}
// 5. Parse and return payload
const 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 body
const 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 header
const parts = signatureHeader.split(',');
const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];
const receivedSignature = parts.find(p => p.startsWith('v1=')).split('=')[1];
// Verify timestamp
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
return res.status(401).json({ error: 'Timestamp expired' });
}
// Verify signature
const 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 verification
const 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.

Active
Everything is working correctly. Events are being delivered.
Broken
Integrity check failed. Delivery is paused for security.
Action: Rotate secret immediately.
Rotated
Secret was recently changed. Update your server config.

Troubleshooting

❌ Problem: "Signatures don't match"

This is almost always caused by using a parsed JSON object instead of the raw request body.

Solution:
  • 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 1
    Immediate
  • Attempt 2
    1 second
  • Attempt 3
    2 seconds
  • Attempt 4
    4 seconds
  • Attempt 5
    8 seconds
  • Attempt 6
    16 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 id field 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.