Reference
Forms, contact and booking
Public endpoints that write into your inbox and calendar. They take no API key, so they are built to be called by visitors: captcha, idempotency and rate limits are part of the contract.
Before you start#
- No API key. Contact and booking are addressed by your workspace slug:
/v1/public/{orgSlug}/…. Forms are addressed by the form’s own key:/v1/forms/{key}/…. - Every write takes an
Idempotency-Keyheader, any unique string up to 200 characters. Reuse it when you retry the same submission and the API returns the first result instead of storing a second one. The SDK generates one for you. - Each write is limited per visitor IP (10 a minute and 60 an hour by default). If you post from your own server, forward the visitor’s
x-forwarded-foranduser-agent, or all your visitors share one budget. - A form answers
404 FORM_UNAVAILABLEunless it is live and the Forms module is on for the workspace.
The same routes also exist without /public/{orgSlug} (/v1/contacts, /v1/appointments, …). Those serve one site that predates workspaces; always use the slugged form.
Contact form#
POST/v1/public/{orgSlug}/contacts
Store an enquiry in the inbox and notify the team.
| Field | Type | |
|---|---|---|
name | string | Required. |
email | string | Optional; must be a valid address when sent. |
phone, subject, message | string | Optional. |
turnstileToken | string | The Cloudflare Turnstile token, when your workspace checks it. |
Any other field is refused with 400. The answer is 201 with the stored enquiry.
curl https://api.console.citronos.com/v1/public/your-org/contacts \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5b0d7c1e-8f3a-4c62-9d1b-2e7f6a4c9b10" \
-d '{
"name": "Ivan Petrov",
"email": "ivan@example.com",
"phone": "+359888000000",
"subject": "Quote",
"message": "A quote for BIO 6, please.",
"turnstileToken": "0.AbC…"
}'const enquiry = await citron.submitContact(
{ name: 'Ivan Petrov', email: 'ivan@example.com', subject: 'Quote', message: 'A quote for BIO 6, please.' },
{ turnstileToken, idempotencyKey, orgSlug: 'your-org' }, // idempotencyKey is generated when omitted
);
console.log(enquiry.id, enquiry.status);{
"data": {
"id": "cmf3m1c2d0010s601k4j3h2g1",
"organizationId": "cmf2x0r1k0000s6014h3j5k7l",
"name": "Ivan Petrov",
"email": "ivan@example.com",
"phone": "+359888000000",
"subject": "Quote",
"message": "A quote for BIO 6, please.",
"status": "new",
"source": "contact_form",
"consentAt": "2026-09-24T08:15:02.114Z",
"meta": { "idempotencyKey": "5b0d7c1e-8f3a-4c62-9d1b-2e7f6a4c9b10" },
"createdAt": "2026-09-24T08:15:02.120Z",
"updatedAt": "2026-09-24T08:15:02.120Z"
},
"meta": { "requestId": "0f6c1d2e-3b4a-4c5d-8e9f-a0b1c2d3e4f5" }
}Turnstile. Each workspace checks the token in one of three modes: off, monitor (checked and logged, never refused) or enforce (a missing or invalid token answers 400). If Cloudflare cannot be reached the enquiry is accepted, so an outage never loses a lead.
Retries. A retry with the same Idempotency-Key returns the enquiry stored the first time, and is answered before the captcha check, because Turnstile tokens are single-use.
Forms#
Forms built in the console’s Forms module have their own public key. Fetch the definition when the form is shown, render its fields, and send the values back with the render token.
GET/v1/forms/{key}
The form’s fields, messages, spam settings and a signed render token.
POST/v1/forms/{key}/submissions
Submit a form, as JSON (201) or as a plain HTML post (303).
# No API key: the form key is public
curl https://api.console.citronos.com/v1/forms/quote// When the form is SHOWN: fields to render, the render token, the Turnstile site key
const form = await citron.forms.get('quote');
// When it is sent
const result = await citron.forms.submit(
'quote',
{ name: 'Ivan Petrov', email: 'ivan@example.com', product: 'BIO 6' },
{ renderToken: form.renderToken, turnstileToken, utm: { utm_source: 'newsletter' } },
);
console.log(result.id, result.status, result.warnings);{
"data": {
"key": "quote",
"name": "Request a quote",
"fields": [
{ "key": "name", "type": "text", "label": "Name", "required": true },
{ "key": "email", "type": "email", "label": "Email", "required": true },
{ "key": "product", "type": "select", "label": "Product", "required": false, "options": ["BIO 6", "BIO 10"] },
{ "key": "message", "type": "longText", "label": "Message", "required": false }
],
"successMessage": "Thank you, we will be in touch.",
"redirectUrl": null,
"honeypotField": "_hp",
"renderTokenField": "_rt",
"renderToken": "Y21mM24wYS4xNzU4…",
"turnstileSiteKey": "0x4AAAAAAA…"
}
}curl https://api.console.citronos.com/v1/forms/quote/submissions \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9a4e2f10-6b7c-4d8e-a1f2-3c4d5e6f7a8b" \
-d '{
"data": { "name": "Ivan Petrov", "email": "ivan@example.com", "product": "BIO 6" },
"renderToken": "Y21mM24wYS4xNzU4…",
"turnstileToken": "0.AbC…",
"utm": { "utm_source": "newsletter" }
}'{
"data": { "id": "cmf3n4q5r0020s601t6y7u8i9", "status": "accepted" },
"warnings": [{ "code": "UNKNOWN_FIELDS_DROPPED", "keys": ["phone2"] }]
}| Body field | |
|---|---|
data | Field key → value, matching the definition. Unknown keys are dropped and reported in warnings as UNKNOWN_FIELDS_DROPPED. |
renderToken | From the definition, fetched when the form was shown. |
turnstileToken | Required when the form has Turnstile on (turnstileSiteKey is set). |
referrer, utm | Optional attribution; utm keeps utm_* keys only. |
Spam protection#
- A submission with a filled honeypot (
_hp), a missing or invalid render token, or one sent faster than the form’s minimum fill time (3 seconds by default) is kept as spam. It still gets the sameacceptedanswer, so bots learn nothing. - With Turnstile on, a missing or invalid token answers
400. - Per-form limits apply on top of the per-IP ones: 30 submissions per IP per hour and 500 per form per hour by default, adjustable per form. Over them:
429 RATE_LIMITED. - A form can list the websites allowed to post it; any other
Origingets403 ORIGIN_NOT_ALLOWED.
Without JavaScript#
A plain <form method="post"> works too. The API answers with a 303 redirect to the form’s redirect URL or to its own thank-you page, and shows errors as a small page.
<!-- No JavaScript needed: the API answers 303 to your redirect URL or its thank-you page -->
<form method="post" action="https://api.console.citronos.com/v1/forms/quote/submissions">
<input name="name" required>
<input name="email" type="email" required>
<!-- honeypot: keep it empty and hidden from people -->
<input name="_hp" tabindex="-1" autocomplete="off" hidden>
<!-- when the form has Turnstile on, the widget adds cf-turnstile-response -->
<div class="cf-turnstile" data-sitekey="0x4AAAAAAA…"></div>
<button>Send</button>
</form>Booking#
GET/v1/public/{orgSlug}/appointment-types
The appointment types visitors can book.
GET/v1/public/{orgSlug}/appointments/slots
Free slots for one type: typeId (required), mode, from, to, staffId, locationId.
POST/v1/public/{orgSlug}/appointments
Book a slot.
# 1. What can be booked
curl https://api.console.citronos.com/v1/public/your-org/appointment-types
# 2. Free slots for one type (from/to default to now and 30 days ahead)
curl "https://api.console.citronos.com/v1/public/your-org/appointments/slots?typeId=cmf3p1…&mode=online&from=2026-09-25T00:00:00.000Z&to=2026-10-09T00:00:00.000Z"
# 3. Book one of them
curl https://api.console.citronos.com/v1/public/your-org/appointments \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2c9f8e7d-6b5a-4d3c-9b2a-1f0e9d8c7b6a" \
-d '{
"contact": { "name": "Ivan Petrov", "email": "ivan@example.com", "phone": "+359888000000" },
"appointmentTypeId": "cmf3p1…",
"staffUserId": "cmf2y7…",
"mode": "online",
"startsAt": "2026-09-29T07:30:00.000Z",
"note": "About BIO 6"
}'const types = await citron.booking.types({ orgSlug: 'your-org' });
const slots = await citron.booking.slots(
{ typeId: types[0].id, mode: 'online', from: new Date(), to: new Date(Date.now() + 14 * 86_400_000) },
{ orgSlug: 'your-org' },
);
const appointment = await citron.booking.book(
{
contact: { name: 'Ivan Petrov', email: 'ivan@example.com', phone: '+359888000000' },
appointmentTypeId: types[0].id,
staffUserId: slots[0].staffUserId,
mode: 'online',
startsAt: slots[0].startsAt,
},
{ idempotencyKey, orgSlug: 'your-org' },
);
console.log(appointment.id, appointment.status);{
"data": [
{
"startsAt": "2026-09-29T07:30:00.000Z",
"endsAt": "2026-09-29T08:00:00.000Z",
"staffUserId": "cmf2y7…",
"staffName": "Maria Georgieva",
"mode": "online",
"label": "пн 29 септ. · 10:30"
}
],
"meta": { "requestId": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a" }
}{
"data": {
"id": "cmf3q2w3e0030s601r4t5y6u7",
"organizationId": "cmf2x0r1k0000s6014h3j5k7l",
"appointmentTypeId": "cmf3p1…",
"staffUserId": "cmf2y7…",
"contactId": "cmf3q2w3e0029s601a1s2d3f4",
"mode": "online",
"status": "requested",
"startsAt": "2026-09-29T07:30:00.000Z",
"endsAt": "2026-09-29T08:00:00.000Z",
"timezone": "Europe/Sofia",
"idempotencyKey": "2c9f8e7d-6b5a-4d3c-9b2a-1f0e9d8c7b6a",
"appointmentType": { "id": "cmf3p1…", "slug": "consultation", "nameBg": "Консултация", "nameEn": "Consultation", "durationMinutes": 30, "mode": "both", "active": true },
"contact": { "id": "cmf3q2w3e0029s601a1s2d3f4", "name": "Ivan Petrov", "email": "ivan@example.com", "phone": "+359888000000" },
"createdAt": "2026-09-24T08:20:11.504Z",
"updatedAt": "2026-09-24T08:20:11.504Z"
},
"meta": { "requestId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" }
}| Body field | |
|---|---|
contact | name (2 to 120 characters, required), email, phone (6 to 40 characters). |
appointmentTypeId | Required. |
mode | online or in_office. Required. |
startsAt | ISO 8601, a slot’s startsAt. Required. |
staffUserId, locationId | From the slot. |
note | Up to 1000 characters. |
- The body is validated strictly: an unknown field answers
400. There is no captcha field; the per-IP limit protects this endpoint. - The server checks the slot again. If someone else took it in the meantime the answer is
400; fetch the slots again. - New bookings start as
requesteduntil your team confirms them in the console. - A retry with the same
Idempotency-Keyreturns the appointment booked the first time.
This reference covers the public API only and follows the API’s compatibility contract: public responses only ever gain fields. The OpenAPI description of these routes is at /developers/openapi.json.