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:

HeaderPurposeExample
Signature-InputDefines signed components and metadatasig1=("@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"
SignatureBase64-encoded signaturesig1=:MEUCIQDxHJKV3+...signature...:
Content-DigestSHA-256 hash of request bodysha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
DateRequest timestampMon, 22 Sep 2025 15:26:23 GMT
X-BTS-Idempotency-KeyUnique event identifierbts-a1b2c3d4e5f6...

Step-by-Step Implementation

Step 1: Parse Signature Input
Extract signature metadata from the Signature-Input header:
Copy
Copied
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:

Copy
Copied
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:

Copy
Copied
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:

Copy
Copied
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:

Copy
Copied
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:

Copy
Copied
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

Copy
Copied
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

Copy
Copied
// 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

  1. Authentication: JWKS endpoint requires Authorization: Bearer YOUR_API_TOKEN
  2. Components: Always signed in this exact order: @method, @target-uri, host, date, content-digest, content-type, content-length, x-bts-idempotency-key
  3. Base64 Encoding: Signatures use base64url encoding (may need conversion for your crypto library)
  4. Timestamp Window: Signatures typically expire 5 minutes after creation
  5. Host Header: Use the actual Host header value, not derived from URL
  6. 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.

The X-BTS-Idempotency-Key header helps you handle duplicate deliveries:
Copy
Copied
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);