# OpnForm Webhooks

## When to Use This Skill

* How do I receive OpnForm webhooks?
* How do I verify the OpnForm `X-Webhook-Signature` header (`sha256=<hex>`)?
* How do I handle an OpnForm form submission in my own service?
* Why is my OpnForm webhook signature verification failing, or why is no signature header sent?

## Essential Code (USE THIS)

> **Setup note:** Set a webhook secret on the OpnForm webhook integration. Without one, deliveries are unsigned and fail verification.

When a webhook secret is set (at least 12 characters), OpnForm adds one header to every delivery:

```
X-Webhook-Signature: sha256=3d35d777510a7ccf73ded723f141b0ada2986aebe63c4e45a9c7e4bb1abaca06

```

* The value is `sha256=` plus the lowercase hex HMAC-SHA256 of the **raw request body** only, keyed with the webhook secret as UTF-8 bytes (not hex- or base64-decoded). No timestamp, id or other header is signed.
* Strip a leading `sha256=`, then require exactly 64 hex characters before decoding (`Buffer.from(x, 'hex')` silently drops bad characters). Accept the bare digest too, as Hookdeck's source does. Hex is case-insensitive.
* Compare in constant time. A missing or empty header, malformed value or mismatch gets `401`.
* There is no timestamp, so there is no replay window to check. Use `submission_id` for idempotency.

### Verification (Node.js)

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

// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyOpnform(rawBody, signatureHeader, secret) {
  if (!secret || !signatureHeader) return false;
  let sig = signatureHeader.trim();
  if (sig.startsWith('sha256=')) sig = sig.slice('sha256='.length);
  if (!/^[0-9a-fA-F]{64}$/.test(sig)) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
  return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), expected);
}

```

### Verification (Python)

```python
import hashlib, hmac, re

def verify_opnform(raw_body: bytes, signature_header, secret: str) -> bool:
    if not secret or not signature_header:
        return False
    sig = signature_header.strip()
    if sig.startswith("sha256="):
        sig = sig[len("sha256="):]
    if not re.fullmatch(r"[0-9a-fA-F]{64}", sig):
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig.lower().encode(), expected.encode())

```

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/opnform-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/opnform-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/opnform-webhooks/examples/fastapi/).

## Event Types

OpnForm has no event name field or header: every delivery is a `POST` of one form submission, so there is nothing to dispatch on. The JSON body has `form_id`, `form_title`, `form_slug`, `submission_id`, `edit_link`, `submission` (field name to value), `data` (field id to `{ value, name, type }`) and `meta` (for example `attribution` UTM values). Treat every field as untrusted and check its type before reading into it. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/opnform-webhooks/references/overview.md).

## Important Headers

| Header | Description |
| --- | --- |
| `X-Webhook-Signature` | `sha256=<hex>` HMAC-SHA256 of the raw body; only sent when a webhook secret is set |
| Custom headers | Up to 10 headers you configure on the integration |

## Environment Variables

```bash
OPNFORM_WEBHOOK_SECRET=your_webhook_secret   # the webhook secret set on the integration (12+ characters)

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 opnform --path /webhooks/opnform

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/opnform-webhooks/references/overview.md) - OpnForm webhook integration, payload structure
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/opnform-webhooks/references/setup.md) - Adding the webhook integration and setting the secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/opnform-webhooks/references/verification.md) - Signature verification details and gotchas