Gareth Wilson Gareth Wilson

Guide to Basin Webhooks: Features and Best Practices

Published


Basin webhooks send each form submission on to another service as soon as it arrives. If you use Basin as the backend for your website's forms, a webhook is how a contact form, signup or lead capture reaches your CRM, database or automation tool without anyone exporting submissions by hand.

This guide covers how Basin webhooks are configured and signed, the payload formats you can choose between, and the practices that keep a Basin receiver reliable.

What are Basin webhooks?

A Basin webhook belongs to a form. When that form receives a submission, Basin queues a POST to the webhook's URL with the submitted data. There are no event types: every delivery is a submission. By default, only submissions that are not flagged as spam trigger the webhook, and a checkbox lets you send all of them.

On the Growth plan and above, Basin signs each delivery with an X-Basin-Signature header: sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with a signing secret Basin generates for that webhook. On lower plans, the header is not sent.

Basin webhook features

FeatureDetails
ConfigurationForm dashboard, Integrations > Webhooks
TriggerEach submission, non-spam only unless you tick the box for all submissions
Payload formatsJSON, data-wrapped JSON, form URL encoded, Customer.IO, Microsoft Teams, MailerLite, Pardot Form Handler, or a custom template
Signature headerX-Basin-Signature (Growth plan and above)
SchemeHMAC-SHA256, lowercase hex, prefixed sha256=
Signed contentThe raw request body, in whichever format the webhook sends
SecretGenerated per webhook, shown under Webhook Signing, and regenerable
Extra authenticationCustom headers per webhook
RetriesUp to 15 times over 24 to 28 hours, with exponential backoff
Manual re-fireFrom the submission log, in bulk from the submissions inbox, or through the API

Basin doesn't state a response timeout or publish source IPs.

Payload formats

Each webhook has a payload format, and what arrives depends on it. With the JSON format, the body is the submitted fields themselves, plus Basin's submission id and any attachments:

{
  "email": "example@example.com",
  "name": "Example Name",
  "id": "submission_id",
  "attachments": [
    {
      "attachment_proxy_url": "proxy_url",
      "public_url": "public_url"
    }
  ]
}

Data-wrapped JSON puts the same object under data. For form URL encoded and Pardot Form Handler, Basin says the form data "is sent as is without any additional processing", in the form email=example@example.com&name=Example+Name&id=submission_id. The Customer.IO, Microsoft Teams and MailerLite formats reshape the data for those services.

The custom format is a template with merge tags such as {{form_name}} and a loop over form_params, sent with a content type you choose: application/json, application/xml, application/x-www-form-urlencoded, multipart/form-data, text/plain or application/graphql. If you build JSON in a template, use Basin's JSONstringify helper on field values so a quote or line break in a submission doesn't produce invalid JSON.

Because the body is the form's own fields, its keys are whatever your form's inputs are called.

Setting up Basin webhooks

  1. In the Basin dashboard, open your form and go to Integrations > Webhooks.
  2. Click Create a new webhook, enter your endpoint URL, and choose a payload format. JSON is the easiest to work with.
  3. Add any authentication headers you want sent with each request, and save.
  4. To get the signing secret, click Edit on the webhook, scroll to Webhook Signing, and click Show. Store it server-side, for example as BASIN_WEBHOOK_SECRET.
  5. Submit the form to test, and check the submission's Log view for the webhook attempt.

If you receive Basin webhooks through Hookdeck, create a source with the Basin type and paste the webhook signing secret. Signing needs the Growth plan or above. On lower plans, deliveries have no X-Basin-Signature header and fail verification, so leave verification off on the Hookdeck source and rely on a custom header your own service checks instead.

Securing Basin webhooks

Read X-Basin-Signature, check it starts with sha256=, compute the hex HMAC-SHA256 of the raw request body with your signing secret as the key, and compare in constant time. Verify before you parse anything, because the body may be JSON or form-encoded depending on the payload format.

const crypto = require('crypto');
const express = require('express');

function verifyBasinSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret || !signatureHeader.startsWith('sha256=')) return false;
  const received = Buffer.from(signatureHeader.slice('sha256='.length).trim().toLowerCase(), 'utf8');
  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(rawBody).digest('hex'),
    'utf8'
  );
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

// '*/*' keeps the raw bytes whatever payload format the webhook uses.
app.post('/webhooks/basin', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.BASIN_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyBasinSignature(req.body, req.get('x-basin-signature'), secret)) {
    return res.sendStatus(401);
  }

  const submission = JSON.parse(req.body.toString('utf8')); // for the JSON payload format
  // Deduplicate on submission.id, then process asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac


