Skip to content

Webhooks

Today Canverly delivers one event by webhook: lead.created, fired on every accepted submission of a form. Webhooks are configured in the site’s admin, in the Forms app area.

  1. Enter the destination URL. It must be https, without a user and password in the URL and without a service port. The URL is validated on save and again on every delivery.
  2. Choose the scope. One specific form, or every form of the site.
  3. Store the secret. The signing secret is generated by the platform and shown once, at creation. To change it, delete the webhook and create a new one.

If your endpoint requires its own authentication, you can register one header (name and value). The value is stored encrypted and is never returned afterwards. Names with the x-canverly- prefix are refused, because that prefix carries the signature.

A POST with a JSON body and these headers:

Header Content
x-canverly-signature t=<unix>,v1=<hmac>: the signature (see below).
x-canverly-timestamp The same instant, in clear, for diagnostics only.
x-canverly-event The event, today always lead.created.
x-canverly-delivery Delivery id, for deduplication.

Without a mapping, the body is the event with the fields event, site_id, form_id, form_slug, lead_id, created_at (RFC 3339), data (the filled fields) and meta (source page, UTM and technical data about the submission). With a mapping, the body has the shape the owner designed in the admin, and the admin preview shows the exact JSON that will be sent.

The signature is an HMAC-SHA256, hex-encoded (64 characters), computed with the secret over the text <t>.<body>: the value of t, a dot and the raw body, byte for byte, as received.

import crypto from 'node:crypto';
function signatureIsValid(header, rawBody, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const received = String(parts.v1 ?? '');
return received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

The v1= prefix exists for a future algorithm change: during the transition, receivers will be able to accept both versions.

  • Delivery is at least once: a lost response makes the same lead arrive again. Use x-canverly-delivery to drop repeats.
  • If the endpoint fails, the platform retries with growing waits (30 s, 1 min, 2 min, 4 min…, capped at 6 hours between attempts), up to 10 attempts, about 8.5 hours in total.
  • After the last one, the delivery is marked as failed and stays visible in the admin.