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 a GeoIP lookup of the source IP address performed at submission time by Krafter.Forms.GeoIP.
- 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: IPLocate MMDB (with MaxMind GeoLite2 as a fallback format the lookup also accepts), served via
geolix. 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.
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.