# Webhooks

Receive a signed submission.created event on your own HTTPS endpoint for every new submission, and verify it with any Standard Webhooks library.

Webhooks are available on Pro. Nisuform sends a `POST` request with a JSON body to your URL for every new submission. Each delivery is signed following the [Standard Webhooks](https://www.standardwebhooks.com) spec, so you can prove it came from Nisuform.

## Connect a webhook

1. Build an endpoint that accepts a `POST` with a JSON body and answers with any `2xx` status.
2. Open the form's **Integrations** tab and choose **Webhook**.
3. Paste your endpoint's URL. It must start with `https://`.
4. Copy the **Signing secret** that appears. It starts with `whsec_`. Store it on your server, for example as `NISUFORM_WEBHOOK_SECRET`.
5. Choose **Send test** to check that your endpoint receives and verifies it.

You can find the secret again under **Signing secret and payload** on the webhook. Changing the URL later keeps the same secret.

## The request

```http
POST /webhooks/nisuform HTTP/1.1
Content-Type: application/json
User-Agent: nisuform-webhooks/1
webhook-id: msg_3f2c8a61-5d4e-4b7a-9c1f-2a6e8d0b4c17
webhook-timestamp: 1790244001
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

```json
{
  "type": "submission.created",
  "timestamp": "2026-09-24T10:00:01.000Z",
  "data": {
    "formId": "8d7e6f5a-4b3c-4d2e-9f1a-0b9c8d7e6f5a",
    "submissionId": "3f2c8a61-5d4e-4b7a-9c1f-2a6e8d0b4c17",
    "submittedAt": "2026-09-24T10:00:00.000Z",
    "test": false,
    "fields": {
      "name": "Jane Doe",
      "email": "jane@example.com",
      "message": "Hello!"
    }
  }
}
```

| Field               | Meaning                                                                                        |
| ------------------- | ---------------------------------------------------------------------------------------------- |
| `type`              | Always `submission.created`.                                                                   |
| `timestamp`         | When this delivery was sent.                                                                   |
| `data.formId`       | The form's ID.                                                                                 |
| `data.submissionId` | The submission's ID, the same one the submit endpoint returned.                                |
| `data.submittedAt`  | When the submission was received.                                                              |
| `data.test`         | `true` for test submissions sent from the dashboard.                                           |
| `data.fields`       | The answers by field name. Every value is a string. Special fields and files are not included. |

The dashboard shows a sample payload with your form's own fields under **Signing secret and payload**.

## Verify the signature

Always verify deliveries before you trust them. Use a [Standard Webhooks library](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries) for your language with the `whsec_` secret. It checks the signature and rejects deliveries older than 5 minutes, which stops replays.

```js
import { Webhook } from 'standardwebhooks'

const webhook = new Webhook(process.env.NISUFORM_WEBHOOK_SECRET)

export async function POST(request) {
  const payload = await request.text()
  let event
  try {
    event = webhook.verify(payload, Object.fromEntries(request.headers))
  } catch {
    return new Response('Invalid signature', { status: 400 })
  }
  console.log(event.data.fields)
  return new Response(null, { status: 204 })
}
```

```python
import os
from standardwebhooks.webhooks import Webhook

webhook = Webhook(os.environ["NISUFORM_WEBHOOK_SECRET"])

def handle(body: bytes, headers: dict) -> int:
    try:
        event = webhook.verify(body, headers)
    except Exception:
        return 400
    print(event["data"]["fields"])
    return 204
```

Verify the raw body exactly as it arrived. Parsing the JSON and serializing it again changes the bytes and breaks the signature.

### Verify without a library

The signature is an HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`, keyed with the secret after removing the `whsec_` prefix and base64-decoding it. The header holds `v1,` followed by the base64 digest.

```js
import { createHmac, timingSafeEqual } from 'node:crypto'

function verify(secret, headers, body) {
  const id = headers.get('webhook-id')
  const timestamp = headers.get('webhook-timestamp')
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return false
  }
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest('base64')
  return headers
    .get('webhook-signature')
    .split(' ')
    .some((part) => {
      const [version, signature] = part.split(',')
      return version === 'v1' && signature.length === expected.length &&
        timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    })
}
```

## Respond quickly

* Answer with any `2xx` status within 10 seconds. The body is ignored.
* Do slow work, like sending emails or calling other APIs, after you respond or in a background job.
* Redirects are not followed. A `3xx` response counts as a failure, so use the final URL.

## Retries and duplicates

Timeouts, network errors, `408`, `429` and `5xx` responses are retried with growing delays for about two hours. Other responses, like `400`, `401` or `404`, are not retried and mark the webhook as failing on the Integrations tab. See [Delivery status and retries](/docs/integrations#delivery-status-and-retries).

Every attempt for the same submission carries the same `webhook-id`, so your endpoint may receive a submission more than once. Store the ID, or the `submissionId`, and skip events you have already handled.

## Rotate the secret

If the secret leaks, choose **Rotate secret** under **Signing secret and payload**. Deliveries are signed with the new secret right away, and your server rejects them until you update it there. Disconnecting the webhook deletes its secret, so reconnecting always issues a new one.
