Bridge Webhooks
Quick Reference
typescript
const WEBHOOK_TIMEOUT_MS = 600000; // 10 minutes type WebhookCategory = 'customers' | 'transfers' | 'wallets' | 'cards' | 'virtual_accounts';
Webhook Event Structure
typescript
interface WebhookEvent {
api_version: string;
event_id: string;
event_category: WebhookCategory;
event_type: string;
event_object: Record<string, unknown>;
event_object_changes?: Record<string, unknown>;
event_created_at: string;
}
Event Types by Category
typescript
const EVENT_TYPES = {
customers: [
'customer.created',
'customer.updated',
'customer.kyc_completed',
'customer.tos_accepted',
],
transfers: [
'transfer.created',
'transfer.state_updated',
'transfer.completed',
'transfer.failed',
],
wallets: [
'wallet.created',
'wallet.tagged',
],
cards: [
'card_account.created',
'card_account.status_updated',
'card_account.freeze_created',
'card_account.freeze_removed',
],
virtual_accounts: [
'virtual_account.created',
'virtual_account.deposit_received',
'virtual_account.conversion_completed',
],
} as const;
Signature Verification (Python Reference)
python
import hashlib
import base64
import time
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
def verify_webhook_signature(
payload: bytes,
signature_header: str,
public_key_pem: str
) -> tuple[bool, str]:
try:
# Parse signature header
signature_parts = signature_header.split(',')
timestamp = next(
(p.split('=', 1)[1] for p in signature_parts if p.startswith('t=')),
None
)
signature = next(
(p.split('=', 1)[1] for p in signature_parts if p.startswith('v0=')),
None
)
if not timestamp or not signature:
return False, 'Missing timestamp or signature'
# Check timestamp (reject > 10 minutes)
current_time = int(time.time() * 1000)
if current_time - int(timestamp) > WEBHOOK_TIMEOUT_MS:
return False, 'Timestamp too old'
# Create signed payload: timestamp.payload
signed_payload = f"{timestamp}.{payload.decode()}"
digester = hashlib.sha256(signed_payload.encode())
# Verify signature
public_key = serialization.load_pem_public_key(public_key_pem.encode())
signature_bytes = base64.b64decode(signature)
public_key.verify(
signature_bytes,
digester.digest(),
padding.PKCS1v15(),
hashes.SHA256()
)
return True, ''
except Exception as e:
return False, f'Signature verification failed: {e}'
Signature Verification (TypeScript)
typescript
import crypto from 'crypto';
interface SignatureVerificationResult {
isValid: boolean;
error?: string;
}
const WEBHOOK_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
your_webhook_public_key_here
-----END PUBLIC KEY-----`;
function verifyWebhookSignature(
payload: Buffer,
signatureHeader: string,
publicKey: string = WEBHOOK_PUBLIC_KEY
): SignatureVerificationResult {
try {
const signatureParts = signatureHeader.split(',');
const timestamp = signatureParts.find(p => p.startsWith('t='))?.split('=')[1];
const signature = signatureParts.find(p => p.startsWith('v0='))?.split('=')[1];
if (!timestamp || !signature) {
return { isValid: false, error: 'Missing timestamp or signature' };
}
// Check timestamp (reject > 10 minutes)
const currentTime = Date.now();
if (currentTime - parseInt(timestamp) > 600000) {
return { isValid: false, error: 'Timestamp too old' };
}
// Create signed payload
const signedPayload = `${timestamp}.${payload.toString()}`;
const digest = crypto.createHash('sha256').update(signedPayload).digest();
// Verify signature
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(digest);
const isValid = verifier.verify(publicKey, signature, 'base64');
return { isValid };
} catch (error) {
return { isValid: false, error: `Verification failed: ${error}` };
}
}
Webhook Handler (Express.js)
typescript
import express, { Request, Response } from 'express';
import crypto from 'crypto';
const app = express();
app.use('/webhooks/bridge', express.raw({ type: 'application/json' }));
function handleWebhookEvent(event: WebhookEvent): void {
switch (event.event_type) {
case 'customer.created':
console.log(`New customer: ${event.event_object.id}`);
break;
case 'customer.updated':
console.log(`Customer updated: ${event.event_object.id}`);
break;
case 'transfer.created':
console.log(`Transfer initiated: ${event.event_object.id}`);
break;
case 'transfer.completed':
console.log(`Transfer completed: ${event.event_object.id}`);
break;
case 'transfer.failed':
console.log(`Transfer failed: ${event.event_object.id}`);
break;
case 'wallet.created':
console.log(`Wallet created: ${event.event_object.id}`);
break;
case 'card_account.created':
console.log(`Card provisioned: ${event.event_object.id}`);
break;
case 'card_account.freeze_created':
console.log(`Card frozen: ${event.event_object.id}`);
break;
default:
console.log(`Unhandled event: ${event.event_type}`);
}
}
app.post('/webhooks/bridge', (req: Request, res: Response) => {
const payload = req.body as Buffer;
const signatureHeader = req.headers['x-webhook-signature'] as string;
if (!signatureHeader) {
res.status(400).json({ error: 'Missing signature header' });
return;
}
const verification = verifyWebhookSignature(payload, signatureHeader);
if (!verification.isValid) {
console.error('Signature verification failed:', verification.error);
res.status(400).json({ error: 'Invalid signature' });
return;
}
try {
const event: WebhookEvent = JSON.parse(payload.toString());
handleWebhookEvent(event);
res.status(200).json({ received: true });
} catch (error) {
console.error('Failed to parse webhook event:', error);
res.status(400).json({ error: 'Invalid JSON' });
}
});
app.listen(3000, () => console.log('Webhook server running on port 3000'));
Webhook Handler (Python Flask)
python
from flask import Flask, request, jsonify
import json
import hashlib
import base64
import time
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature
app = Flask(__name__)
WEBHOOK_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
your_webhook_public_key_here
-----END PUBLIC KEY-----"""
@app.route('/webhooks/bridge', methods=['POST'])
def handle_webhook():
payload = request.get_data()
signature_header = request.headers.get('X-Webhook-Signature')
if not signature_header:
return jsonify({'error': 'Missing signature header'}), 400
is_valid, error = verify_webhook_signature(payload, signature_header, WEBHOOK_PUBLIC_KEY)
if not is_valid:
return jsonify({'error': error}), 400
try:
event_data = json.loads(payload)
event_type = event_data.get('event_type')
if event_type == 'customer.created':
print(f"New customer: {event_data['event_object']['id']}")
elif event_type == 'transfer.completed':
print(f"Transfer completed: {event_data['event_object']['id']}")
elif event_type == 'card_account.freeze_created':
print(f"Card frozen: {event_data['event_object']['id']}")
else:
print(f"Unhandled event: {event_type}")
return jsonify({'received': True})
except Exception as e:
return jsonify({'error': str(e)}), 400
if __name__ == '__main__':
app.run(port=3000, debug=True)
Webhook Handler (Ruby)
ruby
require 'json'
require 'openssl'
require 'base64'
class WebhookHandler
WEBHOOK_PUBLIC_KEY = <<~KEY
-----BEGIN PUBLIC KEY-----
your_webhook_public_key_here
-----END PUBLIC KEY-----
KEY
def self.verify_webhook_signature(payload, signature_header)
signature_parts = signature_header.split(',')
timestamp = signature_parts.find { |p| p.start_with?('t=') }&.split('=', 2)&.last
signature = signature_parts.find { |p| p.start_with?('v0=') }&.split('=', 2)&.last
return [false, 'Missing timestamp or signature'] unless timestamp && signature
# Check timestamp (reject > 10 minutes)
current_time = Time.now.to_i * 1000
return [false, 'Timestamp too old'] if current_time - timestamp.to_i > 600_000
# Verify signature
signed_payload = "#{timestamp}.#{payload}"
public_key = OpenSSL::PKey::RSA.new(WEBHOOK_PUBLIC_KEY)
signature_bytes = Base64.decode64(signature)
is_valid = public_key.verify(OpenSSL::Digest::SHA256.new, signature_bytes, signed_payload)
[is_valid, is_valid ? nil : 'Invalid signature']
end
def self.handle_event(event)
case event['event_type']
when 'customer.created'
puts "New customer: #{event['event_object']['id']}"
when 'transfer.completed'
puts "Transfer completed: #{event['event_object']['id']}"
else
puts "Unhandled event: #{event['event_type']}"
end
end
end
post '/webhooks/bridge' do
payload = request.body.read
signature_header = env['HTTP_X_WEBHOOK_SIGNATURE']
unless signature_header
status 400
return { error: 'Missing signature header' }.to_json
end
is_valid, error = WebhookHandler.verify_webhook_signature(payload, signature_header)
unless is_valid
status 400
return { error: error }.to_json
end
event = JSON.parse(payload)
WebhookHandler.handle_event(event)
status 200
{ received: true }.to_json
end
Webhook Event Handler (Java)
java
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.security.*;
import java.util.Base64;
@RestController
public class BridgeWebhookHandler {
private static final String WEBHOOK_PUBLIC_KEY = """
-----BEGIN PUBLIC KEY-----
your_webhook_public_key_here
-----END PUBLIC KEY-----
""";
@PostMapping("/webhooks/bridge")
public ResponseEntity<Map<String, Object>> handleWebhook(
@RequestHeader("X-Webhook-Signature") String signatureHeader,
@RequestBody String payload) {
try {
// Parse signature header
String[] parts = signatureHeader.split(",");
String timestamp = "";
String signature = "";
for (String part : parts) {
if (part.startsWith("t=")) {
timestamp = part.substring(2);
} else if (part.startsWith("v0=")) {
signature = part.substring(3);
}
}
// Check timestamp
long currentTime = System.currentTimeMillis();
if (currentTime - Long.parseLong(timestamp) > 600000) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Timestamp too old"));
}
// Verify signature
String signedPayload = timestamp + "." + payload;
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(signedPayload.getBytes());
PublicKey publicKey = KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(
Base64.getDecoder().decode(WEBHOOK_PUBLIC_KEY)));
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(publicKey);
sig.update(hash);
if (!sig.verify(Base64.getDecoder().decode(signature))) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Invalid signature"));
}
// Handle event
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> event = mapper.readValue(payload, Map.class);
String eventType = (String) event.get("event_type");
switch (eventType) {
case "customer.created":
System.out.println("New customer: " + event.get("id"));
break;
case "transfer.completed":
System.out.println("Transfer completed: " + event.get("id"));
break;
}
return ResponseEntity.ok(Map.of("received", true));
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()));
}
}
}
Managing Webhooks
typescript
// List configured webhooks
async function listWebhooks(): Promise<{
data: {
id: string;
url: string;
status: 'active' | 'inactive';
public_key: string;
}[];
}> {
const response = await fetch(`${BRIDGE_API_URL}/webhooks`, {
headers: { 'Api-Key': API_KEY },
});
if (!response.ok) {
throw new Error('Failed to list webhooks');
}
return response.json();
}
// Create webhook
async function createWebhook(url: string): Promise<{
id: string;
url: string;
status: string;
public_key: string;
}> {
const response = await fetch(`${BRIDGE_API_URL}/webhooks`, {
method: 'POST',
headers: {
'Api-Key': API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url }),
});
if (!response.ok) {
throw new Error('Failed to create webhook');
}
return response.json();
}
Best Practices
- •Always verify signatures - Never trust webhook payloads without verification
- •Reject old events - Implement replay attack prevention (10 minute timeout)
- •Use idempotent handlers - Handle duplicate events gracefully
- •Respond quickly - Return 200 within seconds
- •Process asynchronously - Queue events for background processing
- •Log all events - Keep audit trail for debugging