BuoyForms
Docs

Webhook Signing

Webhook Signing

BuoyForms signs every outbound webhook with HMAC-SHA256 so your endpoint can verify the request came from BuoyForms and was not replayed later.

Use these headers:

Header Purpose
X-BuoyForms-Signature Versioned signature in the format v1=<hex>
X-BuoyForms-Timestamp Unix timestamp included in the signed payload
Idempotency-Key Stable key for deduplicating retries

The signed payload is:

<timestamp>.<raw request body>

Always verify the raw request body before parsing JSON. Reject requests when the timestamp is missing, the signature is malformed, or the timestamp is more than 5 minutes old.

Submission Values

Submission webhook values keep machine-facing field shapes. A native Name field is sent as either the Full or Split canonical object; it is not flattened or parsed into guessed parts. A Name field configured as sensitive passes through the same safe-value boundary as other sensitive fields and is sent as the string "Redacted".

{
  "event": "form.submission.created",
  "data": {
    "id": "sub_123",
    "formId": "form_123",
    "values": {
      "fld_full_name01": {
        "format": "full",
        "fullName": "Ada Lovelace"
      },
      "fld_split_name01": {
        "format": "split",
        "firstName": "Grace",
        "lastName": "Hopper"
      },
      "fld_sensitive_name01": "Redacted"
    }
  }
}

Node.js

import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/webhooks/buoyforms', express.raw({ type: 'application/json' }), (req, res) => {
  const secret = process.env.BUOYFORMS_WEBHOOK_SECRET;
  const signature = req.header('X-BuoyForms-Signature') ?? '';
  const timestamp = req.header('X-BuoyForms-Timestamp') ?? '';
  const rawBody = req.body;

  if (!verifyBuoyFormsSignature(rawBody, signature, timestamp, secret)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(rawBody.toString('utf8'));
  // Process event here.

  return res.sendStatus(204);
});

function verifyBuoyFormsSignature(rawBody, signature, timestamp, secret) {
  const received = signature.startsWith('v1=') ? signature.slice(3) : '';
  const now = Math.floor(Date.now() / 1000);

  if (!secret || !timestamp || !/^[a-f0-9]{64}$/i.test(received)) return false;
  if (Math.abs(now - Number(timestamp)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(received, 'hex'),
    Buffer.from(expected, 'hex'),
  );
}

Python

import hashlib
import hmac
import time
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/webhooks/buoyforms")
def buoyforms_webhook():
    secret = b"replace-with-your-webhook-secret"
    signature = request.headers.get("X-BuoyForms-Signature", "")
    timestamp = request.headers.get("X-BuoyForms-Timestamp", "")
    raw_body = request.get_data()

    if not verify_buoyforms_signature(raw_body, signature, timestamp, secret):
        abort(401)

    event = request.get_json()
    # Process event here.
    return ("", 204)

def verify_buoyforms_signature(raw_body, signature, timestamp, secret):
    received = signature[3:] if signature.startswith("v1=") else ""

    if not timestamp or len(received) != 64:
        return False
    if abs(int(time.time()) - int(timestamp)) > 300:
        return False

    payload = timestamp.encode("utf-8") + b"." + raw_body
    expected = hmac.new(secret, payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(received, expected)

Ruby

require "openssl"
require "json"
require "rack/utils"

post "/webhooks/buoyforms" do
  secret = ENV.fetch("BUOYFORMS_WEBHOOK_SECRET")
  signature = request.get_header("HTTP_X_BUOYFORMS_SIGNATURE").to_s
  timestamp = request.get_header("HTTP_X_BUOYFORMS_TIMESTAMP").to_s
  raw_body = request.body.read

  halt 401 unless verify_buoyforms_signature(raw_body, signature, timestamp, secret)

  event = JSON.parse(raw_body)
  # Process event here.

  status 204
end

def verify_buoyforms_signature(raw_body, signature, timestamp, secret)
  received = signature.start_with?("v1=") ? signature[3..] : ""
  return false unless timestamp.match?(/\A\d+\z/)
  return false unless received.match?(/\A[0-9a-f]{64}\z/i)
  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
  Rack::Utils.secure_compare(received, expected)
end

Go

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"
)

func buoyformsWebhook(secret []byte) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		rawBody, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "invalid body", http.StatusBadRequest)
			return
		}

		signature := r.Header.Get("X-BuoyForms-Signature")
		timestamp := r.Header.Get("X-BuoyForms-Timestamp")

		if !verifyBuoyFormsSignature(rawBody, signature, timestamp, secret) {
			http.Error(w, "invalid signature", http.StatusUnauthorized)
			return
		}

		var event map[string]any
		if err := json.Unmarshal(rawBody, &event); err != nil {
			http.Error(w, "invalid json", http.StatusBadRequest)
			return
		}

		// Process event here.
		w.WriteHeader(http.StatusNoContent)
	}
}

func verifyBuoyFormsSignature(rawBody []byte, signature, timestamp string, secret []byte) bool {
	if !strings.HasPrefix(signature, "v1=") {
		return false
	}

	received, err := hex.DecodeString(strings.TrimPrefix(signature, "v1="))
	if err != nil || len(received) != sha256.Size {
		return false
	}

	signedAt, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || abs(time.Now().Unix()-signedAt) > 300 {
		return false
	}

	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(timestamp))
	mac.Write([]byte("."))
	mac.Write(rawBody)

	return hmac.Equal(received, mac.Sum(nil))
}

func abs(n int64) int64 {
	if n < 0 {
		return -n
	}
	return n
}

Replay and Retry Handling

Signature verification proves the payload is authentic. It does not replace idempotency.

Store the Idempotency-Key header before doing non-reversible work, then ignore future deliveries with the same key. BuoyForms uses the same key when retrying the same event.