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.