OvertureDocs

Webhooks

A webhook sends each submission to your own system as it arrives: Overture makes an HTTP POST with the submission as JSON to a URL you choose. Use it to feed a CRM, a ticketing system, a chat channel, or your own backend.

Webhooks are set up per form by the form's owner in the Overture portal, and are available on the Business and Unlimited plans. A form can have up to 10 webhooks. Every request is signed with a secret only you and Overture know, and a webhook can also send a bearer token you provide. This page describes what your endpoint receives and how delivery works.

Delivery happens in the background

Webhooks never slow down a submission. As soon as Overture accepts a submission, the visitor gets their confirmation; webhooks are sent separately, usually within a few seconds. A slow or failing endpoint never causes a submission to be rejected. Each webhook on a form is delivered independently.

The request

Every delivery is a POST with a JSON body and these headers:

Header Value
content-type application/json
user-agent Overture-Webhooks/1.0
x-overture-event form.submission
x-overture-delivery A unique id for this submission and webhook. The same on every retry.
x-overture-attempt 1 for the first try, 2 for the first retry, and so on.
x-overture-timestamp ISO-8601 time the request was signed.
x-overture-signature sha256= + hex HMAC-SHA256 of timestamp + "." + raw body, keyed by the webhook's signing secret.
authorization Bearer <your token>, only when the webhook uses a bearer token.

x-overture-event is form.submission for a real submission, consent.recorded for a visitor's choice on a cookie consent banner, and webhook.test for a test event (see Sending a test event below).

Payload for embedded forms

Forms that use the embed snippet (initContactForms) send the submitted fields under data, including any extra fields your form posts. Attached files are described by name, type and size only; their contents are never included.

{
  "event": "form.submission",
  "formId": "YOUR_FORM_ID",
  "submissionId": "SUBMISSION#2026-10-07T17:42:01.123Z#9b2c6f1e-3d1a-4f8e-a0c4-5e7d2b1a9c33",
  "referenceId": "3f0c2a7e-8b4d-4c1f-9e2a-6d5b7c8a1f04",
  "submittedAt": "2026-10-07T17:42:01.123Z",
  "data": {
    "name": "Alex Doe",
    "phone": "+1-555-555-5555",
    "email": "alex@example.com",
    "reason": "Question",
    "message": "Need details",
    "company": "Acme"
  },
  "attachments": [
    { "filename": "resume.pdf", "contentType": "application/pdf", "size": 48213 }
  ],
  "meta": {
    "ip": "203.0.113.5",
    "userAgent": "Mozilla/5.0 (...)",
    "origin": "https://www.example.com"
  }
}

submissionId and referenceId match the values returned to the browser on success (and appended to your redirect URL), so you can correlate the two. attachments is omitted when no files were sent.

Payload for hosted forms

