Mobile apps

Headless API for mobile

Don't want an iframe on your mobile app? Use the public API to fetch the form schema and submit responses from your own UI — on Android, iOS, React Native or Flutter. On the web you can use the eesyform-embed package's EesyFormClient for the same calls.

1Fetch the form schema

Send a GET request to the public schema endpoint. You get the published fields, settings, choices and logic rules as JSON — enough to render the form however you like.

Request
GET https://YOUR-EESYFORM-HOST.com/api/public/forms/my-form-abc123
Response
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": "form_0f8d",
  "slug": "my-form-abc123",
  "title": "Customer feedback",
  "description": "Tell us about your experience",
  "settings": {
    "showProgressBar": true,
    "welcomeScreen": {
      "title": "Hi there!",
      "description": "A quick survey, one question at a time.",
      "showButton": true,
      "buttonLabel": "Start"
    },
    "thankYouScreen": {
      "title": "Thanks for your feedback!",
      "description": "Your response has been recorded."
    },
    "embeddable": true,
    "hiddenVariables": []
  },
  "fields": [
    {
      "id": "q_9kf2",
      "type": "short_text",
      "title": "What's your name?",
      "description": null,
      "required": true,
      "properties": { "placeholder": "Ada Lovelace" }
    },
    {
      "id": "q_m8p1",
      "type": "multiple_choice",
      "title": "How satisfied are you?",
      "description": null,
      "required": true,
      "properties": {
        "choices": [
          { "id": "c_1", "label": "Very satisfied" },
          { "id": "c_2", "label": "Somewhat satisfied" },
          { "id": "c_3", "label": "Not satisfied" }
        ],
        "allowOther": true
      }
    },
    {
      "id": "q_4t7c",
      "type": "number",
      "title": "How likely are you to recommend us?",
      "description": "0 to 10",
      "required": false,
      "properties": { "min": 0, "max": 10 }
    }
  ],
  "logic": []
}

Password-protected forms return 403 and closed forms return 404 so the schema is never leaked.

2Submit responses

Post responses with a Typeform-style payload. Each field carries a field.id and type plus the matching typed value. Required fields must all be present.

Request
POST https://YOUR-EESYFORM-HOST.com/api/public/forms/my-form-abc123/responses
Content-Type: application/json

{
  "responses": [
    {
      "sessionId": "app-session-1234",
      "hidden": { "utm_source": "ios-app" },
      "fields": [
        {
          "field": { "id": "q_9kf2", "type": "short_text" },
          "text": "Ada Lovelace"
        },
        {
          "field": { "id": "q_m8p1", "type": "multiple_choice" },
          "choice": { "label": "Very satisfied" }
        },
        {
          "field": { "id": "q_4t7c", "type": "number" },
          "number": 9
        }
      ]
    }
  ]
}
Response
HTTP/1.1 201 Created
Content-Type: application/json

{
  "responses": [
    { "response_id": "rsp_8d1a", "form_id": "form_0f8d" }
  ]
}

Value shapes by field type

field-types
// Value payloads per field type
short_text   -> { "text": "Ada" }
long_text    -> { "text": "Multiline reply" }
email        -> { "text": "ada@example.com" }
phone        -> { "text": "+1 555 0100" }
website      -> { "text": "https://example.com" }
number       -> { "number": 42 }
date         -> { "date": "1995-06-15" }
yes_no       -> { "choice": { "label": "Yes" } }  // or { "boolean": true }
multiple_choice -> { "choice": { "label": "Choice label" } }
dropdown     -> { "choice": { "label": "Option label" } }
picture_choice  -> { "choice": { "label": "Option label" } }
multiple_select -> { "choices": { "labels": ["A", "C"] } }
ranking      -> { "choices": { "labels": ["First", "Second"] } }
rating       -> { "number": 5 }
opinion_scale  -> { "number": 4 }
legal        -> { "boolean": true }
statement    -> {}                       // informational only
file_upload  -> { "file_url": "https://..." }
payment      -> { "payment": { ... } }   // handled in-app by Razorpay

Errors

The API validates every request and returns 400 with a descriptive message when something is wrong:

Error responses
HTTP/1.1 400 Bad Request
{ "error": "Missing required field \"q_9kf2\"" }

HTTP/1.1 400 Bad Request
{ "error": "Unknown field id \"q_wrong\"" }

HTTP/1.1 400 Bad Request
{ "error": "Invalid or missing value for field \"q_4t7c\"" }

