BETA
Skip to content

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

Required scope: forms:read

Query Parameters ​

ParameterTypeDefaultDescription
pageinteger1Page number
per_pageinteger20Results per page
is_spamboolean--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/:id

Required 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/:id

Required scope: forms:write

Request Body ​

FieldTypeDescription
is_readbooleanMark the submission as read or unread
is_spambooleanMark 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/:id

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

Export Submissions ​

Export all submissions for a form as JSON or CSV.

POST /forms/:form_id/submissions/export

Required scope: forms:read

Query Parameters ​

ParameterTypeDefaultDescription
formatstringjsonExport 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.csv

Returns 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:00Z

TIP

CSV export is also available from the dashboard. Open your form, go to the Submissions tab, and click Export CSV.


Submission Object ​

FieldTypeDescription
idstringUnique submission identifier (UUID)
dataobjectKey-value pairs of submitted form fields
filesobjectFile metadata for uploaded files (see File Uploads)
is_spambooleanWhether the submission is flagged as spam
is_readbooleanWhether the submission has been marked as read
ipstringIP address of the submitter
countrystring | nullTwo-letter country code (ISO 3166-1 alpha-2), detected via GeoIP. See Country lookup below for the lookup behaviour and null cases.
referrerstringThe referring URL (from the Referer header)
created_atstringISO 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_ip of 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 geolix and refreshed in place as new editions ship. This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com.
  • null cases: any of these return null for the country field:
    • 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.

TriggerBodyResponse 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) ​

TriggerBody
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.

Built by Krafter Studio