Webhooks

Add one or more webhooks to a form and SendForm posts to each of them after a submission is stored. Delivery runs after the visitor's response is sent, so a slow or failing endpoint never delays the submission.

The payload

A generic webhook receives:

{
  "event": "submission",
  "form_id": "a8Kz3mXq12",
  "form_name": "Contact",
  "submission_id": 4213,
  "data": {
    "email": "you@example.com",
    "message": "Hello",
    "fields": { "company": "Example Ltd" },
    "attachments": [
      { "id": 91, "filename": "brief.pdf", "size_bytes": 184320 }
    ]
  }
}
Field Type Description
event string submission, feedback, or test for a test delivery.
form_id string The form's public ID - the one in its endpoint URL.
form_name string The form's display name.
submission_id integer or null The stored submission. Always null for feedback events.
data.email string The submitter's normalized address. Empty for feedback (votes are anonymous).
data.message string The message body. Empty for feedback.
data.fields object Extra fields, as strings. For feedback: rating, plus comment and page when present.
data.attachments array Stored files, each with id, filename and size_bytes.

Headers

Header Value
Content-Type application/json
User-Agent SendForm-Webhook/1.0
X-SendForm-Event submission, feedback or test
X-SendForm-Signature sha256=<hex> - HMAC-SHA256 of the raw request body, keyed with the webhook's secret

Verify the signature against the raw bytes of the body, before any JSON parsing, and compare in constant time:

import hashlib, hmac

def valid(secret: str, body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)

Retries

Each delivery is attempted up to three times - immediately, then after 1 second, then after 3 - with a 10-second timeout per attempt. Any 2xx response counts as success and stops the retries; anything else, including a transport error, is another attempt.

Every attempt is logged and visible on the form, and if a webhook still fails after its retries the form owner gets one in-app notification per form per six hours, so a broken integration does not go unnoticed.

Webhook URLs are re-validated at delivery time and never posted to internal or private addresses, even if the URL was stored before that check existed or its DNS record has since changed.

Chat providers

Slack, Mattermost, Discord and Telegram URLs are detected automatically and receive that service's native message format instead of the generic payload, so the submission reads as a formatted message in the channel rather than raw JSON. Everything else gets the generic body above.

For feedback forms the vote's label, comment and page ride along as labelled fields with no message body. By default only votes that carry a comment are delivered, so a busy page cannot flood a channel; the form setting turns that off.