# Webhooks

What a webhook call looks like, how to check that it is ours, and what happens when your server is down.

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

```http
POST /your/address
Content-Type: application/json
ivc-event: submission.created
ivc-signature: t=1792516213,v1=984f0095778b043f3ea787967a853062e9ce20a97f7153a2b3ccea05808c0652
```

```json
{
  "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": "alice@example.com" },
      { "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

`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.

**Node.js**

```js
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);
}
```
**Python**

```python
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

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

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

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

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