Modules
Contact Form
One endpoint, one scope. Submissions land in the dashboard and notify your customer by email.
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
/api/contact-formcontact_form_submitStores a submission and triggers the notification email.
{
"name": "Noor van Elderen",
"email": "noor@example.com",
"message": "Do you cater for groups of twelve?",
"custom_fields": {
"company": "Kade 9",
"preferred_contact": "phone"
}
}| Field | Required | Notes |
|---|---|---|
name | yes | Non-empty once spaces around it are trimmed, and at most 255 characters after trimming. Stored trimmed. |
email | yes | A valid address once trimmed, at most 255 characters. It is what your customer replies to. |
message | no | The body of the enquiry, trimmed. Leave it out and the submission is stored with an empty message, not refused. No length limit. |
organization_id | no | Do not send it; your key decides. One that is not your key's own organization is refused with 403 ORGANIZATION_MISMATCH. |
custom_fields | no | Any extra fields your form collects, as a flat object. Stored with the submission and shown in the dashboard. No length limit. |
email_variables | no | Values injected into the notification template, overriding the organization defaults. |
honeypot | no | A 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.
| Code | When |
|---|---|
400 VALIDATION_ERROR | A 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_BODY | The body is not JSON. |
403 PERMISSION_DENIED | The key does not carry contact_form_submit, whether or not you send organization_id. |
403 ORGANIZATION_MISMATCH | The body names an organization that is not the key's own. |
400 SUBMISSION_REJECTED | Spam screening is on and the address or its domain is on the venue's block list. Deliberately without a reason. |
400 DAILY_LIMIT_REACHED | Spam screening is on and this address has sent the venue's daily maximum in the last 24 hours. |
400 CONTACT_SUBMISSION_INVALID | The body passed validation and the table refused a value anyway. Nothing was stored; sending it again will not help. |
500 CONTACT_SUBMISSION_FAILED | Storing 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.
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.
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.
/api/contact-formcontact_form_manageSubmissions 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.
/api/contact-form/{id}contact_form_manageChange 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.
/api/contact-form/{id}contact_form_manageDelete 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.
/api/contact-form/{id}/repliescontact_form_manageThe reply thread for one submission, oldest first.
/api/contact-form/{id}/repliescontact_form_manageSends a reply by email and records it. Also moves the submission to replied.
{
"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 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
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
honeypotfield 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 a400: branch oncode—DAILY_LIMIT_REACHEDmeans "call us",SUBMISSION_REJECTEDmeans "reach us another way" — and write the visitor's words yourself. The message is written for your logs.