Midday provides webhook endpoints to receive real-time events from banking providers and app integrations. All webhooks are secured with signature verification.
Webhook Architecture
Webhooks are implemented in the API router:
// Location: ~/workspace/source/apps/api/src/rest/routers/webhooks/index.ts
import { webhookRouter } from "@api/rest/routers/webhooks" ;
app . route ( "/webhook" , webhookRouter );
Available Endpoints
POST /webhook/plaid - Plaid banking events
POST /webhook/teller - Teller banking events
POST /webhook/whatsapp - WhatsApp messages and media
GET/POST /webhook/whatsapp - WhatsApp verification and events
POST /webhook/inbox - Email inbox events
POST /webhook/stripe - Stripe payment events
POST /webhook/polar - Polar subscription events
Security
All webhook endpoints:
Use public middleware (no auth required)
Verify signatures from service providers
Return 200 even on processing errors (to prevent retries)
Log all events for debugging
Plaid Webhooks
Receive transaction updates and connection status changes from Plaid.
Configuration
Webhook URL
Webhook URLs are automatically configured during Link token creation:
Production: https://api.midday.ai/webhook/plaid
Sandbox: https://api-staging.midday.ai/webhook/plaid
See: ~/workspace/source/packages/banking/src/providers/plaid/plaid-api.ts:59-65
Signature Verification
Plaid uses JWT signatures in the Plaid-Verification header: import { validatePlaidWebhook } from "@api/utils/plaid" ;
const isValid = await validatePlaidWebhook ({
body: rawBody ,
verificationHeader: headers [ "plaid-verification" ]
});
Event Types
Transaction Events
New transactions are available to sync. {
"webhook_type" : "TRANSACTIONS" ,
"webhook_code" : "SYNC_UPDATES_AVAILABLE" ,
"item_id" : "eVBnVMp7zdTJLkRNr33Rs6zr7KNJqBFL9DrE6" ,
"new_transactions" : 19 ,
"environment" : "production"
}
Action : Triggers sync-connection job to fetch new transactions.
First transaction sync after connection. {
"webhook_type" : "TRANSACTIONS" ,
"webhook_code" : "INITIAL_UPDATE" ,
"item_id" : "eVBnVMp7zdTJLkRNr33Rs6zr7KNJqBFL9DrE6" ,
"new_transactions" : 523 ,
"environment" : "production"
}
Action : Triggers full sync of available transactions.
Historical transaction data sync. {
"webhook_type" : "TRANSACTIONS" ,
"webhook_code" : "HISTORICAL_UPDATE" ,
"item_id" : "eVBnVMp7zdTJLkRNr33Rs6zr7KNJqBFL9DrE6" ,
"new_transactions" : 2000 ,
"environment" : "production"
}
Action : Triggers manual sync if connection is new (created within 1 day).
Bank removed transactions (corrections, deleted entries). {
"webhook_type" : "TRANSACTIONS" ,
"webhook_code" : "TRANSACTIONS_REMOVED" ,
"item_id" : "eVBnVMp7zdTJLkRNr33Rs6zr7KNJqBFL9DrE6" ,
"removed_transactions" : [
"yBVBnVMp7zdTJLkRNr33Rs6zr7KNJqBFL9DrE6" ,
"kVBnVMp7zdTJLkRNr33Rs6zr7KNJqBFL9DrE6"
],
"environment" : "production"
}
Action : Deletes transactions from database by internal IDs.
Item Events
Connection error occurred (auth failure, invalid credentials, etc.). {
"webhook_type" : "ITEM" ,
"webhook_code" : "ERROR" ,
"item_id" : "eVBnVMp7zdTJLkRNr33Rs6zr7KNJqBFL9DrE6" ,
"error" : {
"error_type" : "ITEM_ERROR" ,
"error_code" : "ITEM_LOGIN_REQUIRED" ,
"error_message" : "the login details of this item have changed" ,
"display_message" : "The login details for this account have changed. Please reconnect."
},
"environment" : "production"
}
Action : Marks connection as disconnected.
User revoked access to their account via bank portal. {
"webhook_type" : "ITEM" ,
"webhook_code" : "USER_PERMISSION_REVOKED" ,
"item_id" : "eVBnVMp7zdTJLkRNr33Rs6zr7KNJqBFL9DrE6" ,
"environment" : "production"
}
Action : Marks connection as disconnected.
User successfully reconnected their account. {
"webhook_type" : "ITEM" ,
"webhook_code" : "LOGIN_REPAIRED" ,
"item_id" : "eVBnVMp7zdTJLkRNr33Rs6zr7KNJqBFL9DrE6" ,
"environment" : "production"
}
Action : Marks connection as connected.
Implementation
Location: ~/workspace/source/apps/api/src/rest/routers/webhooks/plaid/index.ts
// Handle transaction webhook
switch ( webhook_code ) {
case "SYNC_UPDATES_AVAILABLE" :
case "INITIAL_UPDATE" :
case "HISTORICAL_UPDATE" :
await tasks . trigger ( "sync-connection" , {
connectionId: connectionData . id ,
manualSync: false
});
break ;
case "TRANSACTIONS_REMOVED" :
await deleteTransactionsByInternalIds ( db , {
teamId: connectionData . team . id ,
internalIds: removed_transactions
});
break ;
}
Teller Webhooks
Receive enrollment and transaction events from Teller.
Configuration
Webhook URL
Configure in Teller dashboard: https://api.midday.ai/webhook/teller
Signature Verification
Teller uses HMAC signatures in the Teller-Signature header: import { validateTellerSignature } from "@api/utils/teller" ;
const isValid = validateTellerSignature ({
signatureHeader: headers [ "teller-signature" ],
text: rawBody
});
Event Types
User disconnected their enrollment. {
"id" : "wh_abc123" ,
"type" : "enrollment.disconnected" ,
"payload" : {
"enrollment_id" : "enr_xyz789" ,
"reason" : "user_action"
},
"timestamp" : "2024-01-15T10:30:00Z"
}
Action : Marks connection as disconnected.
New transactions have been processed and are available. {
"id" : "wh_abc123" ,
"type" : "transactions.processed" ,
"payload" : {
"enrollment_id" : "enr_xyz789"
},
"timestamp" : "2024-01-15T10:30:00Z"
}
Action : Triggers sync-connection job.
Test webhook for verification. {
"id" : "wh_test" ,
"type" : "webhook.test" ,
"payload" : {},
"timestamp" : "2024-01-15T10:30:00Z"
}
Action : Returns success response.
Implementation
Location: ~/workspace/source/apps/api/src/rest/routers/webhooks/teller/index.ts
switch ( type ) {
case "enrollment.disconnected" :
await updateBankConnectionStatus ( db , {
id: connectionData . id ,
status: "disconnected"
});
break ;
case "transactions.processed" :
await tasks . trigger ( "sync-connection" , {
connectionId: connectionData . id ,
manualSync: false
});
break ;
}
WhatsApp Webhooks
Receive messages, media, and button interactions from WhatsApp Business API.
Configuration
Environment Variables
WHATSAPP_VERIFY_TOKEN = your_verify_token
WHATSAPP_APP_SECRET = your_app_secret
Webhook URL
Configure in Meta Developer Portal: https://api.midday.ai/webhook/whatsapp
Callback URL verification uses GET request.
Verification
Meta sends verification request: GET /webhook/whatsapp?hub.mode=subscribe&hub.verify_token=your_token&hub.challenge=random_string
Must return the hub.challenge value if token matches.
Signature Verification
import { verifyWebhookSignature } from "@midday/app-store/whatsapp/server" ;
const isValid = verifyWebhookSignature (
rawBody ,
headers [ "x-hub-signature-256" ],
process . env . WHATSAPP_APP_SECRET
);
Event Types
User sent a text message. {
"object" : "whatsapp_business_account" ,
"entry" : [{
"changes" : [{
"field" : "messages" ,
"value" : {
"messages" : [{
"from" : "1234567890" ,
"id" : "wamid.abc123" ,
"type" : "text" ,
"text" : { "body" : "Hello" }
}]
}
}]
}]
}
Action :
Extract inbox ID for connection
Create WhatsApp connection if inbox ID present
Send welcome message if not connected
User sent an image (receipt photo). {
"object" : "whatsapp_business_account" ,
"entry" : [{
"changes" : [{
"field" : "messages" ,
"value" : {
"messages" : [{
"from" : "1234567890" ,
"id" : "wamid.abc123" ,
"type" : "image" ,
"image" : {
"id" : "img_xyz789" ,
"mime_type" : "image/jpeg"
}
}]
}
}]
}]
}
Action :
Verify user is connected
React with processing emoji (⏳)
Trigger upload job
Process receipt and match to transaction
Send match notification
User sent a document (PDF receipt). {
"object" : "whatsapp_business_account" ,
"entry" : [{
"changes" : [{
"field" : "messages" ,
"value" : {
"messages" : [{
"from" : "1234567890" ,
"id" : "wamid.abc123" ,
"type" : "document" ,
"document" : {
"id" : "doc_xyz789" ,
"mime_type" : "application/pdf" ,
"filename" : "receipt.pdf"
}
}]
}
}]
}]
}
Action : Same as image message.
Implementation
Location: ~/workspace/source/apps/api/src/rest/routers/webhooks/whatsapp/index.ts
The webhook processes different message types:
switch ( message . type ) {
case "text" :
await handleTextMessage ( db , phoneNumber , messageId , message . text . body );
break ;
case "image" :
case "document" :
await handleMediaMessage ( db , phoneNumber , messageId , message );
break ;
case "interactive" :
await handleButtonReply ( db , phoneNumber , messageId , message . interactive . button_reply . id );
break ;
}
Common Patterns
Webhook Handler Structure
All webhooks follow this pattern:
import { OpenAPIHono } from "@hono/zod-openapi" ;
import { z } from "zod" ;
const app = new OpenAPIHono < Context >();
app . openapi (
createRoute ({
method: "post" ,
path: "/" ,
summary: "Service webhook handler" ,
// ...
}),
async ( c ) => {
// 1. Get raw body for signature verification
const rawBody = await c . req . text ();
// 2. Verify signature
const isValid = await verifySignature ( rawBody , headers );
if ( ! isValid ) {
throw new HTTPException ( 401 , { message: "Invalid signature" });
}
// 3. Parse and validate payload
const body = JSON . parse ( rawBody );
const result = webhookSchema . safeParse ( body );
if ( ! result . success ) {
throw new HTTPException ( 400 , { message: "Invalid payload" });
}
// 4. Process webhook
await processWebhook ( result . data );
// 5. Always return 200
return c . json ({ success: true });
}
);
Error Handling
Webhooks log errors but return 200 to prevent retries:
try {
await processEvent ( payload );
} catch ( error ) {
logger . error ( "Webhook processing failed" , {
webhookType: payload . type ,
error: error instanceof Error ? error . message : "Unknown error"
});
// Still return 200 to prevent infinite retries from provider
return c . json ({ success: true });
}
Connection Lookup
Webhooks look up connections using provider-specific identifiers:
// Plaid: item_id
const connection = await getBankConnectionByReferenceId ( db , {
referenceId: itemId
});
// Teller: enrollment_id
const connection = await getBankConnectionByEnrollmentId ( db , {
enrollmentId: enrollmentId
});
// WhatsApp: phone number
const app = await getAppByWhatsAppNumber ( db , phoneNumber );
Triggering Background Jobs
Webhooks trigger asynchronous jobs for heavy processing:
import { tasks } from "@trigger.dev/sdk" ;
// Trigger sync job
await tasks . trigger ( "sync-connection" , {
connectionId: connection . id ,
manualSync: false
});
// Trigger upload job
await tasks . trigger ( "whatsapp-upload" , {
teamId: team . id ,
phoneNumber: phoneNumber ,
mediaId: message . image . id ,
mimeType: message . image . mime_type
});
Testing Webhooks
Local Testing
Use ngrok
Expose your local server:
Update Webhook URLs
Configure the ngrok URL in service dashboards: https://abc123.ngrok.io/webhook/plaid
Test Events
Trigger test events from service dashboards:
Plaid: Use sandbox environment
Teller: Send test webhook
WhatsApp: Send message from test number
Manual Testing
cURL - Plaid
cURL - Teller
cURL - WhatsApp
curl -X POST https://api.midday.ai/webhook/plaid \
-H "Content-Type: application/json" \
-H "Plaid-Verification: jwt_signature" \
-d '{
"webhook_type": "TRANSACTIONS",
"webhook_code": "SYNC_UPDATES_AVAILABLE",
"item_id": "test_item_id",
"environment": "sandbox"
}'
Monitoring
Webhooks are logged with structured data:
logger . info ( "Webhook received" , {
webhookType: type ,
provider: "plaid" ,
itemId: item_id ,
code: webhook_code
});
Use logging infrastructure to:
Track webhook volume
Monitor processing errors
Debug signature failures
Identify slow handlers
Webhook handlers should complete quickly (< 5 seconds). Use background jobs for heavy processing to avoid timeouts.
Security Best Practices
Never process webhooks without signature verification: const isValid = await verifySignature ( rawBody , signature , secret );
if ( ! isValid ) {
throw new HTTPException ( 401 );
}
Use Raw Body for Verification
Read body as text before parsing: const rawBody = await c . req . text ();
const isValid = verify ( rawBody , signature );
const body = JSON . parse ( rawBody ); // Parse after verification
Prevent infinite retries: try {
await process ( webhook );
} catch ( error ) {
logger . error ( "Processing failed" , { error });
return c . json ({ success: true }); // Still 200
}
Use Zod for payload validation: const result = webhookSchema . safeParse ( body );
if ( ! result . success ) {
throw new HTTPException ( 400 );
}
Rate Limit Webhook Endpoints
Protect against abuse: app . use ( "/webhook/*" , rateLimit ({
windowMs: 60000 ,
max: 100
}));