NisuformDocs

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

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=
{
  "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!"
    }
  }
}
FieldMeaning
typeAlways submission.created.
timestampWhen this delivery was sent.
data.formIdThe form's ID.
data.submissionIdThe submission's ID, the same one the submit endpoint returned.
data.submittedAtWhen the submission was received.
data.testtrue for test submissions sent from the dashboard.
data.fieldsThe 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 for your language with the whsec_ secret. It checks the signature and rejects deliveries older than 5 minutes, which stops replays.

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 })
}

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.

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.

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.