Workspace

Forms API

Build the form yourself, in your own markup, and post the answers here.

The embed on the Forms page puts our form on your site. This is the other option: your own fields, your own markup, your own submit button, and one POST when someone finishes. Nothing about the form has to exist here except the record the answers land in.

Create the form in SystemOS as usual and set it live, so it has a slug. You can leave it with no questions at all: the slug is the address, the questions only matter if you want us to render them.

Sending a submission

POST https://www.systemos.so/api/public/form/your-form/submit
Content-Type: application/json

{
  "name": "Anna Weber",
  "email": "anna@studiokern.de",
  "answers": {
    "budget": "10-20k",
    "services": ["Branding", "Website"],
    "notes": "Launching in March."
  }
}

→ 200 { "ok": true, "responseId": "kG0LOHCLHs" }

No key, no token, no signature. The endpoint reads and sets no cookie, so it is callable straight from the browser on any domain — an API key in front-end code would be public anyway. The slug and the rate limit are the gates: 60 submissions per IP and 500 per form, each per 10 minutes, answered with 429.

What goes in answers

  • Any field names you like. They do not have to match questions here, and a form with no questions still records everything you send.
  • Strings, numbers, booleans, arrays of strings, or a flat object. Up to 200 fields, 8,000 characters per string, 50 entries per array.
  • name and email sit outside answers. They are what the response is listed under, so send them when you have them.

A field whose name matches a question in the form lines up under that question. Everything else is listed under its own name, marked as having come from the API, and is in the CSV export either way.

A working example

const res = await fetch(
  "https://www.systemos.so/api/public/form/your-form/submit",
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name, email, answers }),
  },
);
if (!res.ok) {
  // 429 means rate limited, 404 means the form is not live
  throw new Error((await res.json()).error);
}

Reading the questions

If you would rather render our questions in your own markup than hard-code your fields, GET the definition. It returns the live form: sections, steps, ids, options and labels. Draft forms answer 404 and closed ones 410.

GET https://www.systemos.so/api/public/form/your-form

Use each step's id as the key in answers and everything lines up with the questions on the results page. This route is public and CORS-open too, so it works from the browser.

A form that ends in a booking

Our own forms can carry a booking step, and the call the lead takes is stitched onto their answers so the two are one record rather than a response and a booking nobody can tell belong together. Your form can do the same: book first, then send the confirmation along with the answers.

// 1. take the slot (see the Bookings API page)
const call = await fetch(bookUrl, { method: "POST", ... }).then((r) => r.json());

// 2. send the answers with it
await fetch(submitUrl, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    name, email, answers,
    booking: {
      provider: "native",
      when: call.when,
      meetLink: call.meetLink,
      bookingId: call.bookingId,
    },
  }),
});

The response then shows the booked call under the answers, exactly as one made on our own page does. Book first: a booking can fail on a slot that has just gone, and a lead whose answers you already kept is better than a confirmed call with nothing attached to it.

provider is always "native": calls are booked through SystemOS.

A submission through the API is a submission: it shows up in the activity feed, sends the email notification, and fires the webhook with q_complete, exactly like one made on our own page. Nothing downstream can tell the difference.