Webhooks

Send form data to any endpoint using custom webhooks with HMAC verification and retry logic.

7 min read

Webhooks give you full control over where your form data goes and how it is processed. Instead of relying on pre-built integrations, you configure a URL endpoint on your own server, and BttrForm sends a structured JSON payload to that endpoint every time a form is submitted. This is the most flexible integration option β€” ideal for custom backends, proprietary systems, and advanced automation pipelines.

Configuring a Webhook

Step 1: Open the Integrations Tab

In the BttrForm dashboard, open the form you want to connect. Navigate to the "Integrations" tab and click "Webhooks" from the available integrations.

Step 2: Add a Webhook Endpoint

Click "Add Webhook" and provide the following:

  • URL β€” the HTTPS endpoint that will receive the POST request
  • Name β€” a label for your reference (e.g., "Production CRM Sync")
  • Secret β€” an HMAC signing secret used to verify that incoming requests genuinely came from BttrForm (auto-generated, but you can set your own)

HTTPS Required

Webhook endpoints must use HTTPS. BttrForm does not send data to plain HTTP URLs. This ensures your form data is encrypted in transit. If you are developing locally, use a tunneling tool like ngrok to expose a local server over HTTPS.

Step 3: Select Events

Choose which events trigger the webhook:

EventDescription
submission.createdFired when a new form submission is completed
submission.updatedFired when a submission is edited (if editing is enabled)
submission.deletedFired when a submission is deleted

By default, only submission.created is selected. Enable additional events as needed.

Step 4: Test the Webhook

Click "Send Test" to send a sample payload to your endpoint. BttrForm displays the response status code and body so you can verify your server is receiving and processing the data correctly.

Payload Format

Every webhook request is an HTTP POST with a JSON body. Here is the complete payload structure:

{
  "event": "submission.created",
  "timestamp": "2026-02-08T15:42:00.000Z",
  "form": {
    "id": "frm_a1b2c3d4",
    "name": "Customer Feedback Survey"
  },
  "submission": {
    "id": "sub_x9y8z7w6",
    "submitted_at": "2026-02-08T15:42:00.000Z",
    "fields": {
      "name": "Jane Smith",
      "email": "jane@example.com",
      "rating": 4,
      "department": "Engineering",
      "comments": "The onboarding process was smooth.",
      "recommend": true,
      "attachments": [
        {
          "filename": "screenshot.png",
          "url": "https://cdn.bttrlabs.com/uploads/abc123/screenshot.png",
          "size": 245760,
          "content_type": "image/png"
        }
      ]
    },
    "metadata": {
      "ip_country": "US",
      "browser": "Chrome 120",
      "platform": "Windows",
      "referrer": "https://example.com/feedback",
      "completion_time_seconds": 142
    }
  }
}

Headers

Each webhook request includes these HTTP headers:

HeaderDescription
Content-Typeapplication/json
X-BttrForm-EventThe event type (e.g., submission.created)
X-BttrForm-SignatureHMAC-SHA256 signature of the request body
X-BttrForm-TimestampUnix timestamp of when the request was sent
X-BttrForm-Delivery-IdUnique ID for this delivery attempt (useful for deduplication)
User-AgentBttrForm-Webhook/1.0

HMAC Signature Verification

Every webhook request is signed with your secret using HMAC-SHA256. You should verify this signature on your server to confirm the request genuinely came from BttrForm and was not tampered with.

The signature is computed over the concatenation of the timestamp and the raw request body:

signature = HMAC-SHA256(secret, timestamp + "." + body)

Verification in Node.js

import crypto from 'crypto';

