Appearance
Submissions
List, retrieve, update, delete, and export form submissions.
Base URL: https://app.krafter.dev/api/v1
All submission endpoints are nested under a form: /forms/:form_id/submissions.
List Submissions
Retrieve a paginated list of submissions for a form.
GET /forms/:form_id/submissionsRequired scope: forms:read
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
per_page | integer | 20 | Results per page |
is_spam | boolean | -- | Filter by spam status. Omit to return all submissions. |
Example Request
bash
curl "https://app.krafter.dev/api/v1/forms/FORM_ID/submissions?page=1&per_page=10" \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
json
{
"data": [
{
"id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"data": {
"name": "John Doe",
"email": "john@example.com",
"message": "Hello, I have a question about your product."
},
"files": {},
"is_spam": false,
"is_read": false,
"ip": "192.168.1.1",
"country": "US",
"referrer": "https://example.com/contact",
"created_at": "2025-01-15T10:30:00Z"
},
{
"id": "d4e5f6a7-8b9c-0d1e-2f3a-4b5c6d7e8f9a",
"data": {
"name": "Jane Smith",
"email": "jane@example.com",
"message": "Great product! I would like to schedule a demo."
},
"files": {},
"is_spam": false,
"is_read": true,
"ip": "10.0.0.42",
"country": "DE",
"referrer": "https://example.com/pricing",
"created_at": "2025-01-15T09:15:00Z"
}
],
"pagination": {
"page": 1,
"per_page": 10,
"total": 42,
"total_pages": 5
}
}Filtering Spam
bash
# Only spam submissions
curl "https://app.krafter.dev/api/v1/forms/FORM_ID/submissions?is_spam=true" \
-H "Authorization: Bearer kr_live_abc123def456"
# Only non-spam submissions
curl "https://app.krafter.dev/api/v1/forms/FORM_ID/submissions?is_spam=false" \
-H "Authorization: Bearer kr_live_abc123def456"Get Submission
Retrieve a single submission by ID.
GET /forms/:form_id/submissions/:idRequired scope: forms:read
Example Request
bash
curl https://app.krafter.dev/api/v1/forms/FORM_ID/submissions/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
json
{
"id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"data": {
"name": "John Doe",
"email": "john@example.com",
"message": "Hello, I have a question about your product."
},
"files": {},
"is_spam": false,
"is_read": false,
"ip": "192.168.1.1",
"country": "US",
"referrer": "https://example.com/contact",
"created_at": "2025-01-15T10:30:00Z"
}Update Submission
Update a submission's is_read and is_spam flags. No other fields can be modified.
PATCH /forms/:form_id/submissions/:idRequired scope: forms:write
Request Body
| Field | Type | Description |
|---|---|---|
is_read | boolean | Mark the submission as read or unread |
is_spam | boolean | Mark the submission as spam or not spam |
Example Request -- Mark as Read
bash
curl -X PATCH https://app.krafter.dev/api/v1/forms/FORM_ID/submissions/SUBMISSION_ID \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{"is_read": true}'Example Request -- Mark as Spam
bash
curl -X PATCH https://app.krafter.dev/api/v1/forms/FORM_ID/submissions/SUBMISSION_ID \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{"is_spam": true}'Example Response
json
{
"id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"data": {
"name": "John Doe",
"email": "john@example.com",
"message": "Hello, I have a question about your product."
},
"files": {},
"is_spam": false,
"is_read": true,
"ip": "192.168.1.1",
"country": "US",
"referrer": "https://example.com/contact",
"created_at": "2025-01-15T10:30:00Z"
}Delete Submission
Permanently delete a single submission.
DELETE /forms/:form_id/submissions/:idRequired scope: forms:write
Example Request
bash
curl -X DELETE https://app.krafter.dev/api/v1/forms/FORM_ID/submissions/SUBMISSION_ID \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
204 No ContentExport Submissions
Export all submissions for a form as JSON or CSV.
POST /forms/:form_id/submissions/exportRequired scope: forms:read
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
format | string | json | Export format: json or csv |
Example Request -- JSON Export
bash
curl -X POST "https://app.krafter.dev/api/v1/forms/FORM_ID/submissions/export?format=json" \
-H "Authorization: Bearer kr_live_abc123def456"Returns a JSON array of all submissions:
json
[
{
"id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"data": {
"name": "John Doe",
"email": "john@example.com",
"message": "Hello!"
},
"is_spam": false,
"is_read": true,
"ip": "192.168.1.1",
"country": "US",
"created_at": "2025-01-15T10:30:00Z"
}
]Example Request -- CSV Export
bash
curl -X POST "https://app.krafter.dev/api/v1/forms/FORM_ID/submissions/export?format=csv" \
-H "Authorization: Bearer kr_live_abc123def456" \
-o submissions.csvReturns a CSV file with headers derived from the submission data fields:
csv
id,name,email,message,is_spam,is_read,ip,country,created_at
a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,John Doe,john@example.com,Hello!,false,true,192.168.1.1,US,2025-01-15T10:30:00ZTIP
CSV export is also available from the dashboard. Open your form, go to the Submissions tab, and click Export CSV.
Submission Object
| Field | Type | Description |
|---|---|---|
id | string | Unique submission identifier (UUID) |
data | object | Key-value pairs of submitted form fields |
files | object | File metadata for uploaded files (see File Uploads) |
is_spam | boolean | Whether the submission is flagged as spam |
is_read | boolean | Whether the submission has been marked as read |
ip | string | IP address of the submitter |
country | string | null | Two-letter country code (ISO 3166-1 alpha-2), detected via GeoIP. See Country lookup below for the lookup behaviour and null cases. |
referrer | string | The referring URL (from the Referer header) |
created_at | string | ISO 8601 timestamp of when the submission was received |
Country lookup
Each submission stores the submitter's country, derived from an offline GeoIP lookup of the source IP address performed at submission time by Krafter.Geo.country/1.
- Source IP:
conn.remote_ipof the public POST request — i.e. whatever your reverse proxy passes through. The lookup happens inline as the submission is created and is persisted on the row. - Format: ISO-3166 alpha-2 (e.g.
US,DE,RS). - Database: MaxMind GeoLite2 City, read locally via
geolixand refreshed in place as new editions ship. This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com. nullcases: any of these returnnullfor thecountryfield:- the IP is unparseable;
- no GeoIP database is configured on the node handling the request;
- the IP is in the database but has no country code (e.g. some private/loopback ranges);
- the lookup itself raises (network or library errors are swallowed and treated as a miss).
- Use: informational only. The country is not used for rate limits, spam scoring, billing, or routing — it is purely a metadata field exposed on the submission and on CSV/JSON exports.
Error responses
The authenticated /api/v1/forms/:form_id/submissions* routes route through the shared KrafterWeb.Api.V1.FallbackController and follow the standard envelope shapes. The public POST /f/:slug endpoint deliberately bypasses that fallback so it can return short flat bodies that don't leak which condition failed.
400 Bad Request (public endpoint)
A POST /f/:slug sent as application/json must carry a JSON object of fields. A string, array, number, boolean or null is refused before the slug is looked up, so the answer is the same for every slug and nothing is stored:
json
// 400 — the body was a JSON string, e.g. an object encoded twice
{ "error": "request body must be a JSON object, got a JSON string" }See Errors for the usual cause. HTML form posts (application/x-www-form-urlencoded, multipart/form-data) are not affected.
429 Too Many Requests
Three distinct conditions can return 429 on the public submission endpoint. Differentiate by the JSON body — only the platform pipeline also sets X-RateLimit-* and Retry-After response headers.
| Trigger | Body | Response headers |
|---|---|---|
| Platform pipeline cap (200 req / 60 s per IP, per node) | {"error": "Rate limit exceeded", "retry_after": <seconds>} | X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After |
Per-form RateLimiter (default 10 req / 60 s per (form, IP)) | {"error": "rate_limited"} | none |
| Billing quota exhausted (team is over its monthly submissions plan) | {"error": "monthly_submission_limit_exceeded"} | none |
The platform cap is applied first by KrafterWeb.Plugs.RateLimit in the :public_rate_limit pipeline, so it is what callers will see once an IP starts hitting /f/* aggressively. The per-form rate_limited body is what callers see when the IP is under the platform ceiling but is hammering one specific form. The monthly_submission_limit_exceeded body is a billing signal and does not reset on the rate-limit window — contact the form owner to upgrade their plan.
422 Unprocessable Entity (public endpoint)
| Trigger | Body |
|---|---|
| Slug does not resolve to any form | {"error": "submission_failed"} |
Form exists but status is paused or archived (see Pausing a form) | {"error": "submission_failed"} |
Both triggers return the same body — {"error": "submission_failed"} — because Forms.get_form_by_slug_global/1 filters status == "active" in the lookup query (lib/krafter/forms.ex:278), so paused/archived forms fall through the nil branch and never reach the controller's separate form_inactive response. The response is also delayed by a small randomised pause (50–200 ms) so the submitter cannot distinguish either condition from response timing.