HTTP/1.1 403 Forbidden
{ "error": "Form is password protected" }

HTTP/1.1 404 Not Found
{ "error": "Form is closed" }

HTTP/1.1 429 Too Many Requests
{ "error": "Rate limit exceeded" }                          // 10 req/min per IP

HTTP/1.1 429 Too Many Requests
{ "error": "WORKSPACE_RESPONSE_LIMIT_REACHED" }

HTTP/1.1 429 Too Many Requests
{ "error": "FORM_LIMIT_REACHED" }

HTTP/1.1 402 Payment Required
{ "error": "Payment required before completing this form" }

HTTP/1.1 403 Forbidden
{ "error": "PLAN_REQUIRED", "message": "file_upload requires Pro plan" }
File uploads & payments — the public API accepts file_url and payment values, but the upload/checkout flows themselves run in the hosted form. For those field types, open the embed page in a WebView or browser.

3Platform recipes

CORS is open (*), so browsers, native clients and servers can all call the API directly.

Native Android (Kotlin)

Fetch the schema with OkHttp, then render each field in a RecyclerView or Compose list. Required fields are listed in fields[] with their validation constraints under properties.

FormApi.kt — fetch schema
// Get the form schema
val slug = "my-form-abc123"
val client = OkHttpClient()

val schemaRequest = Request.Builder()
    .url("https://YOUR-EESYFORM-HOST.com/api/public/forms/$slug")
    .build()

client.newCall(schemaRequest).enqueue(object : Callback {
    override fun onFailure(call: Call, e: IOException) {
        Log.e("EesyForm", "Failed to load schema", e)
    }

    override fun onResponse(call: Call, response: Response) {
        if (!response.isSuccessful) {
            Log.e("EesyForm", "HTTP ${response.code}: ${response.body?.string()}")
            return
        }
        val schema = JSONObject(response.body?.string() ?: "{}")
        val fields = schema.getJSONArray("fields")
        // Render each field in your UI...
    }
})
FormApi.kt — submit
// Submit a response
val body = JSONObject().apply {
    val responses = JSONArray()
    val r = JSONObject().apply {
        put("sessionId", "android-1234")
        put("hidden", JSONObject().put("utm_source", "android"))
        val fields = JSONArray()

        fields.put(JSONObject().apply {
            put("field", JSONObject().apply {
                put("id", "q_9kf2")
                put("type", "short_text")
            })
            put("text", "Ada Lovelace")
        })
        fields.put(JSONObject().apply {
            put("field", JSONObject().apply {
                put("id", "q_m8p1")
                put("type", "multiple_choice")
            })
            put("choice", JSONObject().put("label", "Very satisfied"))
        })
        put("fields", fields)
    }
    responses.put(r)
    put("responses", responses)
}

val submitRequest = Request.Builder()
    .url("https://YOUR-EESYFORM-HOST.com/api/public/forms/my-form-abc123/responses")
    .post(body.toString().toRequestBody("application/json".toMediaType()))
    .build()

client.newCall(submitRequest).enqueue(object : Callback {
    override fun onResponse(call: Call, response: Response) {
        if (response.code == 201) {
            Log.i("EesyForm", "Response submitted")
        } else {
            Log.e("EesyForm", "HTTP ${response.code}: ${response.body?.string()}")
        }
    }
    override fun onFailure(call: Call, e: IOException) {
        Log.e("EesyForm", "Submission failed", e)
    }
})

Native iOS (Swift)

FormClient.swift — fetch schema
import Foundation

struct FormSchema: Decodable {
    let id: String
    let slug: String
    let title: String
    let fields: [FormField]
}

struct FormField: Decodable, Identifiable {
    let id: String
    let type: String
    let title: String
    let required: Bool
    let properties: [String: JSONValue]?
}

func fetchSchema(slug: String) async throws -> FormSchema {
    let url = URL(string: "https://YOUR-EESYFORM-HOST.com/api/public/forms/\(slug)")!
    let (data, response) = try await URLSession.shared.data(from: url)

    guard let http = response as? HTTPURLResponse,
          http.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }
    return try JSONDecoder().decode(FormSchema.self, from: data)
}
FormClient.swift — submit
struct SubmitBody: Encodable {
    struct FieldValue: Encodable {
        struct FieldRef: Encodable {
            let id: String
            let type: String
        }
        let field: FieldRef
        let text: String?
        let number: Int?
        let choice: ChoiceRef?
    }
    struct ChoiceRef: Encodable {
        let label: String
    }

