Webhooks
Webhooks let your server receive real‑time notifications when important events happen in Passage. Register a publicly reachable HTTPS endpoint for your webhooks and Passage will POST a JSON payload for every event.
Security
- Use HTTPS for your endpoint.
- Recommended: Verify webhook signatures to ensure authenticity (see Webhook Verification below).
- Optional: Verify idempotency using the
idfield of each webhook to avoid duplicate processing.
Webhook Verification
Passage signs all outgoing webhooks so you can verify that they are authentic. Verifying webhooks is optional but recommended for production environments.
How It Works
Every webhook request from Passage includes two HTTP headers:
| Header | Description |
|---|---|
X-Passage-Signature | A JWT (JSON Web Token) signed with ES256 containing a hash of the request body |
X-Passage-Timestamp | Unix timestamp (seconds) when the webhook was sent |
Verification Steps
Follow these five steps to verify a webhook:
1. Extract the JWT
Retrieve the X-Passage-Signature header from the incoming webhook request. This is a signed JWT.
2. Validate the Algorithm
Decode the JWT header (without verifying the signature yet) and confirm that:
- The
algfield equals"ES256" - The
typfield equals"JWT"
If the algorithm is not ES256, reject the webhook.
3. Fetch the Public Key
Extract the kid (key ID) from the JWT header and call the Passage API to retrieve the corresponding public key:
POST https://api.runpassage.ai/webhook_verification_key/get
Content-Type: application/json
{
"key_id": "wsk_1705936800000"
}
Response:
{
"key_id": "wsk_1705936800000",
"key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----",
"algorithm": "ES256",
"created_at": "2025-01-22T12:00:00.000Z"
}
Cache the public key by key_id to reduce API calls. Keys are rotated infrequently, so caching is safe.
4. Verify the JWT Signature
Use a JWT library to verify the signature using the fetched public key. The JWT payload contains:
{
"iat": 1705936800,
"request_body_sha256": "abc123def456..."
}
5. Validate the Body Hash
Compute the SHA-256 hash of the raw webhook body and compare it to the request_body_sha256 claim in the JWT payload. If they don't match, reject the webhook.
Additionally, check that the X-Passage-Timestamp is within an acceptable window (e.g., 5 minutes) to prevent replay attacks.
Code Example
Here's a complete Node.js/TypeScript example using the jsonwebtoken library:
import * as crypto from 'crypto';
import * as jwt from 'jsonwebtoken';
import type { Request, Response } from 'express';
// Cache for public keys
const keyCache = new Map<string, string>();
async function fetchPublicKey(keyId: string): Promise<string> {
// Check cache first
if (keyCache.has(keyId)) {
return keyCache.get(keyId)!;
}
const response = await fetch('https://api.runpassage.ai/webhook_verification_key/get', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key_id: keyId })
});
if (!response.ok) {
throw new Error(`Failed to fetch public key: ${response.status}`);
}
const data = await response.json();
keyCache.set(keyId, data.key);
return data.key;
}
function verifyWebhook(
signature: string,
timestamp: string,
rawBody: string
): Promise<boolean> {
return new Promise(async (resolve, reject) => {
try {
// Step 1: Check timestamp (reject if older than 5 minutes)
const timestampNum = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (now - timestampNum > 300) {
return reject(new Error('Webhook timestamp too old'));
}
// Step 2: Decode JWT header to get algorithm and key ID
const decoded = jwt.decode(signature, { complete: true });
if (!decoded || decoded.header.alg !== 'ES256') {
return reject(new Error('Invalid JWT algorithm'));
}
const keyId = decoded.header.kid as string;
if (!keyId) {
return reject(new Error('Missing key ID in JWT header'));
}
// Step 3: Fetch public key
const publicKey = await fetchPublicKey(keyId);
// Step 4: Verify JWT signature
const payload = jwt.verify(signature, publicKey, {
algorithms: ['ES256']
}) as { request_body_sha256: string };
// Step 5: Verify body hash
const expectedHash = crypto
.createHash('sha256')
.update(rawBody)
.digest('hex');
if (payload.request_body_sha256 !== expectedHash) {
return reject(new Error('Body hash mismatch'));
}
resolve(true);
} catch (error) {
reject(error);
}
});
}
// Express middleware example
export async function handleWebhook(req: Request, res: Response) {
const signature = req.headers['x-passage-signature'] as string;
const timestamp = req.headers['x-passage-timestamp'] as string;
// Important: Use raw body for verification
const rawBody = JSON.stringify(req.body);
try {
await verifyWebhook(signature, timestamp, rawBody);
// Webhook is verified, process it
const event = req.body;
console.log('Verified webhook:', event.type);
// ... handle the event ...
res.sendStatus(200);
} catch (error) {
console.error('Webhook verification failed:', error);
res.status(401).json({ error: 'Invalid signature' });
}
}
Make sure you're computing the hash on the exact raw body bytes that Passage sent. If your framework parses and re-serializes JSON, the hash may not match. Configure your server to preserve the raw body for verification.
JWT Structure Reference
Header:
{
"alg": "ES256",
"typ": "JWT",
"kid": "wsk_1705936800000"
}
Payload:
{
"iat": 1705936800,
"request_body_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
Events
Every time a connection changes status, passage will send your service a webhook. Connection statuses are listed below.
type ConnectionStatus =
"pending" |
"connecting" |
"connected" |
"canceled" |
"rejected" |
"failed" |
"data_processing" |
"data_available" |
"data_partially_available" |
"inactive"
If you just care about receiving data, you will want to listen for the data_available status.
Top-level payload
Each webhook shares a common envelope:
{
"id": "evt_123",
"timestamp": "2025-01-01T00:00:00.000Z",
"type": "Connection.Updated",
"data": { /* event-specific object */ }
}
id(string): Unique event id. Treat as idempotency key.timestamp(ISO-8601 string): When the event occurred.type(string): One of the event types above.data(object): Event-specific payload shown below.
Connection events
The body of the webhook will contain a resources section that will tell you the status of each resource and a url that you can use to fetch that resource
Example full payload:
{
"id": "evt_9b1f",
"timestamp": "2025-01-01T12:34:56.000Z",
"type": "Connection.Updated",
"data": {
"connectionId": "123",
"status": "data_available",
"resources": [
{
"type": "trip",
"url": "https://api.runpassage.ai/connections/123/resources/trip",
"status": "data_available",
"operation": "read",
"startedAt": "2025-01-01T00:00:00.000Z",
"completedAt": "2025-01-01T00:01:00.000Z"
},
{
"type": "account_info",
"url": "https://api.runpassage.ai/connections/123/resources/account_info",
"status": "data_available",
"operation": "read",
"startedAt": "2025-01-01T00:00:00.000Z",
"completedAt": "2025-01-01T00:01:00.000Z"
}
]
}
}
Handling webhooks
Pseudo-code example in Node/Express:
import type { Request, Response } from 'express';
export async function handleWebhook(req: Request, res: Response) {
const event = req.body; // Ensure raw body parsing if verifying signatures later
// De-duplicate using event.id
// if (await hasProcessed(event.id)) return res.sendStatus(200);
if (event.type != "Connection.Updated") {
return;
}
const data = event.data
switch (data.status) {
case 'data_available': {
const { connectionId, resources } = data
for (const resource of resources) {
if (resource.status == 'data_available'){
fetchResource(resource);
}
}
break;
}
default:
// ignore unknown types safely
break;
}
res.sendStatus(200);
}
Testing locally
- Expose your local server using a tunneling tool (e.g.,
ngrok http 3000). - Configure your webhook URL in Passage settings to the public tunnel URL.
- Log received events and store
idvalues to ensure idempotency.