def verify_basin_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret or not signature_header.startswith("sha256="):
        return False
    received = signature_header[len("sha256="):].strip().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)


# In FastAPI: raw_body = await request.body()
# verify_basin_signature(raw_body, request.headers.get("x-basin-signature"),
#                        os.environ["BASIN_WEBHOOK_SECRET"])

Basin's own samples compare the whole sha256=... string, and its Node sample calls crypto.timingSafeEqual without checking lengths first, which throws on a header of the wrong length. Checking the prefix and lengths, as above, turns that into a clean rejection.

The example header on Basin's page, sha256=5d41402abc4b2a76b9719d911017c592, has only 32 hex characters. A real SHA-256 digest has 64, so don't use that value as a test case.

There is no timestamp, so nothing in the request tells you how old it is.

Make Basin webhooks production-ready. Hookdeck Event Gateway verifies the X-Basin-Signature header, stores every submission, and delivers it to your service with retries you control.

Basin webhook limitations and pain points

Signing depends on your plan

The Problem: Verification fails on every request, and the X-Basin-Signature header isn't there.

Why It Happens: Signed webhooks are a Growth plan feature. On lower plans, Basin sends the same deliveries without the header, and there is no switch on the webhook to turn signing on.

Workarounds:

  • Upgrade to Growth if you need signed webhooks.
  • Otherwise, add a custom header with a long random value to the webhook and check it in your receiver.

How Hookdeck Can Help: Hookdeck's Basin source verifies X-Basin-Signature when you paste a secret. Hookdeck answers Basin with a 200 either way, then records a request that fails the check as failed verification, so it never reaches your destination. On a lower plan, leave verification off.

Regenerating the secret takes effect at once

The Problem: Deliveries start failing the moment someone regenerates the secret.

Why It Happens: Basin "will immediately start signing all future requests with the new secret". There is no overlap period with the old one.

Workarounds:

  • Basin's advice is to update your endpoint before you regenerate. In practice, accept both the old and new secret briefly, then regenerate, then remove the old one.
  • If deliveries fail during the switch, re-fire them from the submission log once the new secret is in place.

How Hookdeck Can Help: With Hookdeck in front, the secret lives on the source. Update it there once, and your services behind Hookdeck don't need to change.

The body has no fixed shape

The Problem: A handler breaks when someone renames a form field or changes the payload format.

Why It Happens: The JSON body is the form's own inputs, so its keys change when the form changes. Switching the payload format changes the content type and structure too, and custom templates can produce anything.

Workarounds:

  • Keep one payload format per endpoint and record which one it expects.
  • Validate the fields you rely on and log the raw body when one is missing.

How Hookdeck Can Help: Hookdeck stores every request with its headers and body, so you can see exactly what a form sent, and a transformation can map field names to a stable shape before delivery.

A day of retries, then nothing

The Problem: Submissions from a long outage never arrive.

Why It Happens: Basin retries up to 15 times over 24 to 28 hours and then stops.

Workarounds:

  • Return 200 quickly and process asynchronously.
  • After an outage, select the affected submissions in the inbox and use Re-fire Webhooks, or call the re-fire API.

How Hookdeck Can Help: Hookdeck accepts each submission as it arrives, so Basin's retries aren't used up while your service is down, and you can replay anything your service failed to process.

Best practices

Verify against the raw body

Basin warns that parsing and re-serialising the body produces a different signature. In Express, use express.raw({ type: '*/*' }). In Next.js, use await request.text(). In FastAPI, use await request.body().

Deduplicate on the submission id

Retries and manual re-fires send the same submission again, and a re-fire creates a new log entry rather than replacing the old one. Store each submission id and skip any you have processed. See our guide to webhook idempotency.

Decide how to handle spam

By default, spam-flagged submissions don't fire webhooks. If you tick the box to send all submissions, you take on spam filtering yourself, so plan for it before you enable it.

Acknowledge fast and process asynchronously

Verify, store the submission, return 200, and do the rest afterwards. See why to process webhooks asynchronously.

Conclusion

On the Growth plan and above, Basin signs each submission webhook with X-Basin-Signature: sha256=<hex>, an HMAC-SHA256 of the raw body keyed with the webhook's signing secret. Verify the raw bytes before parsing, expect the body to follow your form's fields and the payload format you chose, and deduplicate on the submission id.

Hookdeck Event Gateway sits in front of your receiver, verifies every Basin signature, and holds each submission until your service has processed it.

Get started with Hookdeck for free and handle Basin webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.