Webhooks
Webhooks let your application receive real-time HTTP notifications when events occur in Banklyze. Instead of polling the API, configure a webhook URL and Banklyze will send a POST request to your endpoint whenever a subscribed event fires.
Each organization has a single webhook configuration. Use PUT /v1/webhooks/config to set it up.
Available Events
| Name | Type | Required | Description |
|---|---|---|---|
| deal.created | event | Optional | A new deal was created. Fires after POST /deals. |
| deal.updated | event | Optional | Deal details or status changed. Fires after PATCH /deals/{id} or when processing updates the deal. |
| deal.decision_changed | event | Optional | A deal was approved, declined, or funded. Fires after POST /deals/{id}/decision. |
| document.uploaded | event | Optional | A new document was uploaded. Fires after POST /deals/{id}/documents. |
| document.processed | event | Optional | A document was successfully processed through the analysis pipeline. |
| document.failed | event | Optional | Document processing failed due to an unrecoverable error. |
| recommendation.generated | event | Optional | An underwriting recommendation was generated for a deal. |
| deal.recommendation_ready | event | Optional | All documents processed and the deal is ready for review with a recommendation. |
Payload Structure
Every webhook delivery is a POST request with a JSON body. All payloads share this structure:
| Name | Type | Required | Description |
|---|---|---|---|
| event | string | Optional | Event type (e.g. deal.created) |
| event_id | string | Optional | Unique event ID for idempotency |
| timestamp | string | Optional | ISO 8601 timestamp |
| api_version | string | Optional | API version (e.g. 2026-02-15) |
| data | object | Optional | Event-specific payload data |
deal.created
{
"event": "deal.created",
"event_id": "evt_a1b2c3d4e5f6",
"timestamp": "2026-02-15T14:35:00Z",
"api_version": "2026-02-15",
"data": {
"id": 42,
"business_name": "Acme Corp",
"status": "new",
"funding_amount_requested": 50000.00,
"created_at": "2026-02-15T14:35:00Z"
}
}deal.updated
{
"event": "deal.updated",
"event_id": "evt_u1p2d3a4t5e6",
"timestamp": "2026-02-15T15:00:00Z",
"api_version": "2026-02-15",
"data": {
"id": 42,
"business_name": "Acme Corp",
"status": "ready",
"health_score": 72.5,
"health_grade": "B",
"document_count": 3
}
}deal.decision_changed
{
"event": "deal.decision_changed",
"event_id": "evt_d1e2c3i4s5n6",
"timestamp": "2026-02-16T10:00:00Z",
"api_version": "2026-02-15",
"data": {
"id": 42,
"business_name": "Acme Corp",
"decision": "approve",
"decided_by": "Jane Doe"
}
}document.processed
{
"event": "document.processed",
"event_id": "evt_x9y8z7w6v5u4",
"timestamp": "2026-02-15T14:30:45Z",
"api_version": "2026-02-15",
"data": {
"id": 15,
"deal_id": 42,
"filename": "acme_jan_2026.pdf",
"document_type": "bank_statement",
"status": "completed",
"bank_name": "Chase",
"transaction_count": 87
}
}document.failed
{
"event": "document.failed",
"event_id": "evt_f1a2i3l4e5d6",
"timestamp": "2026-02-15T14:31:00Z",
"api_version": "2026-02-15",
"data": {
"id": 16,
"deal_id": 42,
"filename": "corrupted_file.pdf",
"status": "failed",
"error_message": "Unable to extract text from PDF"
}
}recommendation.generated
{
"event": "recommendation.generated",
"event_id": "evt_r1e2c3o4m5m6",
"timestamp": "2026-02-15T14:35:00Z",
"api_version": "2026-02-15",
"data": {
"deal_id": 42,
"decision": "approve",
"total_score": 86.7,
"health_grade": "B",
"max_advance": 37629.00,
"cfcr": 1.45
}
}Webhook Headers
Each webhook request includes the following headers:
| Name | Type | Required | Description |
|---|---|---|---|
| Content-Type | header | Required | Always application/json. |
| X-Webhook-Signature | header | Optional | HMAC-SHA256 signature in the format sha256={hex_digest}. Present only if a secret was configured. |
Configuring a Webhook
Set up your webhook endpoint via the API. You must provide an HTTPS URL and optionally specify which events to subscribe to:
curl -X PUT https://api.banklyze.com/v1/webhooks/config \
-H "X-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/banklyze",
"events": ["deal.created", "deal.updated", "document.processed", "document.failed", "recommendation.generated"],
"secret": "whsec_your_signing_secret"
}'The secret field is optional but strongly recommended. It is used to generate the X-Webhook-Signature header so you can verify that incoming webhooks are genuinely from Banklyze.
X-Webhook-Signature header.Testing Webhooks
After configuration, send a test event to verify your endpoint is reachable and your handler works correctly:
curl -X POST https://api.banklyze.com/v1/webhooks/test \
-H "X-API-Key: your_api_key_here"The test endpoint sends a synthetic webhook.test event with sample data. Your endpoint should return a 2xx response to indicate success.
Signature Verification
If you configured a secret, Banklyze signs every payload using HMAC-SHA256. The signature is included in the X-Webhook-Signature header with a sha256= prefix.
To verify a webhook:
- Read the raw request body (before parsing JSON).
- Compute the HMAC-SHA256 of the raw body using your shared secret.
- Compare the computed hex digest with the value after
sha256=in theX-Webhook-Signatureheader, using a timing-safe comparison. - Reject the request if the signatures do not match.
TypeScript verification example:
import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhookSignature(
rawBody: string,
signatureHeader: string,
secret: string
): boolean {
// Header format: "sha256={hex_digest}"
const signature = signatureHeader.replace("sha256=", "");
const expected = createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}Python verification example:
import hmac
import hashlib
def verify_webhook_signature(
raw_body: bytes,
signature_header: str,
secret: str,
) -> bool:
# Header format: "sha256={hex_digest}"
signature = signature_header.removeprefix("sha256=")
expected = hmac.new(
secret.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)Retry Policy
If your endpoint does not return a 2xx status code (or does not respond within 10 seconds), Banklyze retries the delivery up to 3 times with exponential backoff:
| Name | Type | Required | Description |
|---|---|---|---|
| 1st retry | 5s delay | Optional | First retry fires 5 seconds after the initial failure. |
| 2nd retry | 30s delay | Optional | Second retry fires 30 seconds after the first retry. |
| 3rd retry | 2m delay | Optional | Final retry fires 2 minutes after the second retry. |
After 3 failed retries, the delivery is marked as failed. The same event_id is used across all retry attempts for idempotency.
Circuit Breaker
If your endpoint fails 10 consecutive deliveries, the webhook circuit breaker opens and deliveries are paused. The circuit breaker resets after 5 minutes, at which point deliveries resume. You can monitor webhook health in the dashboard.
event_id field for idempotent processing. Retries reuse the same event ID, so store it and deduplicate on your end to avoid handling the same event twice.Best Practices
- Respond quickly. Return a
200response immediately and process the event asynchronously. Long-running handlers may cause timeouts and trigger retries. - Handle duplicates. Use the
event_idto deduplicate events. Retries send the same event ID. - Verify signatures. Always validate the
X-Webhook-Signatureheader to ensure payloads are authentic. - Use HTTPS. Webhook URLs must use HTTPS. HTTP endpoints are rejected during configuration.
- Log deliveries. Store incoming webhook payloads for debugging and auditing.
Example Webhook Handler
A complete Express.js webhook handler with signature verification:
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";
const app = express();
const WEBHOOK_SECRET = process.env.BANKLYZE_WEBHOOK_SECRET!;
// Use raw body for signature verification
app.post(
"/webhooks/banklyze",
express.raw({ type: "application/json" }),
(req, res) => {
const signatureHeader = req.headers["x-webhook-signature"] as string;
const rawBody = req.body.toString();
// 1. Verify signature
if (signatureHeader) {
const signature = signatureHeader.replace("sha256=", "");
const expected = createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
if (
!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
) {
console.error("Invalid webhook signature");
return res.status(401).json({ error: "Invalid signature" });
}
}
// 2. Parse the event
const event = JSON.parse(rawBody);
const { event: eventType, event_id, data } = event;
console.log(`Received ${eventType} (event: ${event_id})`);
// 3. Handle the event
switch (eventType) {
case "deal.created":
console.log(`Deal ${data.id} created: ${data.business_name}`);
break;
case "deal.updated":
console.log(
`Deal ${data.id} updated — Grade: ${data.health_grade}`
);
break;
case "document.processed":
console.log(
`Document ${data.id} processed for deal ${data.deal_id}`
);
break;
case "document.failed":
console.error(
`Document ${data.id} failed: ${data.error_message}`
);
break;
case "recommendation.generated":
console.log(
`Recommendation for deal ${data.deal_id}: ${data.decision}`
);
break;
default:
console.log(`Unhandled event type: ${eventType}`);
}
// 4. Respond immediately
res.status(200).json({ received: true });
}
);
app.listen(3000, () => {
console.log("Webhook handler listening on port 3000");
});