Setting up Webhooks
This page will help you set up and start using webhooks to receive real-time notifications from Bitpanda directly to your application. Follow these steps to configure your API URLs and begin leveraging the power of event-driven data delivery.
Step 1: Register your callback URL
- Configure endpoint: Your application must have a publicly accessible endpoint to receive webhook notifications. This URL will be where we send JSON data when an event occurs.
- Register URL & subscribe to events: Once available, pass this URL to our solution engineer together with the events that are relevant to your application.
There are two main options for differentiating between webhook calls:
- URLs with different routes: You can set up a unique callback URL for each notification type, such as www.123.com/webhooks/webhook-1, webhook-2, etc. This approach allows you to process each event separately based on its endpoint, making it straightforward to distinguish between notification types.
- Single URL with custom headers: Alternatively, if you prefer to use a single callback URL, we can configure custom headers for each notification type. This setup will enable you to identify the event type through the header, allowing flexible handling of different notifications within a single endpoint.
Each notification type also has a distinct payload structure, which aids in differentiation if using a single URL. Additionally, you have the option to activate notification types one at a time, providing control over your integration and easing testing.
Let us know which approach you would like to proceed with.
Step 2: Security Implementation
IP Filtering
Configure the Bitpanda IPs to make sure the requests are correctly filtered.
Webhook Signature Verification
All webhook requests are signed using RFC 9421 HTTP Message Signatures with ECDSA P-256. You must verify these signatures before processing webhook data.
Required Headers
Every webhook includes these security headers:
Header | Purpose | Example |
---|---|---|
Signature-Input | Defines signed components and metadata | sig1=("@method" "@target-uri" "host" "date" "content-digest" "content-type" "content-length" "x-bts-idempotency-key");created=1640995200;expires=1640995500;keyid="key-id";alg="ecdsa-p256-sha256" |
Signature | Base64-encoded signature | sig1=:MEUCIQDxHJKV3+...signature...: |
Content-Digest | SHA-256 hash of request body | sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: |
Date | Request timestamp | Mon, 22 Sep 2025 15:26:23 GMT |
X-BTS-Idempotency-Key | Unique event identifier | bts-a1b2c3d4e5f6... |
Step-by-Step Implementation
Step 1: Parse Signature Input
Extract signature metadata from theSignature-Input
header:function parseSignatureInput(signatureInputHeader) {
// Parse: sig1=("@method" "host" ...);created=1640995200;expires=1640995500;keyid="key-id";alg="ecdsa-p256-sha256"
const match = signatureInputHeader.match(/sig1=\((.*?)\);created=(\d+)(?:;expires=(\d+))?;keyid="([^"]+)";alg="([^"]+)"/);
if (!match) {
throw new Error('Invalid Signature-Input format');
}
const [, components, created, expires, keyid, alg] = match;
const componentList = components.split(' ').map(c => c.replace(/"/g, ''));
return {
components: componentList,
created: parseInt(created),
expires: expires ? parseInt(expires) : null,
keyid,
algorithm: alg
};
}
Step 2: Fetch Public Key
Retrieve the signing key from our JWKS endpoint:
const crypto = require('crypto');
const https = require('https');
async function fetchJWKS(authToken) {
return new Promise((resolve, reject) => {
const url = new URL('https://whitelabel.bitpanda.com/.well-known/jwks.json');
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: 'GET',
headers: {
'Authorization': `Bearer ${authToken}`
}
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
if (res.statusCode !== 200) {
reject(new Error(`JWKS fetch failed with status ${res.statusCode}: ${data}`));
return;
}
try {
const jwks = JSON.parse(data);
resolve(jwks);
} catch (err) {
reject(err);
}
});
});
req.on('error', reject);
req.end();
});
}
function parseECDSAKey(jwk) {
return crypto.createPublicKey({
key: jwk,
format: 'jwk'
});
}
async function loadPublicKey(keyId, authToken) {
const jwks = await fetchJWKS(authToken);
const jwk = jwks.keys.find(k => k.kid.includes(keyId));
if (!jwk) {
throw new Error(`Key ${keyId} not found in JWKS`);
}
return parseECDSAKey(jwk);
}
Step 3: Build Signature Base
Construct the string that was signed according to RFC 9421:
function buildSignatureBase(method, url, headers, signatureInfo) {
const lines = [];
for (const component of signatureInfo.components) {
if (component === '@method') {
lines.push(`"@method": ${method}`);
} else if (component === '@target-uri') {
lines.push(`"@target-uri": ${url}`);
} else if (component === 'host') {
const hostValue = headers['host'];
lines.push(`"host": ${hostValue}`);
} else {
const headerValue = headers[component.toLowerCase()];
if (headerValue === undefined) {
throw new Error(`Missing header: ${component}`);
}
lines.push(`"${component}": ${headerValue}`);
}
}
return lines.join('\n');
}
Step 4: Verify Signature
Validate the cryptographic signature:
function verifySignature(signatureBase, signatureHeader, publicKey) {
// Extract signature from "sig1=:base64signature:" format
const signatureMatch = signatureHeader.match(/sig1=:([^:]+):/);
if (!signatureMatch) {
throw new Error('Invalid signature format - expected sig1=:base64:');
}
const base64Signature = signatureMatch[1];
// Our implementation uses base64.RawURLEncoding (base64url without padding)
// Convert base64url to regular base64 for Node.js
const base64SignatureRegular = base64Signature
.replace(/-/g, '+')
.replace(/_/g, '/');
// Add padding if needed for base64url
const padding = '='.repeat((4 - (base64SignatureRegular.length % 4)) % 4);
const base64WithPadding = base64SignatureRegular + padding;
const signatureBytes = Buffer.from(base64WithPadding, 'base64');
const signatureBaseBytes = Buffer.from(signatureBase, 'utf8');
// Verify using ECDSA-SHA256
return crypto.verify('sha256', signatureBaseBytes, publicKey, signatureBytes);
}
Step 5: Validate Timestamps
Check signature freshness:
function validateTimestamps(signatureInfo) {
const now = Math.floor(Date.now() / 1000);
if (now < signatureInfo.created) {
return false; // Signature from future
}
if (signatureInfo.expires && now > signatureInfo.expires) {
return false; // Signature expired
}
return true;
}
Step 6: Verify Content Digest
Validate request body integrity:
function verifyContentDigest(body, contentDigestHeader) {
// Calculate SHA-256 of body
const expectedDigest = crypto.createHash('sha256')
.update(body, 'utf8')
.digest('base64');
// Extract digest from "sha-256=:digest:" format
const receivedDigest = contentDigestHeader.replace('sha-256=:', '').replace(':', '');
return expectedDigest === receivedDigest;
}
Complete Verification Function
async function verifyWebhook(request, authToken) {
// Extract required headers
const signatureInput = request.headers['signature-input'];
const signature = request.headers['signature'];
const contentDigest = request.headers['content-digest'];
if (!signatureInput || !signature || !contentDigest) {
throw new Error('Missing required signature headers');
}
// Extract required data
const method = request.method;
const url = request.url;
const headers = {};
// Normalize headers to lowercase
for (const [key, value] of Object.entries(request.headers)) {
headers[key.toLowerCase()] = value;
}
// Parse signature metadata
const signatureInfo = parseSignatureInput(signatureInput);
// Validate timestamps (independent of crypto verification)
const timestampValid = validateTimestamps(signatureInfo);
// Fetch public key
const publicKey = await loadPublicKey(signatureInfo.keyid, authToken);
// Build signature base
const signatureBase = buildSignatureBase(method, url, headers, signatureInfo);
// Verify signature (independent of timestamp validation)
const signatureValid = verifySignature(signatureBase, signature, publicKey);
// Verify content digest
const digestValid = verifyContentDigest(request.body, contentDigest);
// All checks must pass for overall success
if (!timestampValid) {
throw new Error('Signature timestamp invalid or expired');
}
if (!signatureValid) {
throw new Error('Cryptographic signature verification failed');
}
if (!digestValid) {
throw new Error('Content digest verification failed');
}
return true; // Webhook verified successfully
}
Usage Example
// Express.js example
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/bitpanda', async (req, res) => {
try {
const request = {
method: req.method,
url: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
headers: req.headers,
body: JSON.stringify(req.body)
};
await verifyWebhook(request, process.env.BITPANDA_API_TOKEN);
// Process verified webhook
await processWebhookEvent(req.body);
res.status(200).send('OK');
} catch (error) {
console.error('Webhook verification failed:', error.message);
res.status(401).send('Unauthorized');
}
});
async function processWebhookEvent(eventData) {
// Your business logic here
console.log('Processing webhook event:', eventData);
}
Key Implementation Notes
- Authentication: JWKS endpoint requires
Authorization: Bearer YOUR_API_TOKEN
- Components: Always signed in this exact order:
@method
,@target-uri
,host
,date
,content-digest
,content-type
,content-length
,x-bts-idempotency-key
- Base64 Encoding: Signatures use base64url encoding (may need conversion for your crypto library)
- Timestamp Window: Signatures typically expire 5 minutes after creation
- Host Header: Use the actual
Host
header value, not derived from URL - Target URI: Use the complete URL including scheme, host, and path
Contact your solution engineer to obtain your API token for JWKS access.
Step 3: Handle Incoming Data
Code and test your endpoint to accept POST requests. Ensure it can parse the JSON payload and handle different types of events according to your business logic as detailed in the following subsections. Note that you should process the events in an idempotent way as the events might be sent one or multiple times.
TheX-BTS-Idempotency-Key
header helps you handle duplicate deliveries:const idempotencyKey = request.headers['x-bts-idempotency-key'];
// Check if you've already processed this event
if (await hasProcessedEvent(idempotencyKey)) {
return res.status(200).send('Already processed');
}
// Process the event and store the idempotency key
await processEvent(eventData);
await markEventAsProcessed(idempotencyKey);