Appearance
Webhooks
Receive real-time HTTP notifications when new form submissions arrive. Krafter Forms sends a signed POST request to your endpoint for every new submission.
Base URL: https://app.krafter.dev/api/v1
All webhook endpoints are nested under a form: /forms/:form_id/webhooks. Signing, retry policy, and auto-disable rules are shared across the platform — see Webhook delivery.
List Webhooks
Retrieve all webhooks for a form.
GET /forms/:form_id/webhooksRequired scope: forms:read
Example Request
bash
curl https://app.krafter.dev/api/v1/forms/FORM_ID/webhooks \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
json
{
"data": [
{
"id": "f1a2b3c4-5d6e-7f8a-9b0c-1d2e3f4a5b6c",
"url": "https://api.yourapp.com/webhooks/forms",
"events": ["submission.created"],
"active": true,
"failure_count": 0,
"last_triggered_at": "2025-01-15T10:30:00Z",
"created_at": "2025-01-10T08:00:00Z"
}
]
}Create Webhook
Register a new webhook endpoint for a form.
POST /forms/:form_id/webhooksRequired scope: forms:write
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | The URL to receive webhook events. Must use https:// — lib/krafter/forms/form_webhook.ex:21 rejects http://, and Krafter.UrlValidator.validate_url/2 rejects loopback, RFC 1918 / link-local, cloud-metadata, and .local / .internal hosts to prevent SSRF. |
Events default to ["submission.created"]. A signing secret is automatically generated (64-character hex string).
Example Request
bash
curl -X POST https://app.krafter.dev/api/v1/forms/FORM_ID/webhooks \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourapp.com/webhooks/forms"
}'Example Response
json
// 201 Created
{
"id": "f1a2b3c4-5d6e-7f8a-9b0c-1d2e3f4a5b6c",
"url": "https://api.yourapp.com/webhooks/forms",
"events": ["submission.created"],
"active": true,
"secret": "a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
"failure_count": 0,
"last_triggered_at": null,
"created_at": "2025-01-15T10:00:00Z"
}WARNING
The secret is only returned when the webhook is created. Store it securely -- you will need it to verify webhook signatures.
Webhook records are immutable
The Forms service does not expose a PATCH /forms/:form_id/webhooks/:id endpoint today, even though Surveys, Flags, and Push all do. To change a webhook's URL or to rotate its secret, you have to delete the webhook and create a new one — the new one will return a fresh secret in the create response (the only time a secret is ever returned). If you need a stable URL while rotating secrets, terminate TLS and route to your handler at a path that's owned by you, not by this webhook record.
Delete Webhook
Permanently delete a webhook endpoint.
DELETE /forms/:form_id/webhooks/:idRequired scope: forms:write
Example Request
bash
curl -X DELETE https://app.krafter.dev/api/v1/forms/FORM_ID/webhooks/WEBHOOK_ID \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
204 No ContentWebhook Object
| Field | Type | Description |
|---|---|---|
id | string | Unique webhook identifier (UUID) |
url | string | Destination URL for webhook delivery |
events | string[] | Event types this webhook subscribes to. Only submission.created exists today, and it is the default. The dispatcher honours the list — a webhook with events: [] receives nothing — and an unknown event name is rejected with a 422 rather than accepted and never matched |
active | boolean | Whether the webhook is currently active |
secret | string | Signing secret (only returned on creation) |
failure_count | integer | Number of consecutive delivery failures |
last_triggered_at | string|null | ISO 8601 timestamp of the last delivery attempt |
created_at | string | ISO 8601 creation timestamp |
Webhook Payload
When a new submission arrives, Krafter sends an HTTP POST request to your webhook URL with the following JSON payload:
json
{
"event": "submission.created",
"form_id": "b1c2d3e4-5f6a-7b8c-9d0e-1f2a3b4c5d6e",
"form_name": "Contact Form",
"submission": {
"id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"data": {
"name": "John Doe",
"email": "john@example.com",
"message": "Hello, I have a question."
},
"files": {},
"metadata": {
"ip": "192.168.1.1",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"referrer": "https://example.com/contact",
"country": "US"
},
"created_at": "2025-01-15T10:30:00Z"
}
}Payload Fields
| Field | Type | Description |
|---|---|---|
event | string | Event type (submission.created) |
form_id | string | UUID of the form |
form_name | string | Display name of the form |
submission.id | string | UUID of the submission |
submission.data | object | Key-value pairs of submitted fields |
submission.files | object | File metadata for uploaded files |
submission.metadata.ip | string | Submitter's IP address |
submission.metadata.user_agent | string | Submitter's User-Agent header |
submission.metadata.referrer | string | Referring URL |
submission.metadata.country | string | Two-letter country code (GeoIP) |
submission.created_at | string | ISO 8601 timestamp |
Request Headers
Every webhook request includes these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
X-Krafter-Event | Event type (e.g., submission.created) |
X-Krafter-Signature | t=<unix>,v1=<hex> — HMAC-SHA256 of <t>.<body>; see Verifying signatures |
Signature Verification
Every webhook request made with a webhook secret carries:
X-Krafter-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>The timestamp is inside the signed payload, so a captured request stops verifying once t goes stale, and rewriting t invalidates v1.
Verification Steps
- Extract the
X-Krafter-Signatureheader value and split it intotandv1. - Check that
tis recent — we recommend a tolerance of 300 seconds. Reject anything older. - Compute
HMAC-SHA256(secret, "<t>." + raw_body)over the exact bytes received (do not re-serialise parsed JSON). - Compare the result to
v1using a constant-time comparison.
Node.js, Elixir and Python implementations are on Verifying signatures, which is the one place this recipe lives for every Krafter service.
Node.js
verifyKrafterSignature is the shared verifier from Verifying signatures — the handler below only adds what is specific to Forms.
javascript
// Express handler example
app.post("/webhooks/forms", (req, res) => {
const rawBody = req.rawBody; // ensure access to the raw body
if (!verifyKrafterSignature(req, process.env.KRAFTER_WEBHOOK_SECRET)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(rawBody);
console.log(`New submission: ${event.submission.id}`);
console.log(`Form: ${event.form_name}`);
console.log(`Data:`, event.submission.data);
// Process the submission...
res.status(200).json({ received: true });
});Python
python
import json
from flask import Flask, request, jsonify
# verify_krafter_signature: see /api/signatures
app = Flask(__name__)
@app.route("/webhooks/forms", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-Krafter-Signature", "")
raw_body = request.get_data()
if not verify_krafter_signature(signature, raw_body, WEBHOOK_SECRET):
return jsonify({"error": "Invalid signature"}), 401
event = json.loads(raw_body)
print(f"New submission: {event['submission']['id']}")
print(f"Form: {event['form_name']}")
print(f"Data: {event['submission']['data']}")
# Process the submission...
return jsonify({"received": True}), 200Automatic Disabling
Webhooks are automatically disabled after 10 consecutive delivery failures. A delivery is considered failed when:
- Your endpoint returns a non-2xx HTTP status code.
- The request times out.
- The endpoint is unreachable.
When a webhook is disabled:
- The
activefield is set tofalse. - The
failure_countreflects the number of consecutive failures. - No further deliveries are attempted until the webhook is re-enabled.
- An email is sent to the team owner with the webhook URL, failure count, and a link to the Forms dashboard so the disable doesn't go unnoticed.
TIP
Monitor the failure_count field to catch issues before a webhook is disabled. You can check webhook status via the List Webhooks endpoint or in the dashboard.
Best Practices
- Always verify the signature before processing webhook events.
- Respond with 2xx quickly. Process submissions asynchronously if needed. The webhook delivery expects a response within a reasonable timeout.
- Handle duplicate deliveries. Use the
submission.idfield to deduplicate. The same event may be sent more than once in edge cases. - HTTPS is required. The schema rejects
http://URLs and any host that resolves to loopback, RFC 1918 / link-local, cloud-metadata, or.local/.internalto prevent SSRF (form_webhook.ex:21-23). Existinghttp://rows from before this enforcement keep working until theirurlfield is changed; new webhooks must use HTTPS. - Store the secret securely. The webhook secret is only returned once at creation time. Keep it in environment variables or a secret manager.