Webhooks
A webhook sends each new response to an address you choose, as a POST with a JSON body. Connect it and turn it on for a form from the Integrations tab. When you connect it you are shown a secret once. Keep it: you need it to check the calls.
The call
Section titled “The call”POST /your/addressContent-Type: application/jsonivc-event: submission.createdivc-signature: t=1792516213,v1=984f0095778b043f3ea787967a853062e9ce20a97f7153a2b3ccea05808c0652{ "event": "submission.created", "ref": "01a15a4a-b93f-7ab2-a386-a238896229dd", "occurredAt": "2026-10-20T17:10:10.305Z", "data": { "title": "New response: Contact form", "formId": "01a15a21-e057-7606-811e-7d71e3794f0a", "formName": "Contact form", "submissionId": "01a15a4a-b93f-7ab2-a386-a238896229dd", "url": "https://form.invoy.in/app/forms/01a15a21-e057-7606-811e-7d71e3794f0a/submissions", "fields": [ { "id": "a741c884-c62b-4be6-bd17-d0f795586299", "name": "email", "label": "Email", "value": "[email protected]" }, { "id": "94f3a973-bb48-4bd0-a826-a4436466eef8", "name": "age", "label": "Age", "value": 31 }, { "id": "0d3b5c88-f106-4924-8b6c-9a700e6d4a46", "name": "sports", "label": "Sports", "value": ["Golf", "Surf"] } ] }}| Field | Meaning |
|---|---|
event |
What happened. Today only submission.created. |
ref |
The id of the response. The same response always has the same ref. |
occurredAt |
When the response came in, in UTC. |
data.fields[].id |
The id of the question. It does not change when you rename the question. |
data.fields[].name |
The name of the question, as you set it in the builder. |
data.fields[].label |
The text of the question. |
data.fields[].value |
The answer as it was given: text for text, a number for a number, a list for a choice of several. |
Questions that were not answered are left out. Uploaded files are not sent.
Check that the call is ours
Section titled “Check that the call is ours”ivc-signature is t=<seconds>,v1=<hex>. v1 is an HMAC-SHA256 of <seconds>.<raw body>, made with your secret. Check it on the raw body, before you parse it, and refuse a call whose time is far from now so an old one cannot be replayed.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function isOurs(rawBody, header, secret, toleranceSeconds = 300) { const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); const age = Math.abs(Date.now() / 1000 - Number(parts.t)); if (!(age <= toleranceSeconds)) return false; // also refuses a missing or bad `t`
const expected = createHmac('sha256', secret) .update(`${parts.t}.${rawBody}`) .digest(); const given = Buffer.from(parts.v1 ?? '', 'hex'); return given.length === expected.length && timingSafeEqual(given, expected);}import hashlib, hmac, time
def is_ours(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: try: parts = dict(p.split("=", 1) for p in header.split(",")) sent_at = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - sent_at) > tolerance: return False
signed = parts["t"].encode() + b"." + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", ""))Answer the call
Section titled “Answer the call”- Reply with any
2xxwithin 10 seconds. What you reply with is not read. - A redirect is not followed. Give the final address.
- We try again when you reply
408,429or5xx, or do not reply in time. Any other answer, such as400or404, is final and is not tried again.
When your server is down
Section titled “When your server is down”A call that fails in a way that can pass is tried again after 30 seconds, 2 minutes, 10 minutes and 1 hour. That is five tries in all. After the last one it is marked Not sent in the log, and it is not sent again on its own.
The same response can arrive twice
Section titled “The same response can arrive twice”A retry can reach you after your first answer was lost on the way. Use ref to tell: keep the ref of each response you handled and ignore one you have seen. Calls can also arrive out of order, so use occurredAt if the order matters.
The address
Section titled “The address”- It must start with
https://and be a public website name. An address that is a number, has a sign-in in it, or is on a private network is refused. localhostand addresses on your own computer are not allowed. To test, use a public address, like one from a tunnel.