---
title: Webhooks reference
description: Headers, payload fields and signature verification for Finerlise webhooks.
icon: webhook
search:
  keywords:
    [webhook signature, hmac, sha256, verify, X-Finerlise-Signature, payload]
---

Set up a webhook from a form's **Integrations** page. See [Webhooks](/integrations/webhooks).

## Request

|              |                                                 |
| ------------ | ----------------------------------------------- |
| Method       | `POST`                                          |
| Content type | `application/json`                              |
| Timeout      | 10 seconds. Reply with any `2xx` status.        |
| Retries      | None. Use the API to fetch anything you missed. |

## Headers

| Header | Value |
| --- | --- |
| `X-Finerlise-Event` | `response.completed` |
| `X-Finerlise-Delivery` | A unique ID for this delivery. Use it to ignore duplicates. |
| `X-Finerlise-Signature` | `sha256=<hex>`, only when you set a signing secret. |

## Payload

- **`event`** · `string`

  Always `response.completed`.

- **`createdAt`** · `string`

  When the event was sent (ISO 8601).

- **`data.formId`** · `string`

  The form's ID.

- **`data.formTitle`** · `string`

  The form's title.

- **`data.responseId`** · `string`

  The response's ID. Use it with `GET /api/v1/responses/{responseId}`.

- **`data.respondentEmail`** · `string \| null`

  The respondent's email, when the form identifies respondents.

- **`data.completedAt`** · `string`

  When the response was submitted (ISO 8601).

- **`data.answers`** · `array`

  One item per answer: `fieldId`, `label`, `type`, `value` (formatted text) and
  `rawValue`.

- **`data.fields`** · `object`

  Answers keyed by question title, for quick access.

- **`data.hiddenFields`** · `object`

  [Hidden field](/logic/hidden-fields) values from the form link.

- **`data.metadata`** · `object`

  Extra response metadata.

:::tip Use `answers[].fieldId` rather than `fields` if your question titles might change. :::

## Verify the signature

The signature is an HMAC-SHA256 of the **raw request body** using your signing secret. Compute it yourself and compare in constant time.

```js Node.js
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post(
  "/finerlise",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const expected =
      "sha256=" +
      crypto
        .createHmac("sha256", process.env.FINERLISE_WEBHOOK_SECRET)
        .update(req.body)
        .digest("hex");
    const received = req.get("X-Finerlise-Signature") ?? "";

    const valid =
      expected.length === received.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
    if (!valid) return res.sendStatus(401);

    const event = JSON.parse(req.body.toString("utf8"));
    console.log("New response", event.data.responseId);
    res.sendStatus(200);
  }
);
```

```python Python
import hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/finerlise")
def finerlise():
    secret = os.environ["FINERLISE_WEBHOOK_SECRET"].encode()
    expected = "sha256=" + hmac.new(secret, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Finerlise-Signature", "")):
        abort(401)
    event = request.get_json()
    print("New response", event["data"]["responseId"])
    return "", 200
```

:::warning Verify against the raw body bytes. Re-serializing parsed JSON can change the bytes and break the signature. :::
