# Guide to OpnForm Webhooks: Features and Best Practices

OpnForm is an open-source form builder, used on its hosted service or self-hosted, for surveys, sign-up forms, contact forms and the like. Its webhook integration sends each new submission to a URL you choose, which is how teams feed responses into a CRM, a ticketing tool or their own database without exporting spreadsheets.

This guide covers adding the webhook integration, the submission payload with its two views of the same answers, enabling and verifying `X-Webhook-Signature`, and what OpnForm does when a delivery fails.

## What are OpnForm webhooks?

Webhooks in OpnForm are an integration on an individual form, added in the form's **Integrations** settings or through the API. Each integration has an HTTPS URL, an optional signing secret, up to 10 custom headers and, optionally, conditional logic so it only fires for certain answers.

Every submission produces one `POST`. There are no event types to choose from. OpnForm logs each delivery attempt against the integration, and the latest eligible failed attempt can be retried through the API.

## OpnForm webhook features

| Feature | Details |
| --- | --- |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-webhook-signature` |
| Body | JSON |

From OpnForm's [webhook integration reference](https://docs.opnform.com/api-reference/integrations/create-webhook-integration):

* `webhook_url` must be HTTPS and resolve only to public IP addresses. Private, loopback, link-local and cloud metadata addresses are refused.
* Signing happens only when `webhook_secret` is set. It must be at least 12 characters.
* Custom headers can't override `Authorization`, `X-Webhook-Signature`, `Content-Type`, `Host`, `Cookie` and some others.
* Automatic retries, a timeout and expected status codes aren't documented.

## Events and payloads

| Field | Description |
| --- | --- |
| `form_id`, `form_title`, `form_slug` | The form that received the submission |
| `submission_id` | Submission id; use it for idempotency |
| `edit_link` | Link to edit the submission |
| `submission` | Field name to submitted value |
| `data` | Field id to `{ value, name, type }` |
| `meta` | Extra context such as `attribution` UTM values |

`submission` and `data` describe the same answers in two ways: `submission` is keyed by the field's label, which is easy to read but changes if someone renames a question, while `data` is keyed by field ID and includes each field's `type`. Use `data` for anything that must survive edits to the form.

This is the `submission` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/opnform/latest/submission.json):

```json
{
  "form_id": 123,
  "form_title": "Contact Form",
  "form_slug": "contact-form",
  "submission_id": 456,
  "edit_link": "https://opnform.com/forms/contact-form?submission_id=example",
  "submission": {
    "Name": "Jane Doe"
  },
  "data": {
    "field-id": {
      "value": "Jane Doe",
      "name": "Name",
      "type": "text"
    }
  },
  "meta": {
    "attribution": {
      "utm_source": "newsletter",
      "utm_campaign": "summer-launch"
    }
  }
}

```

`meta.attribution` carries UTM values captured with the submission, which is useful for lead attribution. Every value in `submission` and `data` was typed by the person filling in the form, so validate types and lengths before use.

## Setting up OpnForm webhooks

1. Open the form, go to **Integrations** and add a **Webhook** integration.
2. Enter your HTTPS endpoint, for example `https://example.com/webhooks/opnform`.
3. Enter a webhook secret. OpnForm doesn't generate one, so create a random string of at least 12 characters and store it as `OPNFORM_WEBHOOK_SECRET`.
4. Optionally add custom headers or conditional logic, then save.

Set a webhook secret on the OpnForm webhook integration. Without one, deliveries are unsigned and fail verification.

The API equivalent:

```bash
curl -X POST 'https://api.opnform.com/open/forms/123/integrations' \
  -H 'Authorization: Bearer YOUR_PAT' \
  -H 'Content-Type: application/json' \
  -d '{
    "integration_id": "webhook",
    "status": "active",
    "data": {
      "webhook_url": "https://example.com/webhooks/opnform",
      "webhook_secret": "a-random-secret-of-12-plus-characters"
    }
  }'

```

To use Hookdeck, create an OpnForm source with the same secret and use the source URL as `webhook_url`. If an integration has no secret, leave verification off on the source, since OpnForm sends no signature. Because private addresses are rejected, local testing needs a public tunnel:

```bash
npx hookdeck-cli listen 3000 opnform --path /webhooks/opnform

```

## Securing OpnForm webhooks