    let responses: [Response]
    struct Response: Encodable {
        let sessionId: String?
        let fields: [FieldValue]
    }
}

func submitResponses(fields: [SubmitBody.FieldValue]) async throws {
    var request = URLRequest(
        url: URL(string: "https://YOUR-EESYFORM-HOST.com/api/public/forms/my-form-abc123/responses")!
    )
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    let body = SubmitBody(
        responses: [
            .init(sessionId: "ios-1234", fields: fields)
        ]
    )
    request.httpBody = try JSONEncoder().encode(body)

    let (_, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse, http.statusCode == 201 else {
        throw URLError(.badServerResponse)
    }
}

React Native

React Native can use the package's typed client directly — no fetch boilerplate or hand-rolled types.

RemoteForm.tsx
import { useEffect, useState } from "react";
import {
  ActivityIndicator,
  FlatList,
  Pressable,
  Text,
  TextInput,
  View,
} from "react-native";
import { EesyFormClient, type FormSchema } from "eesyform-embed/client";

const client = new EesyFormClient("https://YOUR-EESYFORM-HOST.com");

export function RemoteForm({ slug }: { slug: string }) {
  const [schema, setSchema] = useState<FormSchema | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [answers, setAnswers] = useState<Record<string, unknown>>({});
  const [submitting, setSubmitting] = useState(false);

  useEffect(() => {
    client
      .getSchema(slug)
      .then(setSchema)
      .catch((e: Error) => setError(e.message));
  }, [slug]);

  if (error) return <Text style={{ color: "red" }}>{error}</Text>;
  if (!schema) return <ActivityIndicator />;

  async function submit() {
    setSubmitting(true);
    try {
      await client.submitResponses(slug, {
        responses: [
          {
            sessionId: "rn-1234",
            fields: schema.fields
              .filter((f) => answers[f.id] !== undefined)
              .map((f) => ({
                field: { id: f.id, type: f.type },
                ...(answers[f.id] as object),
              })),
          },
        ],
      });
      Alert.alert("Done", "Response recorded");
    } catch (e) {
      Alert.alert("Error", (e as Error).message);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <FlatList
      data={schema.fields}
      renderItem={({ item }) => (
        <TextInput
          placeholder={item.title}
          onChangeText={(value) =>
            setAnswers((prev) => ({
              ...prev,
              [item.id]: { text: value },
            }))
          }
        />
      )}
      ListFooterComponent={
        <Pressable disabled={submitting} onPress={submit}>
          <Text>{submitting ? "Submitting..." : "Submit"}</Text>
        </Pressable>
      }
    />
  );
}

Flutter

Uses the http package. Pair with json_serializable models for type safety.

form_api.dart
import 'dart:convert';
import 'package:http/http.dart' as http;

const host = "https://YOUR-EESYFORM-HOST.com";

Future<Map<String, dynamic>> fetchSchema(String slug) async {
  final res = await http.get(
    Uri.parse('$host/api/public/forms/$slug'),
  );
  if (res.statusCode != 200) {
    throw Exception('Failed to load form: ${res.body}');
  }
  return jsonDecode(res.body) as Map<String, dynamic>;
}

Future<void> submitResponse(List<Map<String, dynamic>> fields) async {
  final payload = jsonEncode({
    'responses': [
      {
        'sessionId': 'flutter-1234',
        'hidden': {'utm_source': 'flutter'},
        'fields': fields,
      },
    ],
  });

  final res = await http.post(
    Uri.parse('$host/api/public/forms/my-form-abc123/responses'),
    headers: {'Content-Type': 'application/json'},
    body: payload,
  );

  if (res.statusCode != 201) {
    throw Exception('Submission failed: ${res.body}');
  }
}

// Usage:
// final schema = await fetchSchema('my-form-abc123');
// final fields = (schema['fields'] as List).cast<Map<String, dynamic>>();
// await submitResponse([
//   {
//     'field': {'id': 'q_9kf2', 'type': 'short_text'},
//     'text': 'Ada Lovelace',
//   },
// ]);

void main() async {
  final schema = await fetchSchema('my-form-abc123');
  print('Form: ${schema['title']}');
  await submitResponse([
    {
      'field': {'id': 'q_9kf2', 'type': 'short_text'},
      'text': 'Ada Lovelace',
    },
  ]);
}
Need the hosted UI instead? If you'd rather not build a custom renderer, open the embed page in a WebView or follow the web embed guide for the no-code route.