Use vault.handleWebhook(provider, payload, headers) to verify provider signatures and normalize provider-specific event payloads into a canonical VaultEvent format.
Overview
Webhook handling provides:
Signature Verification : Cryptographic verification of webhook authenticity
Event Normalization : Consistent event shape across all providers
Automatic Parsing : Raw payload parsing and type conversion
Provider Support
Each provider adapter implements webhook signature verification:
Provider Header Config Required Stripe stripe-signaturewebhookSecretdLocal x-dlocal-signature or x-signaturewebhookSecret or secretKeyPaystack x-paystack-signaturewebhookSecret or secretKey
Critical : Pass the raw request body exactly as received (string or Buffer). Do not parse and re-serialize JSON before verification, as this will cause signature validation to fail.
Basic Usage
import { VaultClient , WebhookVerificationError } from '@vaultsaas/core' ;
const vault = new VaultClient ({
providers: {
stripe: {
adapter: StripeAdapter ,
config: {
apiKey: process . env . STRIPE_API_KEY ,
webhookSecret: process . env . STRIPE_WEBHOOK_SECRET ,
},
},
},
routing: {
rules: [{ match: { default: true }, provider: 'stripe' }],
},
});
try {
const event = await vault . handleWebhook (
'stripe' ,
rawBody , // Buffer or string - don't parse!
headers // Request headers
);
console . log ( 'Event type:' , event . type );
console . log ( 'Transaction ID:' , event . transactionId );
console . log ( 'Provider event ID:' , event . providerEventId );
} catch ( error ) {
if ( error instanceof WebhookVerificationError ) {
// Signature verification failed
return { status: 400 , body: error . message };
}
throw error ;
}
VaultEvent Structure
All webhook events are normalized to this shape:
export interface VaultEvent {
id : string ; // Vault-generated event ID
type : VaultEventType ; // Canonical event type
provider : string ; // Provider name (e.g., "stripe")
transactionId ?: string ; // Related transaction ID
providerEventId : string ; // Original provider event ID
data : Record < string , unknown >; // Event-specific data
rawPayload : unknown ; // Original webhook payload
timestamp : string ; // ISO 8601 timestamp
}
Event Types
export type VaultEventType =
| 'payment.completed'
| 'payment.failed'
| 'payment.pending'
| 'payment.requires_action'
| 'payment.refunded'
| 'payment.partially_refunded'
| 'payment.disputed'
| 'payment.dispute_resolved'
| 'payout.completed'
| 'payout.failed' ;
Framework Examples
Node.js HTTP
Express
Next.js App Router
Fastify
import { createServer } from 'node:http' ;
import { StripeAdapter , VaultClient , WebhookVerificationError } from '@vaultsaas/core' ;
function mustEnv ( name : string ) : string {
const value = process . env [ name ];
if ( ! value ) throw new Error ( `Missing env var: ${ name } ` );
return value ;
}
const vault = new VaultClient ({
providers: {
stripe: {
adapter: StripeAdapter ,
config: {
apiKey: mustEnv ( 'STRIPE_API_KEY' ),
webhookSecret: mustEnv ( 'STRIPE_WEBHOOK_SECRET' ),
},
},
},
routing: {
rules: [{ match: { default: true }, provider: 'stripe' }],
},
});
const server = createServer ( async ( req , res ) => {
if ( req . method !== 'POST' || req . url !== '/webhooks/stripe' ) {
res . statusCode = 404 ;
res . end ( 'not found' );
return ;
}
const chunks : Buffer [] = [];
req . on ( 'data' , ( chunk ) => chunks . push ( Buffer . from ( chunk )));
req . on ( 'end' , async () => {
try {
const payload = Buffer . concat ( chunks );
const headers = Object . fromEntries (
Object . entries ( req . headers )
. filter (( entry ) : entry is [ string , string ] => typeof entry [ 1 ] === 'string' ),
);
const event = await vault . handleWebhook ( 'stripe' , payload , headers );
console . log ( 'Normalized event:' , event . type , event . providerEventId );
res . statusCode = 200 ;
res . end ( 'ok' );
} catch ( error ) {
if ( error instanceof WebhookVerificationError ) {
res . statusCode = 400 ;
res . end ( ` ${ error . code } : ${ error . message } ` );
return ;
}
res . statusCode = 500 ;
res . end ( 'internal_error' );
}
});
});
server . listen ( 3000 , () => {
console . log ( 'Listening on http://localhost:3000/webhooks/stripe' );
});
Event Processing
Basic Processing
Database Updates
Idempotent Processing
async function processWebhookEvent ( event : VaultEvent ) {
console . log ( `Received ${ event . type } from ${ event . provider } ` );
switch ( event . type ) {
case 'payment.completed' :
await handlePaymentCompleted ( event );
break ;
case 'payment.failed' :
await handlePaymentFailed ( event );
break ;
case 'payment.refunded' :
await handlePaymentRefunded ( event );
break ;
default :
console . log ( 'Unhandled event type:' , event . type );
}
}
async function handlePaymentCompleted ( event : VaultEvent ) {
if ( ! event . transactionId ) {
console . warn ( 'No transaction ID in event' );
return ;
}
await db . transaction . update ({
where: { id: event . transactionId },
data: {
status: 'completed' ,
completedAt: new Date ( event . timestamp ),
providerEventId: event . providerEventId ,
},
});
// Trigger fulfillment
await fulfillOrder ( event . transactionId );
}
async function processWebhookEvent ( event : VaultEvent ) {
// Check if already processed
const existing = await db . webhookEvent . findUnique ({
where: { providerEventId: event . providerEventId },
});
if ( existing ) {
console . log ( 'Event already processed:' , event . providerEventId );
return ; // Idempotent - safe to ignore
}
// Process event
await processEvent ( event );
// Mark as processed
await db . webhookEvent . create ({
data: {
providerEventId: event . providerEventId ,
provider: event . provider ,
type: event . type ,
processedAt: new Date (),
},
});
}
Signature Verification Details
The SDK verifies webhook signatures when adapters support it:
src/client/vault-client.ts:343
async handleWebhook (
provider : string ,
payload : Buffer | string ,
headers : Record < string , string > ,
): Promise < VaultEvent > {
const adapter = this . getAdapter ( provider );
if (adapter.handleWebhook) {
const handler = adapter . handleWebhook ;
const event = await this . wrapProviderCall ( provider , 'handleWebhook' , () =>
Promise . resolve ( handler . call ( adapter , payload , headers )),
);
if ( event . transactionId ) {
this . transactionProviderIndex . set ( event . transactionId , provider );
}
this . queueWebhookEvent ( event );
return event ;
}
const parsedPayload = this . parseWebhookPayload ( payload );
const event = normalizeWebhookEvent ( provider , parsedPayload , payload );
if (event.transactionId) {
this . transactionProviderIndex . set ( event . transactionId , provider );
}
this.queueWebhookEvent(event);
return event;
}
If signature verification fails, a WebhookVerificationError is thrown with code WEBHOOK_SIGNATURE_INVALID.
Best Practices
Always use raw body Configure your framework to preserve the raw request body for webhook routes: // Express
app . use ( '/webhooks' , express . raw ({ type: 'application/json' }));
// Next.js - use request.text() instead of request.json()
const body = await request . text ();
Implement idempotent processing Providers may send duplicate webhooks. Track processed event IDs to prevent duplicate actions.
Return 200 quickly Acknowledge receipt immediately and process asynchronously: app . post ( '/webhooks/stripe' , async ( req , res ) => {
const event = await vault . handleWebhook ( 'stripe' , req . body , req . headers );
res . json ({ received: true }); // Return 200 immediately
// Process asynchronously
processWebhookEvent ( event ). catch ( console . error );
});
Secure your webhook endpoints
Always verify signatures (don’t skip handleWebhook)
Use HTTPS in production
Consider IP allowlisting for additional security
Don’t expose webhook URLs publicly
Log webhook events Log all webhook events for debugging and audit trails: logger . info ( 'Webhook received' , {
provider: event . provider ,
type: event . type ,
providerEventId: event . providerEventId ,
transactionId: event . transactionId ,
});
Testing Webhooks
Use provider CLI tools to test webhook handling:
Stripe CLI
ngrok for dLocal/Paystack
# Install Stripe CLI
stripe listen --forward-to localhost:3000/webhooks/stripe
# Trigger test events
stripe trigger payment_intent.succeeded
When Platform Connector is enabled, webhook events are automatically queued for analytics:
src/client/vault-client.ts:573
private queueWebhookEvent ( event : VaultEvent ): void {
if ( ! this . platformConnector ) {
return ;
}
this . platformConnector . queueWebhookEvent ({
id: event . id ,
type: event . type ,
provider: event . provider ,
transactionId: event . transactionId ,
providerEventId: event . providerEventId ,
data: event . data ,
timestamp: event . timestamp ,
});
}
No additional code required - webhook events are automatically sent to VaultSaaS platform when platformApiKey is configured.
Next Steps
Error Handling Handle webhook verification errors
Architecture Learn about webhook normalization