Agent skill
Basin Webhooks Skill
Receive and verify Basin form backend webhooks. Use when setting up Basin webhook handlers, debugging X-Basin-Signature verification, or handling form submissions delivered as JSON or form-urlencoded webhook payloads.
Install this skill
npx skills add hookdeck/webhook-skills --skill basin-webhooks
When to Use This Skill
- How do I receive Basin webhooks?
- How do I verify the
X-Basin-Signatureheader on a Basin webhook? - How do I handle a Basin form submission webhook (JSON or form-urlencoded)?
- Why is my Basin webhook signature verification failing?
- Why are my Basin webhooks arriving without a signature?
Verification (core)
Basin sends the header X-Basin-Signature: sha256=<hex>, where <hex> is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the webhook signing secret. There is no timestamp and it is not the Standard Webhooks spec. Require and strip the sha256= prefix, then compare in constant time. Verify over the raw bytes before parsing: the body is JSON or form-urlencoded depending on the webhook's payload type.
Node (check the hex shape first: Buffer.from(x, 'hex') silently truncates invalid hex):
const crypto = require('crypto');
function verifyBasinSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false;
const received = signatureHeader.slice('sha256='.length);
if (!/^[0-9a-f]{64}$/i.test(received)) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
return crypto.timingSafeEqual(Buffer.from(received, 'hex'), expected);
}
Python (compare bytes so a non-ASCII header yields 401/400, not a 500):
import hashlib, hmac
def verify_basin_signature(raw_body: bytes, signature_header: str | None, secret: str) -> bool:
if not signature_header or not signature_header.startswith("sha256="):
return False
received = signature_header[len("sha256="):].lower().encode("utf-8")
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest().encode("utf-8")
return hmac.compare_digest(received, expected)
Plan requirement: signed webhooks are only available on Basin's Growth plan and above. On lower plans deliveries carry no
X-Basin-Signatureheader, so verification fails. Express handlers must read the body withexpress.raw({ type: '*/*' })so any content type verifies.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Events
Basin has no event types. Each webhook fires once per form submission (non-spam by default; there is a checkbox to also fire for spam). Failed deliveries are retried up to 15 times over 24-28 hours with exponential backoff, and can be re-fired from the submission log or API.
Payload Types
The format is chosen per webhook and mirrors the original submission:
| Type | Body |
|---|---|
| JSON | {"email": "...", "name": "...", "id": "submission_ |
| Data wrapped JSON | {"data": {"email": "...", "id": "..."}} |
| Form URL encoded | email=...&name=...&id=... |
Use the submission id as your idempotency key.
Environment Variables
BASIN_WEBHOOK_SECRET=your_signing_secret # Form dashboard > Integrations > Webhooks > Edit > Webhook Signing > Show
Setup with Hookdeck
Basin signs webhooks only on the Growth plan and above: paste the webhook signing secret into the Hookdeck source's webhook verification settings. On lower plans, deliveries are unsigned and fail verification.
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 basin --path /webhooks/basin
Reference Materials
- references/overview.md - Basin webhook concepts and payload types
- references/setup.md - Configure a webhook and find the signing secret
- references/verification.md - Signature verification details and gotchas