Submify v1 API Contract
Base path: /api/v1 (except the public submit endpoint, which is /api/submit)
Full URL example: https://your-host:2512/api/v1 (behind Nginx in Docker)
All JSON bodies use Content-Type: application/json unless noted. Error responses are { "error": "..." }.
One organization per instance: POST /auth/register only succeeds while no account exists yet (GET /system/bootstrap-status returns "setup_required": true) — that first registration creates both the account and its organization. Once any account exists, registration closes — POST /auth/register returns 403 and further members join the same organization via POST /users (admin-only) instead of registering themselves. See Organization & members.
Rate limiting (all return 429 with a JSON error string):
| Scope | Default (env) | Notes |
|---|---|---|
GET /system/bootstrap-status, GET /system/health | unlimited | Monitoring / first-load friendly |
POST /auth/register, /auth/login, /auth/refresh, /auth/logout, /system/setup | 25 RPM / IP (RATE_LIMIT_SENSITIVE_PUBLIC_RPM) | Slows brute force |
POST /api/submit | 90 RPM / IP and 180 RPM / API key (RATE_LIMIT_SUBMIT_IP_RPM, RATE_LIMIT_SUBMIT_KEY_RPM) | |
| Authenticated routes (Bearer) | 600 RPM / user id (RATE_LIMIT_AUTH_USER_RPM) | Dashboard users not capped like anonymous |
CORS: Allowed origins come from ALLOWED_ORIGINS (comma-separated). Browser requests from another origin must list that origin here. Per-project allowed_origins further restrict which origins may call /api/submit with that project's key.
Public (no Bearer token)
GET /system/bootstrap-status
Response: 200 { "setup_required": true | false }
POST /auth/register
Creates the one and only account for this instance. Fails with 403 once an account already exists.
Body:
| Field | Type | Required |
|---|---|---|
full_name | string | yes |
phone | string | yes |
email | string (email) | yes |
password | string (min 8 chars) | yes |
Response: 201
{ "access_token": "...", "refresh_token": "...", "api_key": "...", "email": "...", "full_name": "...", "phone": "..." }
Errors: 409 email already registered, 403 registration closed (an account already exists).
POST /system/setup
Retired. Always returns 410 pointing to POST /auth/register.
GET /system/health
Liveness/deps check.
Response: 200 { "status": "ok", "db": "up" } — or 503 { "status": "degraded", "db": "down" } if the DB ping fails.
POST /auth/login
Body: { "email": "...", "password": "..." }
Response: 200
{ "access_token": "...", "refresh_token": "...", "api_key": "...", "full_name": "...", "phone": "..." }
api_key is the account's primary form ingest key (embed on websites).
Errors: 401 invalid credentials.
POST /auth/refresh
Body: { "refresh_token": "..." } (or send the refresh token via x-refresh-token header / __Host-submify_rt cookie — the body is optional if one of those is present)
Response: 200 { "access_token": "...", "refresh_token": "...", "api_key": "...", "full_name": "...", "phone": "..." }
Errors: 400 missing refresh token, 401 invalid/expired/revoked refresh token.
POST /auth/logout
Stateless logout (always 200): { "status": "logged out" }. Revokes the refresh session if a valid refresh token is supplied.
POST /api/submit
Public form endpoint — not under /api/v1.
Headers:
Content-Type: application/jsonx-api-key: <project public key>— required. Either the account's primaryapi_key(submissions land in the default project) or a specific project'spublic_api_key(submissions land in that project).x-signature(optional) — HMAC-SHA256 of the raw body, hex-encoded, signed with the project's secret key. Send this only from a server you trust; never embed the secret key in browser code. A valid signature also bypasses theallowed_originscheck.
Body (either shape):
Recommended:
{ "data": { "field1": "value" }, "files": [] }
Alternative: a flat JSON object is stored directly as data:
{ "name": "...", "email": "..." }
Response: 201 — created submission:
{ "id": "uuid", "project_id": "uuid", "data": {}, "files": [], "created_at": "RFC3339" }
Errors: 401 missing/invalid/unknown x-api-key or bad x-signature, 403 origin not allowed for this project, 400 empty body or bad JSON, 413 payload exceeds SUBMIT_MAX_BODY_BYTES, 429 project at the 5,000-submission cap.
Authenticated (Authorization: Bearer <access_token>, or session cookies)
Requires a completed setup. Without any account, these routes return 503 { "error": "no accounts yet; register first" }.
GET /auth/me
Response: 200
{
"email": "...", "api_key": "...", "full_name": "...", "phone": "...",
"is_admin": true, "organization_id": "uuid", "organization_role": "owner",
"telegram_chat_id": "...", "s3_endpoint": "...", "s3_bucket": "...",
"telegram_configured": true, "s3_configured": false
}
organization_role is one of owner, admin, manager, member, viewer — see
Organization & members below.
GET /dashboard/summary
Response: 200 { "latest_submission": { "at": "RFC3339", "project_id": "uuid", "project_name": "..." } | null }
GET /projects
Response: 200 { "projects": [ Project, ... ] }
Project: id, user_id (creator, informational only), organization_id, name, is_default, api_key, api_secret, allowed_origins, telegram_chat_id, telegram_configured, s3_endpoint, s3_bucket, s3_configured, created_at
Projects belong to the caller's organization, not to the individual user who created them — every member of the organization sees the same project list. See Organization & members.
POST /projects
Body: { "name": "Project name" }
Response: 201 — full Project object (includes new key pair).
PATCH /projects/{id}
Body (all fields optional — only send what you want to change):
| Field | Effect |
|---|---|
name | rename |
regenerate_key | true issues a new public/secret key pair; old keys stop working immediately |
allowed_origins | replace the array of allowed browser origins for /api/submit (empty array = no restriction) |
telegram_bot_token, telegram_chat_id | set per-project Telegram notification target |
s3_endpoint, s3_access_key, s3_secret_key, s3_bucket | set per-project storage credentials (takes priority over account-level settings) |
zulivio_enabled, zulivio_api_url, zulivio_api_key | push new submissions to a Zulivio instance as leads — see Zulivio integration |
email_notifications_enabled, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from_email, notification_recipients | email each new submission through your own SMTP account — see Email notifications |
Response: 200 { "status": "updated", "project": Project }
DELETE /projects/{id}
Deletes a project and all of its submissions. Irreversible. The organization's default project cannot be deleted (400).
Response: 200 { "status": "deleted" }
GET /projects/{id}/submissions
Query: limit (default 50, max 500), offset (default 0)
Response: 200 { "submissions": [ Submission, ... ], "limit": 50, "offset": 0 }
DELETE /projects/{id}/submissions/bulk
Body: { "submission_ids": ["uuid", "..."] }
Response: 200 { "deleted": <number> }
POST /uploads/presign
Uses S3-compatible storage if configured (project-level, falling back to
account-level); otherwise automatically falls back to local disk — there is
no separate storage-mode setting, uploads just work either way. See
docs/decisions/0003-local-storage-fallback.md.
Body:
| Field | Type |
|---|---|
project_id | string (UUID) |
filename | string |
content_type | string (must be in ALLOWED_MIME_TYPES) |
size | int64 bytes (≤ UPLOAD_MAX_SIZE_BYTES) |
Response (S3 backend): 200 { "upload_url": "https://...", "object_key": "...", "expires_at": "RFC3339" } —
PUT the file bytes directly to upload_url.
Response (local backend, when no S3 credentials are configured): 200
{ "backend": "local", "upload_url": "https://this-instance/api/v1/uploads/local/<token>", "download_url": "https://this-instance/api/v1/uploads/local/<object_key>", "object_key": "...", "expires_at": "RFC3339" } —
PUT the file bytes to upload_url (a one-time token, consumed on first use); the
resulting file is then servable from download_url (public — same trust model as
an S3 object's URL being the access control).
Errors: 400 file too large / MIME not allowed, 404 project not found.
PUT /uploads/local/{token}
Local-storage upload target returned by POST /uploads/presign above. The
request body is the raw file bytes. Single-use — the token is invalidated after
one successful (or failed) attempt. Public, unauthenticated by design (the token
is the authorization, same as a presigned S3 URL's signature).
Response: 200 { "status": "stored", "object_key": "..." }
Errors: 404 token not found/expired/already used, 400 upload exceeds the
size committed to at presign time.
GET /uploads/local/{key}
Serves a previously uploaded local file back. Public, unauthenticated by design —
see docs/decisions/0003-local-storage-fallback.md for why this matches the
existing S3 trust model rather than introducing a stricter one only for local
storage.
Response: 200 — file bytes, with a best-effort Content-Type guessed from
the file extension.
GET /projects/{id}/export
Query: format — xlsx (default) or pdf
Response: file download (Content-Disposition: attachment), capped at the first 5,000 submissions.
PUT /users/me/integrations
Update account-level integrations (legacy fallback used when a project has no S3/Telegram settings of its own).
Body (partial): telegram_bot_token, telegram_chat_id, s3_endpoint, s3_access_key, s3_secret_key, s3_bucket
Response: 200 { "status": "updated" }
PUT /users/me/password
Body: { "current_password": "...", "new_password": "..." } (min 8 chars)
Response: 200 { "status": "password updated" }
Errors: 401 current password incorrect.
POST /users/me/api-key/rotate
Rotates the account's primary api_key. Old key stops working immediately.
Response: 200 { "status": "rotated", "api_key": "..." }
POST /users/me/projects/rotate-keys
Rotates the public/secret key pair for every project in the caller's organization (not just ones the caller personally created).
Response: 200 { "status": "rotated", "projects_rotated": <number> }
Organization & members
An organization is the unit of shared access: every member sees the same projects
and submissions. Registration is closed once any account exists on the instance
(POST /auth/register → 403 after the first account) — additional accounts join
the same organization via the invite endpoints below; there is currently no way
to create a second, independent organization on one instance. Roles: owner (exactly
one per organization, set at registration, cannot be removed or reassigned via these
endpoints), admin, manager, member, viewer (assignable on invite).
These three endpoints require the caller to have is_admin = true (currently the
instance-wide flag set on the account that ran /auth/register, independent of
organization role — see docs/roadmap/00-MASTER-PLAN.md for the follow-up to make
this role-aware instead).
GET /users
Lists every member of the caller's organization.
Response: 200 { "users": [ { "id", "email", "full_name", "role", "created_at" }, ... ] }
POST /users
Invites a new member into the caller's organization. Does not create a project — the new member gets access to the organization's existing projects immediately.
Body: full_name, phone, email, password (min 8 chars), role (optional,
default member; one of admin, manager, member, viewer — owner is not
assignable here)
Response: 201 { "id", "email", "full_name", "phone", "role" }
Errors: 400 invalid role, 409 email already registered.
DELETE /users/{id}
Removes a member's account from the caller's organization. Cannot target the caller's
own account (400), cannot target the organization's owner (400), and cannot
target a user outside the caller's organization (404).
Response: 200 { "status": "deleted" }
Client portal (per-project, read-only)
Each project can expose a public, read-only portal at https://<host>/<portal_slug> so a
client can view and export that project's submissions, and view (not manage) that
project's own calendar bookings in a real month/week/day calendar — nothing else, and
no write access to either. Every project has its own separate calendar (event types and
bookings are scoped by project_id), so a portal visitor sees only their project's
bookings, never another project's. A project's portal is created with an auto-generated password
(shown once on project creation). The account owner can change the slug, (re)generate the
password, and enable/disable the portal from Projects in the dashboard, then share the
URL + password with the client.
Portal sessions use a project-scoped portal token stored in an HttpOnly cookie
(submify_portal_token, path /api/v1/portal). They carry no account identity and can only
reach the endpoints below. A signed-in account owner opening their own project's portal is
let in without the portal password.
GET /portal/lookup?slug=<slug>
Public. Reports whether an accessible portal exists at <slug>.
Response: 200 { "exists": true, "project_name": "...", "owner_access": <bool>, "password_required": <bool> }
or 404 when there is no accessible portal.
POST /portal/login
Public (rate-limited). Body: { "slug": "...", "password": "..." }. Grants a portal session
via the correct portal password, or via the owner's dashboard session (password ignored).
Response: 200 { "ok": true, "project_name": "...", "portal_slug": "...", "token": "..." }
(also sets the HttpOnly portal cookie). Errors: 401 incorrect password, 403 portal not
available, 404 unknown slug.
POST /portal/logout
Clears the portal cookie.
GET /portal/info
Portal session required. Response: 200 { "project_name": "...", "submission_count": <n> }
GET /portal/submissions?limit=&offset=
Portal session required. Same shape as GET /projects/{id}/submissions, scoped to the
session's project.
GET /portal/export?format=xlsx|pdf
Portal session required. File download of the project's submissions (first 5,000 rows).
GET /portal/event-types
Portal session required. Read-only, minimized event types for the portal session's own
project (see docs/decisions/0010-portal-calendar-read-only.md for the read-only
scope decision and docs/decisions/0011-project-scoped-calendar.md for the project
isolation). Response: 200
{ "event_types": [{ "id", "title", "description", "duration_minutes", "location", "timezone", "is_active" }] }
— no host_user_id or scheduling-policy internals.
GET /portal/bookings?from=&to=
Portal session required. Read-only bookings for the portal session's own project, RFC3339
from/to (default: now to +30 days), same date-range contract as GET /bookings.
Deliberately excludes manage_token, attendee_email, and notes — a portal visitor can see
that a slot is booked and by whom (name only), not reschedule/cancel it or see the other
attendee's contact details. Response: 200
{ "bookings": [{ "id", "event_type_id", "event_type_title", "location", "starts_at", "ends_at", "attendee_name", "status", "created_at", "cancelled_at" }], "from", "to" }
Project portal fields (owner endpoints)
POST /projects now also returns the new project's portal_slug, portal_enabled,
portal_password_set, and a one-time portal_password.
PATCH /projects/{id} additionally accepts:
portal_slug— set a custom URL slug (lowercase letters, numbers, hyphens; not a reserved word).portal_enabled— enable/disable the portal.regenerate_portal_password(true) — generate a new random password; returned once asportal_password.portal_password— set an explicit password (min 8 chars; returned once asportal_password), or""to clear it.
Backup & restore
POST /system/backup
Admin-only. Streams a full-instance backup as a .zip — see
docs/decisions/0004-backup-format-pure-go-json-dump.md for exactly what's
included (all organizations/users/projects/submissions/calendar data,
local-storage uploads) and excluded (S3-stored files, session tokens,
migration history).
Response: 200 — file download (Content-Disposition: attachment).
POST /system/restore
Unauthenticated — self-guards on the instance having zero existing accounts
(same reachability as POST /auth/register, for the same reason: there's no
admin session to authenticate as on a fresh install). Restoring over an
already-active installation is not supported by this endpoint.
Body: multipart/form-data, field backup = a .zip produced by
POST /system/backup.
Response: 200 { "status": "restored", "tables": { "<table>": <row count>, ... }, "files_restored": <number>, "file_warnings"?: [...] }
Errors: 403 an account already exists on this instance, 400 invalid/
corrupted backup (missing manifest, unsupported backupVersion, or a
checksum mismatch — the backup is rejected before anything is written).
POST /system/restore/active
Admin-only. Restores a local uploaded .zip over an already-active
install — unlike POST /system/restore above, this permanently replaces
all current data. Always takes an automatic pre-restore safety backup
(written to SAFETY_BACKUP_DIR on local disk) before the destructive
write. See docs/decisions/0009-s3-backup-and-self-update.md.
Body: multipart/form-data, fields backup (the .zip) and confirm
(must be exactly RESTORE, or the request is rejected with 400 before
anything happens).
Response: 200 — same shape as POST /system/restore, plus
"safety_backup": "<path>".
PUT /system/backup/s3-config
Admin-only. Sets the instance-wide S3-compatible backup destination (separate from any project's own S3 upload-storage credentials).
Body: { "endpoint": "...", "access_key": "...", "secret_key": "...", "bucket": "..." }
POST /system/backup/s3
Admin-only. Builds a backup archive (same format as POST /system/backup)
and uploads it to the configured S3 destination under backups/.
Response: 200 { "status": "uploaded", "key": "backups/submify-backup-<ts>.zip", "size": <bytes> }
GET /system/backup/s3
Admin-only. Lists available S3 restore points, newest first. Pass
?latest=1 to get just the newest one directly (404 if none exist).
Response: 200 { "backups": [{ "key", "size", "last_modified" }, ...] } (or { "backup": {...} } with ?latest=1).
POST /system/backup/s3/restore
Admin-only. Downloads the given key from the configured S3 destination and
restores it over the active install — same typed-confirmation and
automatic-safety-backup guarantees as POST /system/restore/active.
Body: { "key": "backups/...", "confirm": "RESTORE" }
Response: 200 — same shape as POST /system/restore/active, plus "restored_from": "<key>".
GET /system/update/check
Admin-only. Compares this build's own version against the latest release/
tag on SUBMIFY_UPDATE_REPO and persists the result.
Response: 200 { "current_version", "latest_version", "update_available" }
POST /system/update/apply
Admin-only. Triggers a real self-update: pulls the latest code and rebuilds/
restarts the stack via a separate helper container (see
docs/decisions/0009-s3-backup-and-self-update.md for why it can't just run
the update inline). Responds immediately, before the update finishes — poll
GET /api/v1/system/health afterward to detect when the instance is back.
Response: 202 { "status": "update started" }
Calendar & booking
Every project has its own, separate calendar. An event type defines
a bookable service (duration, weekly hours, buffers, notice window) owned
by one host and scoped to one project in the caller's organization. Each
has a public, unauthenticated booking flow at its id. See
docs/decisions/0011-project-scoped-calendar.md.
POST /event-types
Body: project_id (required — which project's calendar this event
type belongs to), slug, title, description, duration_minutes,
location, timezone (IANA name), buffer_before_minutes,
buffer_after_minutes, min_notice_minutes, max_advance_days (default
60), slot_interval_minutes (default 15), rules: [{ weekday: 0-6, start_minute, end_minute }] (weekly recurring availability, weekday
0=Sunday). slug only needs to be unique within the project, not the
whole organization — two different projects can each have their own
"consultation" event type.
Response: 201 { "event_type": EventType, "rules": [...] }, or 400
if project_id is missing, 404 if it doesn't belong to your organization.
GET /event-types?project_id= / GET /event-types/{id} / DELETE /event-types/{id}
List (requires project_id — one project's event types), fetch (with
rules and date overrides, by id), or delete (by id) an event type —
all organization-scoped; list is additionally project-scoped.
PUT /event-types/{id}/overrides
Sets or clears a date-specific override (blocks a date entirely, or gives it custom hours instead of the weekly rule).
Body: { "date": "YYYY-MM-DD", "blocked": true } or
{ "date": "YYYY-MM-DD", "blocked": false, "start_minute": ..., "end_minute": ... }
GET /bookings?project_id=
One project's bookings. Query: project_id (required), from, to
(RFC3339, default: now to +30 days).
POST /bookings/{id}/cancel
Cancels a booking on behalf of the organization (as opposed to the attendee cancelling via their manage link, below).
Personal calendar items
A personal event is the logged-in user's own private agenda item — a task, a timed event, or a reminder — distinct from event types/bookings above (which model external attendees booking a slot). Every route is scoped to both the caller's organization and their own user ID: one user can never see or edit another user's personal items, even within the same organization.
GET /calendar/items?project_id=
Query (all required): project_id, from, to (RFC3339) — items
whose range overlaps [from, to), within that one project's calendar.
Response: 200 { "items": [PersonalEvent, ...] }
POST /calendar/items
Body: project_id (required — which project's calendar this item
belongs to), title (required), description, kind (event | task |
reminder, default event), starts_at (RFC3339, required), ends_at
(RFC3339, optional — omit for a point-in-time task/reminder), all_day,
color, remind_at (RFC3339, optional — when set, a Telegram notification
fires at that time via the caller's own Telegram bot token/chat ID, same
mechanism as booking reminders; silently does nothing if Telegram isn't
configured).
Response: 201 { "item": PersonalEvent }
PATCH /calendar/items/{id}
Partial update — every field is optional and a missing key leaves that
field unchanged. Exception: ends_at/remind_at are nullable
server-side; send an explicit empty string "" to clear one, not JSON
null (the server can't distinguish an absent key from an explicit
null).
Response: 200 { "item": PersonalEvent }, or 404 if the item
doesn't exist or doesn't belong to the caller.
DELETE /calendar/items/{id}
Response: 200 { "status": "deleted" }, or 404.
CORS: every /public/* route below allows any browser Origin by
default (CORS_PUBLIC_BOOKING_ANY_ORIGIN=true), the same trust model as
POST /api/submit — no cookies are involved, and the unguessable event-type
ID / booking manage_token is the access control. This means an external
website can call these directly via fetch() to build its own embedded
booking widget, not just link to /book/{id} as a plain page. Set
CORS_PUBLIC_BOOKING_ANY_ORIGIN=false to require origins to be explicitly
allowlisted instead (see ALLOWED_ORIGINS).
Connecting calendar booking to your website
No API key is involved. Unlike POST /api/submit (which needs
x-api-key), every /public/event-types/* and /public/bookings/* route
below is authorized purely by the event-type ID (or the booking's
manage_token) being unguessable — the identical trust model as this
project's client-portal links and presigned upload URLs. Don't look for an
API key in the Calendar settings for this; there isn't one to find, by
design.
1. Get the event-type ID. In the dashboard: Calendar, pick the
right project from the Calendar dropdown (every project has its own
calendar), then Event Types → Copy Link on the event type you want to
embed. That copies
https://<your-submify-host>/book/<event-type-id> — the ID is the last URL
segment. You'll use <event-type-id> in both integration options below.
2. Pick an integration style:
-
Simplest — link or
<iframe>to the hosted booking page. No code beyond a link/embed. Submify already renders a complete booking flow at/book/{id}.<a href="https://your-submify-host.com/book/<event-type-id>" target="_blank"> Book a time </a> <!-- or embed it in place --> <iframe src="https://your-submify-host.com/book/<event-type-id>" style="width:100%;height:700px;border:0" title="Book a time" ></iframe> -
Custom widget — call the public API directly. Build your own UI (matching your site's design) against the three endpoints documented below. Minimal end-to-end example:
const SUBMIFY_HOST = 'https://your-submify-host.com'; const EVENT_TYPE_ID = '<event-type-id>'; // 1. Load event type details (title, duration, location, timezone) const eventType = await fetch( `${SUBMIFY_HOST}/api/v1/public/event-types/${EVENT_TYPE_ID}` ).then((r) => r.json()); // 2. Load open slots and render them as buttons/a picker const { slots } = await fetch( `${SUBMIFY_HOST}/api/v1/public/event-types/${EVENT_TYPE_ID}/slots` ).then((r) => r.json()); // 3. When the visitor picks a slot and submits the form, create the booking async function bookSlot(slot, attendee) { const res = await fetch( `${SUBMIFY_HOST}/api/v1/public/event-types/${EVENT_TYPE_ID}/bookings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ starts_at: slot.start, attendee_name: attendee.name, attendee_email: attendee.email, attendee_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }), } ); if (res.status === 409) { // Someone else just took this slot — reload slots and ask the visitor to pick again. throw new Error('That slot was just booked. Please choose another.'); } if (!res.ok) throw new Error('Booking failed.'); const { booking, manage_url } = await res.json(); // Show `manage_url` to the visitor so they can reschedule/cancel later — // it's also emailed to them via `booking.manage_token`'s reschedule/cancel flow. return booking; }This works cross-origin from any website out of the box (
CORS_PUBLIC_BOOKING_ANY_ORIGIN=trueby default on the Submify instance) — no server-side proxy or secret needed, since nothing here is a secret. If the instance has been locked down withCORS_PUBLIC_BOOKING_ANY_ORIGIN=false, ask whoever administers that instance to add your site's origin toALLOWED_ORIGINS. -
AI coding agent — paste this prompt. Fill in the two placeholders (your Submify host and event-type ID, both from step 1 above) and hand the whole block to Claude Code, Cursor, or any other coding agent working in your website's repo. It has the full API contract inline, so the agent doesn't need internet access to this doc to build a correct integration.
Add a "book a call" flow to this website using Submify's public booking API (no API key — the event-type ID itself is the access control). SUBMIFY_HOST = "https://<your-submify-host>" EVENT_TYPE_ID = "<event-type-id>" API contract (all under SUBMIFY_HOST/api/v1/public, no auth headers): - GET /event-types/{EVENT_TYPE_ID} -> { id, title, description, duration_minutes, location, timezone } - GET /event-types/{EVENT_TYPE_ID}/slots -> { slots: [{ start: RFC3339, end: RFC3339 }, ...], timezone } Only returns genuinely open slots (weekly hours, buffers, minimum notice, and existing bookings already applied) — don't re-derive availability client-side, just render what's returned. - POST /event-types/{EVENT_TYPE_ID}/bookings body: { starts_at: RFC3339 (one of the returned slots), attendee_name, attendee_email, attendee_timezone (optional, e.g. Intl.DateTimeFormat().resolvedOptions().timeZone), notes (optional) } -> 201 { booking: {..., manage_token}, manage_url } -> 409 if the slot was just taken by someone else (real double-booking prevention at the DB level, not just a validation nicety) — on 409, re-fetch slots and ask the visitor to pick again, don't just show a generic error. - GET /bookings/{manage_token}, POST /bookings/{manage_token}/reschedule ({ starts_at }), POST /bookings/{manage_token}/cancel — let a visitor manage their own booking later via manage_url, no login needed. - GET /bookings/{manage_token}/ics — downloads a calendar file for the booking; link this as "Add to calendar" after a successful booking. Requirements: - Match this site's existing design system (colors, spacing, fonts, button/card styles) — don't introduce a visually generic/bolted-on widget. - Show duration and location/timezone from the event-type response, not hardcoded values. - Group slots by day for readability if there are more than ~15 (a full multi-day flat list is overwhelming); a simple day-tabs or day-grouped layout is enough, doesn't need to be fancy. - Handle the 409 conflict case explicitly (see above) — don't let it show as an unexplained failure. - After a successful booking, show the confirmed time (in the visitor's own timezone), the "Add to calendar" link, and the manage_url for reschedule/cancel — don't just show a bare "success" message. - This calls SUBMIFY_HOST directly from the browser (CORS is already open for this) — no server-side proxy route needed unless this codebase's conventions require one for other outbound calls.
GET /public/event-types/{id}
Public booking-page info: id, title, description, duration_minutes,
location, timezone. No authentication.
GET /public/event-types/{id}/slots
Available slots from now through max_advance_days, respecting weekly
rules, date overrides, buffers, minimum notice, and existing bookings.
Response: 200 { "slots": [{ "start": "RFC3339", "end": "RFC3339" }, ...], "timezone": "..." }
POST /public/event-types/{id}/bookings
Creates a booking. Body: starts_at (RFC3339, must be one of the
offered slots), attendee_name, attendee_email, attendee_timezone
(optional), notes (optional).
Response: 201 { "booking": Booking, "manage_url": "..." } — booking.manage_token
is an unguessable token for the reschedule/cancel endpoints below; never a
predictable ID.
Errors: 409 the slot was just taken (double-booking is prevented at
the database level, so this can happen under real concurrent requests, not
just as a validation nicety).
GET /public/bookings/{token} / POST /public/bookings/{token}/reschedule / POST /public/bookings/{token}/cancel
View, reschedule ({ "starts_at": "RFC3339" }), or cancel a booking via its
manage token. No authentication — the token itself is the access control,
the same trust model as the client portal and presigned uploads.
GET /public/bookings/{token}/ics
Downloads a standards-compliant .ics calendar file for the booking.
Response: 200 — text/calendar, Content-Disposition: attachment.
Software version
GET /system/version
Not yet implemented for Submify itself (internal/update exists in the
codebase but isn't wired up to an endpoint — see
docs/roadmap/00-MASTER-PLAN.md).
Zulivio integration
Optional, per-project. When configured (PATCH /projects/{id} with
zulivio_enabled: true, zulivio_api_url, zulivio_api_key), every new
submission to that project is pushed to the Zulivio instance at
zulivio_api_url as a lead (POST {zulivio_api_url}/api/v1/leads,
Authorization: Bearer <zulivio_api_key>), best-effort and asynchronous —
a Zulivio outage never affects the submission response.
zulivio_api_key is a normal Zulivio personal API key (generate one in
Zulivio under Settings → API Keys) — no special Submify-specific credential
or Zulivio-side configuration is needed. See
docs/decisions/0006-zulivio-integration-via-existing-api-key.md for why.
Field mapping (best-effort, case-insensitive key matching against the
submission's data object — not a configurable mapping):
| Submify field (any of) | Zulivio field |
|---|---|
name, full_name, fullname, your_name | fullName (required — submissions with none of these are skipped, not sent) |
email, email_address, your_email | email |
phone, phone_number, mobile, telephone | phone |
company, organization, organisation, business | company |
| (everything else) | folded into notes as JSON, so no data is silently dropped |
Always sets source: "Submify: <project name>" and autoAssign: true (so
the lead enters Zulivio's existing assignment-rule pipeline immediately).
Known limitations: no dedupe — resubmitting the same form creates a new lead each time (Zulivio itself has no dedupe-by-email/phone yet). No delivery-status tracking in Submify's UI — a submission whose push fails all 3 retry attempts is only visible in the server log.
Email notifications
Optional, per-project. When configured (PATCH /projects/{id} with
email_notifications_enabled: true and the fields below), every new
submission to that project is emailed to notification_recipients
through your own SMTP account — Submify never operates a shared
sending identity. See
docs/decisions/0007-email-notifications-smtp-relay.md.
Fields:
| Field | Notes |
|---|---|
smtp_host | e.g. smtp.gmail.com |
smtp_port | 587 (STARTTLS, default) or 465 (implicit TLS) — both are handled automatically based on the port |
smtp_username / smtp_password | SMTP auth credentials |
smtp_from_email | the sending address |
notification_recipients | array of destination addresses, e.g. ["sales@company.com", "jane@company.com"] |
email_configured (read-only, on the Project object) is true once
host/username/password/from-email are all set and at least one recipient
is configured.
Delivery is async and best-effort with 3 retries (same pattern as Telegram and Zulivio) — an SMTP failure never affects the submission response, and is only visible in the server log.
