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-Signature header 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-Signature header, so verification fails. Express handlers must read the body with express.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:

TypeBody
JSON{"email": "...", "name": "...", "id": "submission_id", "attachments": [...]}
Data wrapped JSON{"data": {"email": "...", "id": "..."}}
Form URL encodedemail=...&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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 6, 2026

View on GitHub →