CnterDocumentationSign in

Start here

  • What Cnter is
  • Core concepts
  • Quickstart

Platform

  • Authentication
  • Conventions
  • Errors
  • Organization
  • The dashboard

Modules

  • Content Management
  • Online Ordering
  • Restaurant Ordering
  • Promotions
  • Reservations
  • Bookings
  • Contact Form

Payments

  • AgnusPAY
  • Tax

Services

  • Address autocomplete
  • Video
  • Email

Reference

  • API reference

Modules

Contact Form

One endpoint, one scope. Submissions land in the dashboard and notify your customer by email.

A full brief for whoever builds against this.

The smallest module in the platform. You post a form, it is stored against the organization and your customer is emailed. There is no form builder and no embed script: you own the markup, which means you own the styling, the validation and the accessibility.

Submitting#

POST/api/contact-formcontact_form_submit

Stores a submission and triggers the notification email.

json
{
  "name": "Noor van Elderen",
  "email": "noor@example.com",
  "message": "Do you cater for groups of twelve?",
  "custom_fields": {
    "company": "Kade 9",
    "preferred_contact": "phone"
  }
}
FieldRequiredNotes
nameyesNon-empty once spaces around it are trimmed, and at most 255 characters after trimming. Stored trimmed.
emailyesA valid address once trimmed, at most 255 characters. It is what your customer replies to.
messagenoThe body of the enquiry, trimmed. Leave it out and the submission is stored with an empty message, not refused. No length limit.
organization_idnoDo not send it; your key decides. One that is not your key's own organization is refused with 403 ORGANIZATION_MISMATCH.
custom_fieldsnoAny extra fields your form collects, as a flat object. Stored with the submission and shown in the dashboard. No length limit.
email_variablesnoValues injected into the notification template, overriding the organization defaults.
honeypotnoA hidden field your form includes and a person never fills in; up to 200 characters. Only while the venue has spam screening on: a filled one is stored but held back — no notification email, and still a 201. With screening off it is ignored and the message is emailed as any other.

The scope is contact_form_submit and it grants exactly this. A key holding only that can do nothing else in the organization, which makes it the safest key in the platform to hand to a small site.

A 201 answers { "message": "Contact form submitted successfully", "submission_id": "…" } — a confirmation for you, not copy for the visitor. Keys the form does not know are not refused and not stored: the 201 then also carries ignored_fields, naming them in the order you sent them, and leaves it out when there are none. A phone number sent as phone rather than inside custom_fields is in that list, and did not arrive. Every field in the table above is read, so none of those is ever listed.

When it is refused

The body is checked before the key, so a malformed one is a 400 even from a call that carries none.

CodeWhen
400 VALIDATION_ERRORA field breaks the rules above. The message is always Validation failed; which field, and why, is in details — "Name is required", "Email must be at most 255 characters".
400 INVALID_BODYThe body is not JSON.
403 PERMISSION_DENIEDThe key does not carry contact_form_submit, whether or not you send organization_id.
403 ORGANIZATION_MISMATCHThe body names an organization that is not the key's own.
400 SUBMISSION_REJECTEDSpam screening is on and the address or its domain is on the venue's block list. Deliberately without a reason.
400 DAILY_LIMIT_REACHEDSpam screening is on and this address has sent the venue's daily maximum in the last 24 hours.
400 CONTACT_SUBMISSION_INVALIDThe body passed validation and the table refused a value anyway. Nothing was stored; sending it again will not help.
500 CONTACT_SUBMISSION_FAILEDStoring it failed on our side. Nothing was stored; it can be sent again.

The last two carry the same sentence, Failed to submit contact form: the status and the code are what tell them apart. While screening is on, the same message from the same address a second time is stored but held back, and no second email goes out — the visitor who pressed twice still gets a 201.

Your key decides the organization

A submission belongs to whoever sent it. Sending an organization_id that is not your key's own is refused with 403 ORGANIZATION_MISMATCH, and a key without contact_form_submit is refused with 403 PERMISSION_DENIED whether or not you send the field. If you run several organizations, use each one's own key.

Custom fields#

