Last Updated: 2026-06-28 Status: Active Companion docs: api-spec.md · database.md
Webhooks deliver real-time submission notifications to your own endpoint. Every accepted submission (excluding spam) triggers a POST to the form's configured webhookUrl.
1. Setup
Set webhookUrl on a form via PUT /admin/forms/:id or at creation time. When a URL is set for the first time, the service auto-generates a webhookSecret used for HMAC signing.
{
"webhookUrl": "https://hooks.example.com/vlozi"
}IMPORTANT
The webhookSecret is generated once when you first set webhookUrl. It cannot be rotated without clearing and re-setting the URL (PUT { "webhookUrl": null } then PUT { "webhookUrl": "..." }). Treat it like a password — do not expose it in client-side code.
2. Payload
Every delivery is a POST with Content-Type: application/json:
{
"event": "submission.created",
"formId": "form_abc123",
"formName": "Contact Us",
"submissionId": "sub_xyz789",
"submittedAt": "2026-06-28T10:00:00.000Z",
"data": {
"email": "visitor@example.com",
"message": "Hello!",
"resume": {
"__file": true,
"mediaId": "med_01j…",
"name": "resume.pdf",
"size": 184320,
"type": "application/pdf",
"url": "https://cdn.vlozi.app/…/resume.pdf"
}
},
"meta": {
"referer": "https://example.com/contact",
"origin": "https://example.com",
"utm_source": "newsletter",
"utm_medium": "email"
}
}3. Request Headers
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Vlozi-Forms/1.0 |
X-Vlozi-Form-Id |
The form's id |
X-Vlozi-Submission-Id |
The submission's id |
X-Vlozi-Timestamp |
Unix timestamp (ms) of delivery attempt |
X-Vlozi-Signature |
HMAC of `${timestamp}.${body}` — sha256=<hex> (if webhookSecret is set) |
4. Verifying the Signature
Always verify X-Vlozi-Signature before trusting payload content:
import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhook(
body: string, // raw request body (before JSON.parse)
signature: string, // X-Vlozi-Signature header value
timestamp: string, // X-Vlozi-Timestamp header value
secret: string, // form.webhookSecret from your DB
): boolean {
// The signature covers `${timestamp}.${body}`, not the body alone — binding the
// timestamp in lets you reject replayed deliveries.
const expected = "sha256=" + createHmac("sha256", secret)
.update(`${timestamp}.${body}`, "utf8")
.digest("hex");
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length) return false;
return timingSafeEqual(sigBuf, expBuf);
}
// Express example
app.post("/vlozi-webhook", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["x-vlozi-signature"] as string;
const ts = req.headers["x-vlozi-timestamp"] as string;
if (!verifyWebhook(req.body.toString(), sig, ts, process.env.WEBHOOK_SECRET!)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString());
// process event.data …
res.sendStatus(200);
});NOTE
Compute the HMAC over the raw request body bytes before parsing — not over a re-serialized object. Always use timingSafeEqual to prevent timing attacks.
5. Retry Policy
The service makes up to 3 delivery attempts per submission with exponential backoff:
| Attempt | Delay before attempt |
|---|---|
| 1 | Immediate (0 ms) |
| 2 | 1 000 ms |
| 3 | 3 000 ms |
An attempt succeeds if the target returns any 2xx status code. Any other status (including timeouts) is treated as a failure and the next attempt is scheduled.
Delivery runs in waitUntil() — it does not block the 200 response to the visitor.
Each attempt is logged as a separate row in form_webhook_deliveries (see database.md §3). The final row's status will be success or failed.
6. Delivery Log
GET /admin/forms/:id/webhook-deliveriesReturns the last 20 delivery records for all submissions to that form, newest first:
{
"data": [
{
"id": "wd_…",
"submissionId": "sub_…",
"url": "https://hooks.example.com/vlozi",
"attempt": 1,
"statusCode": 200,
"responseBody": "OK",
"status": "success",
"deliveredAt": "2026-06-28T10:00:01.500Z",
"createdAt": "2026-06-28T10:00:01.000Z"
}
]
}7. Manual Retry
There is currently no REST endpoint for manual retry. Use the MCP tool:
{ "tool": "retry-webhook-delivery", "input": { "id": "wd_…" } }Manual retry creates a new form_webhook_deliveries row — it does not update the status of the original failed row. The failed row remains in the log as a historical record.
8. Known Gaps
| Gap | Impact | Workaround |
|---|---|---|
| Webhook secret cannot be rotated | If the secret leaks, you must clear and reset webhookUrl to get a new secret |
Treat the secret as highly sensitive; rotate by PUT { webhookUrl: null } then PUT { webhookUrl: "..." } |
| Manual retry via MCP only (no REST endpoint) | Dashboard users cannot retry from the UI | Use MCP client or wait for a REST endpoint to be added |
| Retry does not update old row status | "failed" rows stay "failed" even after a successful manual retry | Check the newer row's status for the retry outcome |
| No webhook timeout override | Worker default timeout (~30s) applies | Ensure your endpoint responds quickly |