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 a GeoIP lookup of the source IP address performed at submission time by Krafter.Forms.GeoIP.

  • 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: IPLocate MMDB (with MaxMind GeoLite2 as a fallback format the lookup also accepts), served via geolix.
  • 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.

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