A contact form is never just name, email and message. Company size, budget, which branch, how they heard about you. Put those in custom_fields as a flat object and they are stored alongside the submission and displayed with it.

Use stable keys. They are what your customer sees in the dashboard, so preferred_contact reads better than field_3, and renaming one later leaves old submissions labelled with the old name.

What happens next#

The submission is stored, then the notification email is sent using your customer's template and recipients, configured under the module's settings. Templates can reference organization variables and anything you passed in email_variables.

Setting the module up is dashboard work for an owner or admin: switching it on, the two default templates, and who is notified. POST /api/contact-form/enable-module, POST /api/contact-form/setup-template and POST /api/contact-form/setup-notifications refuse every key, contact_form_manage included, with 403 DASHBOARD_ONLY "Contact form setup is only available from the dashboard", and nothing is written.

A failed email does not fail the submission

If the mail server is unreachable, the submission is still stored and the failure is logged. Losing an enquiry because a notification bounced would be the wrong trade. Your customer sees it in the dashboard either way.

Reading and managing submissions#

Everything else a key may do sits behind contact_form_manage, a separate scope from contact_form_submit. Keep them apart: the key in a public website should carry only the second one, so a leak cannot read a year of enquiries.

Not in the manifest, and not in @cnter/client

The endpoints below are for a back office, not a website, and the client does not name them. Call them by path, with a key carrying contact_form_manage, from a server of your own.

GET/api/contact-formcontact_form_manage

Submissions for the organization, paginated, newest first.

Each submission comes back with id, name, email, message, organization_id, status, created_at, updated_at, email_variables and custom_fields, on this list and from PATCH below alike. What the spam screening and the provenance record write — quarantined_at, spam_signals, source_channel, source_host and source_api_key_id — is the dashboard's, and a key gets it from neither.

A held-back message is not there for a key

A message the screening held back is stored, and its sender still got a 201, but a key does not see it. Not on this list, whose total and hasMore are counted without it, and not by id: PATCH, DELETE and both replies calls answer it with 404 NOT_FOUND "Contact submission not found", word for word what an id nobody has gets, and nothing is changed, deleted or sent. POST /api/contact-form/batch-update and POST /api/contact-form/batch-delete count its id as one that does not exist, in the 400 WRITE_INCOMPLETE they give when some ids were not found. The dashboard shows it, and staff decide what it was.

PATCH/api/contact-form/{id}contact_form_manage

Change the status: new, read, replied or archived.

It answers with the submission as it now stands. A body without status changes nothing and writes nothing, updated_at included, and answers with the submission as it is: that is how to read one by id.

DELETE/api/contact-form/{id}contact_form_manage

Delete a submission. Permanent, and it takes the replies with it.

Replying#

A submission is a conversation, not a record. Staff answer it from the dashboard, in the panel where they read it, and the reply is stored alongside so the next person can see it has been dealt with.

GET/api/contact-form/{id}/repliescontact_form_manage

The reply thread for one submission, oldest first.

POST/api/contact-form/{id}/repliescontact_form_manage

Sends a reply by email and records it. Also moves the submission to replied.

json
{
  "subject": "Re: Do you cater for groups of twelve?",
  "body": "We do. Twelve fits at the long table, and we ask for a set menu."
}

Each reply comes back with id, submission_id, organization_id, subject, body, from_email, from_name, to_email, status (sent or failed) and created_at, in the thread and from POST alike; POST adds submission_status_updated, whether the submission was moved to replied. Who on the staff sent a reply (sent_by) and what the mail server said when it refused one (error) are the dashboard's, and a key gets them from neither.

The message leaves from the platform's sending domain with the organization's name on it, so the recipient sees the business they wrote to rather than software they have never heard of. Their answer goes to the address the organization already receives contact notifications on, which keeps the conversation with the business.

A failed send is still recorded

If the mail server refuses it, the reply is stored with status failed and the error, and the endpoint answers 502 REPLY_SEND_FAILED. The alternative is a message that silently never arrived and no trace of a customer who was never answered. The mail server's own words stay in the dashboard: a key's 502 says "The reply could not be sent", and the stored reply comes back to it as failed without the reason.