function verifyWebhookSignature(req, secret) {
  const signature = req.headers['x-bttrform-signature'];
  const timestamp = req.headers['x-bttrform-timestamp'];
  const body = JSON.stringify(req.body);

  // Protect against replay attacks: reject requests older than 5 minutes
  const currentTime = Math.floor(Date.now() / 1000);
  if (Math.abs(currentTime - parseInt(timestamp, 10)) > 300) {
    throw new Error('Webhook timestamp is too old');
  }

  // Compute expected signature
  const payload = `${timestamp}.${body}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  // Constant-time comparison to prevent timing attacks
  const isValid = crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );

  if (!isValid) {
    throw new Error('Invalid webhook signature');
  }

  return true;
}

// Express.js example
app.post('/webhooks/bttrform', (req, res) => {
  try {
    verifyWebhookSignature(req, process.env.BTTRFORM_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(401).json({ error: err.message });
  }

  const { event, submission } = req.body;
  console.log(`Received ${event} for submission ${submission.id}`);

  // Process the submission...

  res.status(200).json({ received: true });
});

Verification in Python

import hmac
import hashlib
import time
import json
from flask import Flask, request, jsonify

app = Flask(__name__)

def verify_webhook_signature(request, secret):
    signature = request.headers.get('X-BttrForm-Signature')
    timestamp = request.headers.get('X-BttrForm-Timestamp')
    body = request.get_data(as_text=True)

    # Protect against replay attacks
    current_time = int(time.time())
    if abs(current_time - int(timestamp)) > 300:
        raise ValueError('Webhook timestamp is too old')

    # Compute expected signature
    payload = f"{timestamp}.{body}"
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Constant-time comparison
    if not hmac.compare_digest(signature, expected_signature):
        raise ValueError('Invalid webhook signature')

    return True

@app.route('/webhooks/bttrform', methods=['POST'])
def handle_webhook():
    try:
        verify_webhook_signature(request, BTTRFORM_WEBHOOK_SECRET)
    except ValueError as e:
        return jsonify({'error': str(e)}), 401

    data = request.get_json()
    event = data['event']
    submission = data['submission']
    print(f"Received {event} for submission {submission['id']}")

    # Process the submission...

    return jsonify({'received': True}), 200

Always Verify Signatures

Never skip HMAC verification in production. Without it, anyone who discovers your webhook URL can send fake submissions to your server. The verification code above includes replay attack protection and constant-time comparison to prevent timing attacks.

Retry Logic

BttrForm retries failed webhook deliveries using an exponential backoff schedule. A delivery is considered failed if your server returns a non-2xx status code or does not respond within 30 seconds.

AttemptDelayCumulative Time
1st retry1 minute1 minute
2nd retry5 minutes6 minutes
3rd retry30 minutes36 minutes
4th retry2 hours2 hours 36 minutes
5th retry12 hours~14 hours 36 minutes

After 5 failed retries, the delivery is marked as permanently failed. You can view all delivery attempts in the webhook delivery log (Integrations > Webhooks > Delivery Log).

Idempotent Processing

Because retries can cause the same payload to be delivered more than once, your webhook handler should be idempotent. Use the X-BttrForm-Delivery-Id header or submission.id to detect and skip duplicate deliveries.

Debugging Webhooks

Delivery Log

Every webhook delivery β€” successful or failed β€” is logged for 30 days. The log shows:

  • Timestamp β€” when the delivery was attempted
  • Status code β€” your server's HTTP response code
  • Response body β€” the first 1 KB of your server's response
  • Duration β€” how long the request took
  • Delivery ID β€” unique identifier for this attempt

Manual Replay

If a delivery failed and you have fixed the issue on your server, click "Retry" next to any failed delivery in the log. BttrForm resends the exact same payload with a new delivery ID and timestamp.

Local Development

For local development, use a tunneling tool to receive webhooks on your machine:

# Using ngrok
ngrok http 3000

# Your webhook URL becomes:
# https://abc123.ngrok.io/webhooks/bttrform

Paste the generated HTTPS URL into the webhook configuration in BttrForm. Remember to update it to your production URL before going live.

Pro Tip

Create a dedicated webhook endpoint per form if each form requires different processing logic. Alternatively, use a single endpoint and route internally based on the form.id in the payload.

Multiple Webhooks

A single form can have multiple webhook endpoints. Each webhook fires independently for every qualifying event. Use this to:

  • Send data to both a primary backend and a backup logging service
  • Notify multiple microservices that process different aspects of the submission
  • Maintain separate staging and production endpoints during development

Security Best Practices

  1. Always verify HMAC signatures β€” reject unsigned or incorrectly signed requests
  2. Check the timestamp β€” reject requests older than 5 minutes to prevent replay attacks
  3. Use HTTPS only β€” BttrForm enforces this, but ensure your TLS certificate is valid
  4. Rotate secrets periodically β€” generate a new secret in the integration settings and update your server
  5. Respond quickly β€” return a 200 status within 30 seconds. If processing is slow, acknowledge the webhook immediately and process asynchronously using a job queue
  6. Implement idempotency β€” use delivery IDs to handle duplicate deliveries gracefully

Was this helpful?

Webhooks | BttrForm