Webhooks
Webhooks send a signed POST request to your server when something happens in your workspace, so you do not need to poll.
Set up an endpoint
In your workspace, open Settings, API and integrations, Webhooks, add your HTTPS address and pick events. You can also manage endpoints with a secret key that has the webhooks:manage scope. The signing secret (whsec_...) is shown once.
The address must be public HTTPS. Addresses on private or internal networks, and addresses with a user name or password, are refused.
curl -X POST "https://api.visaflow.work/v1/webhooks" \
-H "Authorization: Bearer vf_test_xxxxxxxx_PASTE_THE_REST_OF_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/visaflow",
"description": "CRM sync",
"events": ["lead.created", "lead.updated", "application.module_updated"]
}'
# The answer includes "secret" once. Store it; it is not shown again.Events
| Event | Sent when |
|---|---|
lead.created | A lead is created by your team, a form, the API or a partner. |
lead.updated | Lead fields change. data.changed_fields lists which. |
lead.assigned | A lead gets a new owner. |
lead.converted | A lead becomes an applicant with an application. |
lead.lost | A lead is marked lost. |
application.stage_changed | An application moves to another stage. |
application.module_updated | A case module changes: marked Updated or Pending, switched on or off, restored or added. data.module_event says what changed. |
application.outcome | A visa outcome is recorded. |
document.uploaded | A document is uploaded and passes the virus scan. |
document.rejected | A document is rejected. |
invoice.issued | An invoice is issued. |
payment.received | A payment is recorded. |
refund.paid | A refund is marked paid. |
task.overdue | A task passes its due time. |
webhook.test | You press Send test. Always sent to that endpoint. |
The request
A POST with a JSON body. data is the record in the same shape as the API returns it.
POST /webhooks/visaflow HTTP/1.1
Content-Type: application/json
User-Agent: VisaFlow-Webhooks/1.0
X-VisaFlow-Event: lead.created
X-VisaFlow-Delivery: DELIVERY_ID
X-VisaFlow-Signature: t=1791234567,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd{
"id": "EVENT_ID",
"event": "lead.created",
"tenant_id": "TENANT_ID",
"created_at": "2026-10-02T05:30:00.000Z",
"data": {
"id": "LEAD_ID",
"full_name": "PW Sample Lead",
"email": "sample.lead@example.test",
"phone": "+919000000001",
"stage": { "key": "new", "name": "New" },
"source": { "key": "lead_api", "name": "Lead API" },
"branch": { "id": "BRANCH_ID", "code": "HO" },
"owner": null
}
}Check the signature
- Read the raw request body before parsing it.
- Split the X-VisaFlow-Signature header on commas: t is the time in Unix seconds, each v1 is a signature.
- Compute HMAC-SHA256 of the text t + "." + raw body, using your endpoint secret as the key, as lowercase hex.
- Compare it with each v1 value in constant time. One match is enough.
- Reject the request if t is more than 5 minutes away from your clock.
// Node.js 18 or later with Express. Read the raw body: the signature covers the exact bytes.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.VISAFLOW_WEBHOOK_SECRET; // whsec_REPLACE_WITH_YOUR_ENDPOINT_SECRET
const TOLERANCE_SECONDS = 300; // reject requests older than 5 minutes
export function verifySignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
const parts = String(header || "").split(",").map((p) => p.trim().split("="));
const timestamp = parts.find(([k]) => k === "t")?.[1];
const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
if (!timestamp || signatures.length === 0) return false;
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
// During a secret rotation there are two v1 values. Accept the request if either matches.
return signatures.some(
(sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
);
}
const app = express();
app.post("/webhooks/visaflow", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verifySignature(raw, req.get("X-VisaFlow-Signature"), SECRET)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(raw);
// event.id is the same on every retry and resend: skip ids you already handled.
console.log(event.event, event.data.id);
res.sendStatus(200); // answer 2xx within 10 seconds, do slow work afterwards
});
app.listen(8080);After you rotate the secret, every request carries two v1 values for 24 hours: one from the new secret and one from the old. Accept either, then switch to the new secret.
Respond, retries and pausing
- Answer with any 2xx status within 10 seconds. Do slow work after you answer.
- Anything else, a timeout or a redirect counts as a failure. We retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then mark the delivery failed.
- After 50 failures in a row the endpoint is paused and the workspace owners are told. Resume it in Settings once your server is fixed.
- The id field is the event id. It stays the same on every retry and on a manual resend, so use it to skip events you already handled.
Delivery log
Each endpoint has a log of the last 30 days with the request headers, body, your response and its timing, and a Resend button.