Agent skill

OpnForm Webhooks Skill

Receive and verify OpnForm webhooks. Use when setting up OpnForm webhook integration handlers, debugging X-Webhook-Signature (sha256=<hex>) HMAC verification, or handling OpnForm form submission payloads.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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)

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)

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/, examples/nextjs/, 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.

Important Headers

HeaderDescription
X-Webhook-Signaturesha256=<hex> HMAC-SHA256 of the raw body; only sent when a webhook secret is set
Custom headersUp to 10 headers you configure on the integration

Environment Variables

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

Local Development

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

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 12, 2026

View on GitHub →