`X-Webhook-Signature` is `sha256=` followed by a hex HMAC-SHA256 of the raw body, keyed with your secret. Strip the prefix, compute the HMAC over the received bytes and compare in constant time. OpnForm's [signature guide](https://docs.opnform.com/api-reference/integrations/webhook-security) passes two strings to `crypto.timingSafeEqual`, which throws in Node.js because it needs buffers. The function below compares decoded bytes instead.

### Node.js verification

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-webhook-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature.startsWith('sha256=') ? signature.slice(7) : signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler stores the submission by `submission_id`, using the field IDs from `data`:

```javascript
const express = require('express');

const app = express();

app.post('/webhooks/opnform', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.OPNFORM_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  let payload;
  try {
    payload = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return res.sendStatus(400);
  try {
    const answers = Object.entries(payload.data ?? {}).map(([fieldId, field]) => ({
      fieldId,
      label: field?.name,
      type: field?.type,
      value: field?.value,
    }));

    await saveFormResponse(payload.form_id, payload.submission_id, answers, payload.meta ?? {});
  } catch {
    return res.sendStatus(503);
  }

  res.sendStatus(200);
});

```

`saveFormResponse` ignores a `submission_id` it already has for that form, which covers a manual retry of the same delivery.

No timestamp is signed. Hookdeck's OpnForm source checks the same `sha256=` HMAC, responds `200` and records a mismatch as failed verification.

> **Make OpnForm webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies each OpnForm submission, stores it, and retries delivery to your CRM automatically instead of waiting for someone to retry a failed send by hand.

## OpnForm webhook limitations and pain points

### Retries are manual

**The Problem:** A submission sent while your endpoint was down may never arrive unless someone notices.

**Why It Happens:** OpnForm documents logging every attempt and retrying the latest eligible failed attempt through the API. It doesn't describe automatic retries.

**Workarounds:**

* Check the integration's event log (List Integration Events) after any outage.
* Script the retry endpoint, and compare with the form's submissions in OpnForm to find gaps.

**How Hookdeck Can Help:** Hookdeck accepts the submission and retries your service automatically, with alerts and bulk replay.

### Signing is optional

**The Problem:** An integration created without a secret sends unsigned requests that anyone could imitate.

**Why It Happens:** `webhook_secret` is optional, and OpnForm doesn't generate one for you.

**Workarounds:**

* Always set a random secret of at least 12 characters.
* Reject requests without `X-Webhook-Signature`.

**How Hookdeck Can Help:** With verification on, a Hookdeck source records unsigned requests as failed verification, so a missing secret shows up immediately.

### Labels change

**The Problem:** Renaming a question breaks code that reads `submission` by label.

**Why It Happens:** `submission` is keyed by field name, which form editors can change at any time.

**Workarounds:**

* Map answers by field ID from `data`.
* Keep a mapping of field IDs to your own column names.

**How Hookdeck Can Help:** A Hookdeck transformation can reshape `data` into the structure each destination expects, in one place.

### Only public endpoints

**The Problem:** You can't point a webhook at an internal service, even from a self-hosted OpnForm.

**Why It Happens:** OpnForm rejects URLs that resolve to private, loopback, link-local or metadata addresses.

**Workarounds:**

* Expose a public HTTPS receiver that forwards internally.
* Use a tunnel for development.

**How Hookdeck Can Help:** Hookdeck provides the public URL and delivers onwards to your service, or to `localhost` through the CLI.

## Best practices

### Deduplicate on `submission_id`

Store `form_id` and `submission_id` with a unique constraint, following the [webhook idempotency guide](/webhooks/guides/implement-webhook-idempotency).

### Respond fast, process later

With no documented timeout or retries, a quick `200` is the safest response. The [asynchronous processing guide](/webhooks/guides/why-implement-asynchronous-processing-webhooks) explains the pattern.

### Use conditional logic

Fire the webhook only for submissions that need it, such as qualified leads, rather than filtering everything downstream.

### Use custom headers for routing, not authentication

A custom header can carry an API key for a gateway, but the signature is what proves OpnForm sent the request.

## Conclusion

OpnForm webhooks send one signed `POST` per form submission, once you set a secret on the integration. Verify `X-Webhook-Signature` against the raw body, store answers by field ID and `submission_id`, and plan for failed deliveries, since OpnForm documents only manual retries.

[Hookdeck Event Gateway](https://hookdeck.com) adds the automatic retries and storage that OpnForm leaves to you.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle OpnForm webhooks reliably in minutes.