Webhooks

Receive a signed POST request at your own URL every time a SimplyForms submission arrives.

Overview

Webhooks let you push form submissions to your own services — Slack relays, Zapier, internal CRMs, anything that can accept an HTTP POST. Every active webhook for a form receives a signed JSON payload as soon as a submission is committed.

Each delivery is:

  • Signed with HMAC-SHA256 so you can verify the request really came from SimplyForms.
  • Retried with exponential backoff for up to ~48 hours when your receiver returns a transient error.
  • Idempotent— a stable delivery ID lets you safely deduplicate retries.
  • Logged in your dashboard so you can inspect every attempt and replay failed ones with one click.

Plan availability: Webhooks are included on the Starter plan and above (1 webhook on Starter, 3 on Pro, 10 on Business). See pricing for the full comparison.

Configuring an endpoint

  1. Open a form in your dashboard and switch to the Webhooks tab.
  2. Click Add webhook and enter the receiver URL (https recommended).
  3. Optionally add custom headers (e.g. Authorization: Bearer …) — these are sent with every delivery.
  4. Save. Your signing secret is generated automatically and shown once on creation. You can re-view it at any time using the Show secret button on the endpoint card.
  5. Use Test fire to send a synthetic event and confirm your receiver responds with a 2xx.

Payload format

Every delivery is a POST with Content-Type: application/json. The body is a single JSON object:

{
  "event": "submission.created",
  "delivery_id": "9d3b6c4a-7e22-4d9a-8b1f-7a3c6b8e2d11",
  "occurred_at": "2026-04-25T14:22:31.000Z",
  "data": {
    "submission_id": "5a7c4f12-3e41-4bc8-9c0d-1e9f2a4b6c8d",
    "form": {
      "id": "9b3a4f1e-8c2d-4e6b-a1f3-c5d7e9f0a1b2",
      "name": "Contact form",
      "short_id": "abc123xyz0"
    },
    "submission": {
      "name": "Jane Doe",
      "email": "jane@example.com",
      "message": "Hello!"
    },
    "files": [
      { "name": "resume.pdf", "size": 184320, "content_type": "application/pdf" }
    ]
  }
}

The submission object contains the exact field names from your form. The files array is empty when no files are attached.

Headers

HeaderDescription
Content-TypeAlways application/json.
User-AgentSimplyForms-Webhooks/1.0.
X-SimplyForms-EventEvent type, e.g. submission.created or webhook.test for test fires.
X-SimplyForms-Delivery-IdUUID identifying this specific delivery. Stable across retries — use it for idempotency on your side.
X-SimplyForms-TimestampUnix timestamp (seconds) at the moment the delivery was enqueued. Part of the signed payload.
X-SimplyForms-SignatureHMAC-SHA256 of {timestamp}.{body} using your signing secret, hex-encoded, prefixed with sha256=.

Any custom headers you configured on the endpoint are merged with these. The X-SimplyForms-* prefix is reserved and cannot be overridden.

Verifying signatures

Always verify the signature before trusting the payload. The exact byte sequence to sign is:

signed_payload = X-SimplyForms-Timestamp + "." + raw_request_body
expected = "sha256=" + hex(hmac_sha256(secret, signed_payload))

Compare expected to the X-SimplyForms-Signature header using a constant-time comparison. The body must be the raw bytes as received — do not parse and re-serialize the JSON before signing, or whitespace differences will break verification.

For replay protection, also reject deliveries whose timestamp is more than a few minutes old (5 minutes is a reasonable default).

Node.js (Express)

import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.SIMPLYFORMS_WEBHOOK_SECRET;

