Skip to content

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.

POST /your/address
Content-Type: application/json
ivc-event: submission.created
ivc-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.

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);
}
  • Reply with any 2xx within 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, 429 or 5xx, or do not reply in time. Any other answer, such as 400 or 404, is final and is not tried again.

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.

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.

  • 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.
  • localhost and addresses on your own computer are not allowed. To test, use a public address, like one from a tunnel.