A submission without an email address has nobody to answer: 422 NO_REPLY_ADDRESS "This submission has no email address to reply to", and nothing is sent, stored or marked replied.

The text is wrapped in a designed HTML email: the organization's name and colours at the top, the reply, the original message quoted underneath, and a quiet "Powered by Cnter" at the foot. A plain text alternative is sent alongside for clients that prefer it. You supply the words, not the layout.

Building the form#

A route handler on your own server
import { createCnterClient, CnterError } from '@cnter/client/server'

export async function POST(request: Request) {
  const form = await request.formData()

  const cnter = await createCnterClient({
    baseUrl: process.env.CNTER_BASE_URL!,
    organizationId: process.env.CNTER_ORG_ID!,
    apiKey: process.env.CNTER_API_KEY!,
  }).ready()

  try {
    // No path and no organization_id: the client reads the address from the
    // manifest, and the organization comes from the key that sent this.
    await cnter.contactForm.submit({}, {
      name: form.get('name'),
      email: form.get('email'),
      message: form.get('message'),
      custom_fields: { company: form.get('company') },
    })
    return Response.json({ ok: true })
  } catch (error) {
    // Whatever happens, the browser keeps what the visitor typed. The words
    // below are yours: the message CNTER sends is for your logs, never for them.
    if (!(error instanceof CnterError)) {
      return Response.json({ ok: false, error: 'try_again' }, { status: 502 })
    }

    const { status, code, details } = error // details needs @cnter/client 1.0.4

    // A refusal of this one message: tell the visitor what to do about it.
    if (status === 400 && code === 'VALIDATION_ERROR') {
      // details[].path names the fields. Put your own sentence next to each.
      const fields = Array.isArray(details) ? details.map((d) => d.path?.[0]) : []
      return Response.json({ ok: false, error: 'invalid', fields }, { status: 400 })
    }
    if (status === 400 && code === 'DAILY_LIMIT_REACHED') {
      return Response.json({ ok: false, error: 'call_us' }, { status: 429 })
    }
    if (status === 400 && code === 'SUBMISSION_REJECTED') {
      return Response.json({ ok: false, error: 'reach_us_another_way' }, { status: 422 })
    }

    // 401 and 403 are your configuration, not the visitor's problem: a wrong or
    // expired key, or a key without contact_form_submit. Log it where you will
    // see it, show a neutral message, and answer 502 to your own browser.
    if (status === 401 || status === 403) {
      console.error('CNTER contact form is misconfigured', { status, code })
      return Response.json({ ok: false, error: 'try_again' }, { status: 502 })
    }

    // 5xx, a timeout, no connection: the visitor can try again in a moment.
    const timedOut = error.reason === 'timeout'
    return Response.json(
      { ok: false, error: 'try_again' },
      { status: timedOut ? 504 : 502 },
    )
  }
}

The point of the three branches is that a site can tell a message that was refused from a message that was lost. Do not turn every failure into the same 502: a visitor whose email address was rejected needs to fix a field, a visitor who hit the daily limit needs your phone number, and only the third kind — the key, the network, our side — is a reason to say "try again". An unknown code on a 400 belongs with the last kind, with a neutral message.

  • /Label above the input, error below it. Never a placeholder standing in for a label.
  • /Keep the typed values on failure. A cleared form after a network error loses the enquiry, not just the request.
  • /Validate the email on your side too, so an obvious typo is caught before a round trip.
  • /Add a hidden honeypot field and post whatever lands in it. A person never sees it, so anything in there came from a script.
  • /Screening runs on our side too, once the venue switches it on under Settings then Spam & screening. A held-back message is stored but not emailed, and still answers 201. A refusal is a 400: branch on code — DAILY_LIMIT_REACHED means "call us", SUBMISSION_REJECTED means "reach us another way" — and write the visitor's words yourself. The message is written for your logs.
PreviousBookingsNextAgnusPAY

On this page

  • Submitting
  • Custom fields
  • What happens next
  • Reading and managing submissions
  • Replying
  • Building the form