// Capture the raw body so we can verify the signature byte-for-byte.
app.post(
  "/webhooks/simplyforms",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("X-SimplyForms-Signature") || "";
    const timestamp = req.header("X-SimplyForms-Timestamp") || "";

    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(400).send("Stale timestamp");
    }

    const expected =
      "sha256=" +
      crypto
        .createHmac("sha256", SECRET)
        .update(`${timestamp}.${req.body.toString("utf8")}`)
        .digest("hex");

    const sigBuf = Buffer.from(signature);
    const expBuf = Buffer.from(expected);
    if (
      sigBuf.length !== expBuf.length ||
      !crypto.timingSafeEqual(sigBuf, expBuf)
    ) {
      return res.status(401).send("Bad signature");
    }

    const event = JSON.parse(req.body.toString("utf8"));
    // ... handle event, dedupe by event.delivery_id ...
    res.status(200).send("ok");
  }
);

Python (Flask)

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

app = Flask(__name__)
SECRET = os.environ["SIMPLYFORMS_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/simplyforms")
def receive():
    signature = request.headers.get("X-SimplyForms-Signature", "")
    timestamp = request.headers.get("X-SimplyForms-Timestamp", "")

    try:
        if abs(time.time() - int(timestamp)) > 300:
            abort(400, "Stale timestamp")
    except ValueError:
        abort(400, "Bad timestamp")

    body = request.get_data()  # raw bytes
    expected = "sha256=" + hmac.new(
        SECRET, f"{timestamp}.{body.decode()}".encode(), hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        abort(401, "Bad signature")

    event = request.get_json()
    # ... handle event, dedupe by event["delivery_id"] ...
    return "", 200

PHP

<?php
$secret = getenv('SIMPLYFORMS_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIMPLYFORMS_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_SIMPLYFORMS_TIMESTAMP'] ?? '';

if (abs(time() - (int) $timestamp) > 300) {
    http_response_code(400);
    exit('Stale timestamp');
}

$expected = 'sha256=' . hash_hmac('sha256', "{$timestamp}.{$body}", $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Bad signature');
}

$event = json_decode($body, true);
// ... handle event, dedupe by $event['delivery_id'] ...
http_response_code(200);
echo 'ok';

Retries & timeouts

Each delivery has a 10-second connect+read timeout. If your receiver returns a retryable status or fails to respond, we back off and try again on the schedule below (with ±10% jitter). We give up after 8 attempts (~48 hours total).

AttemptDelay before this attemptCumulative
10s (inline)0s
21 minute~1 min
35 minutes~6 min
430 minutes~36 min
52 hours~2.6 hrs
66 hours~8.6 hrs
712 hours~20.6 hrs
824 hours~44.6 hrs

What we retry

  • Network errors and timeouts.
  • HTTP 408, 429, and 5xx.

What we don't retry

The following responses are treated as a permanent rejection — the delivery is marked dead immediately:

  • 400 Bad Request
  • 401 Unauthorized
  • 403 Forbidden
  • 404 Not Found
  • 410 Gone
  • 422 Unprocessable Entity

If your receiver responds with a 2xx (any status from 200 to 299), the delivery is marked succeeded.

Idempotency

Webhooks are at-least-once. The same delivery may be re-attempted after a transient failure, and your receiver may briefly process it twice (for example, if it succeeded but our connection dropped before you returned the response).

Every retry of the same delivery sends an identical X-SimplyForms-Delivery-Id. Use this value as a deduplication key — store it on first receipt and ignore subsequent deliveries with the same ID.

Replays (triggered manually from the dashboard) get a new delivery_idon purpose — they're a deliberate re-emission, not a retry, so your receiver should treat them as a fresh event.

Replays & test fires

Replay

From the dashboard delivery log, click Replay on any past delivery to re-send it with a fresh delivery ID and a new signature. The body is byte-identical to the original. Replays are useful when your receiver was down or you fixed a bug and want to re-process a specific event.

Test fire

The Test fire button on each endpoint sends a synthetic event so you can confirm your receiver and signature verification work end-to-end without waiting for a real submission. Test fires use the event type webhook.test — handle them or filter them out as you see fit.

Auto-disable

If an endpoint accumulates 20 consecutive failed deliveries, we automatically disable it and email the form's notification recipients. The endpoint stays in your dashboard with an Auto-disabledbadge — re-enable it once you've fixed the receiver.

A successful delivery anywhere along the way resets the counter, so a transient outage alone won't trip auto-disable.