logicspike/docs

Forms

Forms Service — Webhooks

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-deliveries

Returns 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
Forms