BETA
Skip to content

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/webhooks

Required 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/webhooks

Required scope: forms:write

Request Body ​

FieldTypeRequiredDescription
urlstringYesThe 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/:id

Required 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 Content

Webhook Object ​

FieldTypeDescription
idstringUnique webhook identifier (UUID)
urlstringDestination URL for webhook delivery
eventsstring[]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
activebooleanWhether the webhook is currently active
secretstringSigning secret (only returned on creation)
failure_countintegerNumber of consecutive delivery failures
last_triggered_atstring|nullISO 8601 timestamp of the last delivery attempt
created_atstringISO 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 ​

FieldTypeDescription
eventstringEvent type (submission.created)
form_idstringUUID of the form
form_namestringDisplay name of the form
submission.idstringUUID of the submission
submission.dataobjectKey-value pairs of submitted fields
submission.filesobjectFile metadata for uploaded files
submission.metadata.ipstringSubmitter's IP address
submission.metadata.user_agentstringSubmitter's User-Agent header
submission.metadata.referrerstringReferring URL
submission.metadata.countrystringTwo-letter country code (GeoIP)
submission.created_atstringISO 8601 timestamp

Request Headers ​

Every webhook request includes these headers:

HeaderDescription
Content-Typeapplication/json
X-Krafter-EventEvent type (e.g., submission.created)
X-Krafter-Signaturet=<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 ​

  1. Extract the X-Krafter-Signature header value and split it into t and v1.
  2. Check that t is recent — we recommend a tolerance of 300 seconds. Reject anything older.
  3. Compute HMAC-SHA256(secret, "<t>." + raw_body) over the exact bytes received (do not re-serialise parsed JSON).
  4. Compare the result to v1 using 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}), 200

Automatic 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 active field is set to false.
  • The failure_count reflects 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.id field 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 / .internal to prevent SSRF (form_webhook.ex:21-23). Existing http:// rows from before this enforcement keep working until their url field 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.

Built by Krafter Studio