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-Key header, 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-for and user-agent, or all your visitors share one budget.
  • A form answers 404 FORM_UNAVAILABLE unless 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.

FieldType
namestringRequired.
emailstringOptional; must be a valid address when sent.
phone, subject, messagestringOptional.
turnstileTokenstringThe 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…"
  }'
Response (abridged)
{
  "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
GET /v1/forms/quote
{
  "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
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" }
  }'
Response
{
  "data": { "id": "cmf3n4q5r0020s601t6y7u8i9", "status": "accepted" },
  "warnings": [{ "code": "UNKNOWN_FIELDS_DROPPED", "keys": ["phone2"] }]
}
Body field
dataField key → value, matching the definition. Unknown keys are dropped and reported in warnings as UNKNOWN_FIELDS_DROPPED.
renderTokenFrom the definition, fetched when the form was shown.
turnstileTokenRequired when the form has Turnstile on (turnstileSiteKey is set).
referrer, utmOptional 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 same accepted answer, 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 Origin gets 403 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.

HTML
<!-- 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"
  }'
Slots
{
  "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" }
}
Booking (abridged)
{
  "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
contactname (2 to 120 characters, required), email, phone (6 to 40 characters).
appointmentTypeIdRequired.
modeonline or in_office. Required.
startsAtISO 8601, a slot’s startsAt. Required.
staffUserId, locationIdFrom the slot.
noteUp 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 requested until your team confirms them in the console.
  • A retry with the same Idempotency-Key returns 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.