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)
endGo
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.