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-Signatureheader (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_idfor 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
| 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
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
- references/overview.md - OpnForm webhook integration, payload structure
- references/setup.md - Adding the webhook integration and setting the secret
- references/verification.md - Signature verification details and gotchas