Forms built in Overture and rendered with initStandardForm (or on Overture's hosted pages) include the account's tenantId, key data by each field's key, and add the visitor's locale:

{
  "event": "form.submission",
  "formId": "0f6b1c9e-2a4d-4e8f-b1c3-7d9a5e2f8b60",
  "tenantId": "a4e1d2c3-5b6f-4a7e-9c8d-1f2e3a4b5c6d",
  "submissionId": "7c2d9e4f-1a3b-4c5d-8e6f-9a0b1c2d3e4f",
  "referenceId": "d1e2f3a4-b5c6-4d7e-8f9a-0b1c2d3e4f5a",
  "submittedAt": "2026-10-07T17:42:01.123Z",
  "data": {
    "first_name": "Alex",
    "email": "alex@example.com",
    "appointment_type": "consult"
  },
  "meta": {
    "ip": "203.0.113.5",
    "userAgent": "Mozilla/5.0 (...)",
    "locale": "en"
  }
}

Payload for cookie consent choices

A cookie consent banner can have webhooks too. Each choice a visitor makes is sent as consent.recorded. referenceId is the visitor's consent ID (the same on every choice they make, and in their overture_consent cookie), and data describes the choice:

{
  "event": "consent.recorded",
  "formId": "5d1c7a2e-9b3f-4e6d-8a1c-2f7e9b4d6a10",
  "tenantId": "a4e1d2c3-5b6f-4a7e-9c8d-1f2e3a4b5c6d",
  "submissionId": "c3a9e1f2-7b6d-4c5e-9f8a-1d2e3f4a5b6c",
  "referenceId": "6f9619ff-8b86-4011-b42d-00c04fc964ff",
  "submittedAt": "2026-10-07T17:42:01.123Z",
  "data": {
    "consentId": "6f9619ff-8b86-4011-b42d-00c04fc964ff",
    "action": "save",
    "granted": ["necessary", "analytics"],
    "denied": ["preferences", "marketing"],
    "revision": 2,
    "version": 5,
    "pageUrl": "https://www.example.com/pricing",
    "gcm": {
      "ad_storage": "denied",
      "ad_user_data": "denied",
      "ad_personalization": "denied",
      "analytics_storage": "granted",
      "functionality_storage": "denied",
      "personalization_storage": "denied",
      "security_storage": "granted"
    }
  },
  "meta": {
    "ip": "203.0.113.0",
    "userAgent": "Mozilla/5.0 (...)",
    "locale": "en",
    "origin": "https://www.example.com"
  }
}

action is accept_all, reject_all or save. gcm is the Google Consent Mode state sent with the choice, when Consent Mode is on. The IP address is shortened (its last part removed) before it's stored or sent.

Responding

Reply with any 2xx status within 10 seconds. The response body is ignored. If you need to do slow work, acknowledge first and process afterwards (for example, put the payload on your own queue).

Your response What Overture does
2xx Delivered.
No response, a network error, or a timeout Retries.
408, 425, 429, or any 5xx Retries.
3xx Fails. Redirects are not followed; use the final URL.
Any other 4xx (for example 401, 404, 410) Fails. Not retried.

Retries back off exponentially, waiting about 1, 2, 4, 8 and then 15 minutes, for up to 6 attempts over roughly half an hour. After the last attempt the delivery is recorded as failed. Each delivery's outcome appears on the submission's timeline in the portal.

Handle duplicates and ordering

Delivery is at least once: in rare cases (a timeout after you already processed the request, for instance) the same delivery arrives more than once. Use x-overture-delivery as an idempotency key and skip ids you have already handled. Deliveries are not ordered: a retried submission can arrive after a newer one.

Verifying the signature

When you save a webhook, the portal shows its signing secret (it starts with whsec_) once. Store it with your endpoint's other secrets. Every request carries x-overture-timestamp and x-overture-signature; recompute the signature from the raw request body, before parsing it, and compare in constant time:

import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.OVERTURE_SIGNING_SECRET; // whsec_...
const handled = new Set(); // use a durable store in production

const verifyOverture = (rawBody, signature, timestamp) => {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  return (
    typeof signature === "string" &&
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  );
};

app.post(
  "/webhooks/overture",
  express.raw({ type: "application/json", limit: "1mb" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8");
    const timestamp = req.get("x-overture-timestamp");
    if (!verifyOverture(rawBody, req.get("x-overture-signature"), timestamp)) {
      return res.sendStatus(401);
    }
    // Reject stale requests (each retry is signed again with a fresh timestamp).
    if (Math.abs(Date.now() - Date.parse(timestamp)) > 5 * 60 * 1000) {
      return res.sendStatus(401);
    }

    const deliveryId = req.get("x-overture-delivery");
    if (handled.has(deliveryId)) return res.sendStatus(200); // duplicate
    handled.add(deliveryId);

    const { event, formId, data } = JSON.parse(rawBody);
    if (event === "webhook.test") return res.sendStatus(204);
    // ...hand the submission to your system, then acknowledge quickly.
    res.sendStatus(204);
  }
);

Each retry is signed again with a fresh timestamp, so you can reject requests whose timestamp is more than a few minutes old.

Rotating the signing secret

To replace a secret, choose Rotate signing secret on the webhook in the portal and save. The new secret is shown once. Deliveries already queued are signed with the new secret from then on, so accept both the old and the new secret for a few minutes before dropping the old one.

Webhooks created before signing was available send unsigned requests until they are saved again (or rotated); the portal marks them Not signed yet.

Verifying the bearer token

A webhook can also send a bearer token you choose, for receivers that only support token auth. Check the authorization header on every request and reject anything else with 401. Compare in constant time:

const expected = Buffer.from(`Bearer ${process.env.OVERTURE_WEBHOOK_TOKEN}`);
const received = Buffer.from(req.get("authorization") ?? "");
const ok = received.length === expected.length && crypto.timingSafeEqual(received, expected);

A token is only ever sent to the host it was saved for. If the webhook is moved to a different host (which requires entering a token again), deliveries that were already queued for the old host fail rather than carry the new token.

To rotate a token, start accepting the new token alongside the old one, update it on the webhook in the portal, and stop accepting the old token a few minutes later (deliveries already in progress can still carry it briefly).

Sending a test event

Send test event on a saved webhook makes one real request to your endpoint, signed and authenticated exactly like a submission, and shows the result (your endpoint's status code, or the error) right away. It is not retried and doesn't appear on any submission. The body has "event": "webhook.test" and sample data in place of submitted fields:

{
  "event": "webhook.test",
  "formId": "0f6b1c9e-2a4d-4e8f-b1c3-7d9a5e2f8b60",
  "tenantId": "a4e1d2c3-5b6f-4a7e-9c8d-1f2e3a4b5c6d",
  "submissionId": "test-7c2d9e4f-1a3b-4c5d-8e6f-9a0b1c2d3e4f",
  "submittedAt": "2026-10-07T17:42:01.123Z",
  "data": {
    "message": "This is a test event from Overture. It is not a real submission.",
    "name": "Test Person",
    "email": "test@example.com"
  },
  "meta": {}
}

Treat webhook.test as a no-op that returns 2xx.

Signed webhooks in form templates

Webhooks defined in a hosted form's YAML template (destinations.webhooks) carry their own secret instead of a portal-generated one. They send the same x-overture-timestamp and x-overture-signature headers, signed with that secret, and are verified the same way.

Endpoint requirements

  • The URL must use https. (YAML-template webhooks may still use http.)
  • The host must resolve to a public address. Overture refuses to deliver to localhost, private networks, and link-local or other reserved addresses, and re-checks this on every attempt.
  • Don't put credentials in the URL (https://user:pass@…); use a bearer token.
  • On the Solo and Team plans, webhooks set up before a plan change keep delivering and can be removed, but new webhooks need Business or Unlimited.
  • Webhook payloads are limited to 256 KB. A larger submission is still accepted (and emailed, if the form sends email), but isn't sent to webhooks.

Data you receive

The payload contains everything the visitor submitted, which can include personal or health information. Only point webhooks at systems that are allowed to receive that data, and verify the signature on every request.