docs
/
Walkthroughs

Collect form submissions

Define a CRM form, open it to anonymous visitors, post to it from a page, and read the submissions back — including the permission that has to be set twice.

A CRM form is a named record that describes a set of fields and who is allowed to submit them. Once it exists, anyone can post JSON to it and the result lands in your CRM as a form_submission. No backend of your own is required.

This is the first tutorial where permissions decide whether the thing works, and they are easy to get half-right.

1. Define the form

Create a crm_form record. The name is the public identifier you will post to, so keep it URL-safe.

curl -s -X PUT "$APPMINT_HOST/repository/create" \
  -H "orgid: $APPMINT_ORG" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "datatype": "crm_form",
    "name": "contact-us",
    "data": {
      "name": "contact-us",
      "title": "Contact us",
      "description": "Enquiries from the website contact form.",
      "permission": {
        "create": ["Guest", "Customer", "User"],
        "read":   ["Guest", "Customer", "User"],
        "update": ["User"],
        "delete": ["User"]
      },
      "schema": {
        "type": "object",
        "required": ["name", "email", "message"],
        "properties": {
          "name":    { "type": "string", "title": "Your name" },
          "email":   { "type": "string", "title": "Email" },
          "company": { "type": "string", "title": "Business name" },
          "phone":   { "type": "string", "title": "Phone" },
          "topic":   { "type": "string", "title": "What is this about",
                       "enum": ["Product question", "Pricing", "Partnership", "Something else"] },
          "message": { "type": "string", "title": "Message" }
        }
      },
      "emails": ["[email protected]"]
    }
  }'
FieldWhat it does
nameThe public id. POST crm/form/submit/<name>
schemaJSON Schema describing the fields. Used to render and to label
permissionWho may create, read, update and delete submissions
emailsAddresses notified when a submission arrives

2. The permission that has to be set twice

permission.create is the obvious one — it is what lets an anonymous visitor submit. Set only that and the submission still fails, with this:

{ "error": "This form is not available for public access." }

That message is about the read permission, not create. Before accepting a submission the platform loads the form definition to validate against it, and that load is a read performed as the caller. An anonymous caller with no read grant cannot load the form, so the submission never gets as far as being created.

For a public form, Guest must appear in both permission.create and permission.read. Granting create alone produces an error message that points at the wrong permission and sends you looking in the wrong place.

read on the form grants access to the form definition — the fields and labels. It does not expose the submissions; those are a separate datatype with their own permissions, and Guest should never be on them.

Fixing it on an existing form:

s, rec = req("GET", f"/repository/get/crm_form/{SK}", token=t)
perm = rec["data"]["permission"]
perm["read"] = sorted(set(perm.get("read", []) + ["Guest"]))
req("POST", f"/repository/update-partial/crm_form/{SK}",
    {"sk": SK, "version": rec["version"], "data.permission": perm}, token=t)

3. Check it from outside

Test with no credentials at all — that is the case that matters:

curl -s "$APPMINT_HOST/crm/form/contact-us" -H "orgid: $APPMINT_ORG"
{
  "form": {
    "_id": "6aa7989cbecb632aecaf01bd",
    "datatype": "crm_form",
    "name": "contact-us",
    "data": { "name": "contact-us", "title": "Contact us", "…": "…" }
  }
}

A 200 here means an anonymous visitor can load the definition, which is the precondition for submitting.

4. Submit

curl -s -X POST "$APPMINT_HOST/crm/form/submit/contact-us" \
  -H "orgid: $APPMINT_ORG" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jane Operator",
    "email": "[email protected]",
    "company": "Two Doors Cafe",
    "topic": "Pricing",
    "message": "Two coffee shops, 14 staff. What does moving over look like?"
  }'

201, and the created record comes back:

{
  "pk": "your-org|form_submission",
  "sk": "6aa81adbbecb632aecaf0236",
  "name": "form_submission-1789401819154-jsz8i1hn",
  "datatype": "form_submission",
  "data": {
    "formId": "6aa7989cbecb632aecaf01bd",
    "values": {
      "name": "Jane Operator",
      "email": "[email protected]",
      "company": "Two Doors Cafe",
      "topic": "Pricing",
      "message": "Two coffee shops, 14 staff. What does moving over look like?"
    },
    "status": "new"
  }
}

The body is a flat object of field values — the endpoint wraps it into data.values for you. The route also accepts an optional email segment, POST crm/form/submit/:name/:email, for cases where the address is known separately from the payload.

No authentication header was sent. This endpoint is genuinely public when the permissions allow it, which is what makes it usable from a static page with no server of your own.

5. Wire it to a page

The whole client is a fetch. Nothing secret is involved, so this can live in the page:

form.addEventListener('submit', function (e) {
  e.preventDefault();

  var values = {};
  new FormData(form).forEach(function (v, k) { values[k] = v; });

  if (!values.name || !values.email || !values.message) {
    return show('err', 'Name, email and a message, please.');
  }

  if (form.dataset.sending) return;          // see the warning below
  form.dataset.sending = '1';
  button.disabled = true;

  fetch('https://appengine.appmint.io/crm/form/submit/contact-us', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', orgid: 'your-org' },
    body: JSON.stringify(values),
  })
    .then(function (r) { if (!r.ok) throw new Error('submit failed'); return r.json(); })
    .then(function () {
      form.reset();
      show('ok', 'Got it — we will come back to you at that address.');
    })
    .catch(function () {
      show('err', 'That did not go through. You can always email [email protected].');
    })
    .then(function () {
      delete form.dataset.sending;
      button.disabled = false;
    });
});

Guard against double submission at the form, not the button. Disabling the button is not enough — a second submit event can fire from the keyboard, and on a single-page host your handler can be bound twice if the page re-renders. The dataset.sending flag costs one line and is the difference between one record and two identical ones a minute apart.

If the page is AppMint-hosted, there is a second trap: inline <script> blocks do not execute. See Run JavaScript on a hosted page before you ship this.

6. Read submissions back

Submissions are ordinary records:

curl -s "$APPMINT_HOST/repository/find/form_submission?perPage=25" \
  -H "orgid: $APPMINT_ORG" -H "Authorization: Bearer $TOKEN"

Filter by form when you have more than one:

s, d = req("POST", "/repository/query/form_submission", {
    "where": [{ "field": "data.formId", "operator": "eq", "value": FORM_SK }],
    "perPage": 50,
}, token=t)

data.status starts at new, so a simple triage tool is an update away.

Notification emails are sent to data.emails on the form — but they are billed. An org with no credit shows You tried to use ses but don't have enough credits after a submission, the record is still written, and nobody is told about it. If submissions matter, poll the collection as well as relying on the email.

Checklist

  • GET crm/form/<name> returns 200 with no auth header
  • POST crm/form/submit/<name> returns 201 with no auth header
  • One submit produces exactly one form_submission
  • The org has credit if you are depending on the notification email

Next: Take bookings with reservations — where some endpoints are public and one crucial one is not.