Docs

How Submify works

Submify is a self-hosted backend for HTML/JS forms. You create projects, each with a public and secret key. Browsers POST JSON to /api/submit with the public key; you review rows in the dashboard and export when needed.

This page is a condensed quick reference. The full README, deployment guide, and API contract live in the GitHub repository.

Requirements

Install

One command installs Docker Engine and git if either is missing, clones the repo, generates strong random secrets on first run, and brings up the full stack. If you installed Docker from Debian's docker.io package, see the Debian prerequisite steps in the docs before running.

One-line installer (installs Docker Engine + git if needed)

curl -fsSL https://raw.githubusercontent.com/Raktim94/Submify/main/scripts/quickstart.sh | bash

Requires Docker Engine and the Compose v2 plugin. Once the containers are up, open http://localhost:2512 and create your first account at /register.

Configuration

No .env is required to get started — compose-up.sh / Compose-Up.ps1 auto-create .env.auto with strong random POSTGRES_PASSWORD and JWT_SECRET values on first run. Copy .env.example to .env only to override defaults (custom CORS origins, port, cookie settings, and so on).

  • Single published port — Nginx is the only exposed port (default 2512), proxying /api/* to the Go API and everything else to the dashboard.
  • One account per instance — registration only works while no account exists yet (GET /api/v1/system/bootstrap-status). After the first account is created, sign-up closes and POST /api/v1/auth/register returns 403.
  • Optional storage — connect any S3-compatible bucket per project from the dashboard for presigned uploads. No storage container ships with the stack.

API overview

Dashboard and API routes live under /api/v1 and require a Bearer access token. Public form submissions use a separate route, POST /api/submit, authenticated with a project's public key in the x-api-key header — not your account password.

The full request/response contract for every endpoint (auth, projects, submissions, exports, uploads) is documented in docs/api.md on GitHub.

Client portal (share view-only access)

Each project has its own password-protected portal at https://<your-host>/<slug> (for example /acme-contact). Share the URL and password with a client and they can view and export that project's submissions, and view (not manage) your organization's calendar bookings on a real month/week/day calendar — nothing else. No dashboard account, no API keys, no delete rights, and no ability to create, reschedule, or cancel a booking.

  • Auto-generated password — creating a project mints a strong random portal password, shown once so you can copy and share it. Only its Argon2id hash is stored; regenerate a fresh one any time.
  • You stay in control — rename the slug, rotate or clear the password, and enable or disable the portal per project from the dashboard.
  • Secure by default — sessions are project-scoped in an HttpOnly cookie, login is rate-limited per project, and every portal response is sent with Cache-Control: no-store.
  • Calendar is read-only — a client can browse bookings in month, week, and day views (navigate with prev/next/today, click a booking for details), but can't reschedule, cancel, or create one, and never sees your own private personal-agenda items. Because bookings belong to your organization rather than one specific project, a portal visitor sees the organization's full booking calendar, not just meetings tied to that one project.

Calendar & booking

Beyond form submissions, the authenticated app includes a real day/week/month calendar for scheduling and your own personal agenda.

  • Event types — define a bookable service (duration, weekly available hours, buffers, minimum notice) and share its public booking page. The person booking doesn't need an account or login.
  • Attendee self-service — attendees can reschedule or cancel from their own booking link and download a .ics file for their calendar app. Double-booking is prevented at the database level.
  • Personal events & reminders — a private agenda (tasks, events, reminders) scoped to you and your organization, separate from the external-attendee booking system — nobody else in the organization can see it.
  • Telegram reminders — optional notifications for both bookings and personal calendar items, delivered through the Telegram bot you've already connected under Settings.

Wire up calendar booking with an AI assistant

No API key needed for this one — the event-type ID itself is the access control. Paste the prompt below into your AI coding assistant to scaffold a "book a call" flow against your own instance's public booking API. Replace <MY-SUBMIFY-HOST> and <EVENT-TYPE-ID> with your own values first (copy the event-type link from Calendar → Event Types in your dashboard).

Paste into Cursor, Claude, or any coding assistant

You are helping me add a "book a call" flow to my website using my self-hosted Submify's public calendar booking API.

Submify facts:
- No API key needed for this part — the event-type ID itself is the access control (same trust model as a presigned upload URL).
- SUBMIFY_HOST = "https://<MY-SUBMIFY-HOST>", EVENT_TYPE_ID = "<EVENT-TYPE-ID>" (from Calendar -> Event Types -> Copy Link in my dashboard; the ID is the last URL segment).
- All calls are under SUBMIFY_HOST/api/v1/public, no auth headers, and work cross-origin from the browser out of the box.

API contract:
- 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 genuinely open slots are returned (weekly hours, buffers, minimum notice, and existing bookings already applied) - render what comes back, don't re-derive availability.
- 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 }. Returns 409 if the slot was just taken by someone else (real double-booking prevention at the database level) - on 409, re-fetch slots and ask the visitor to pick again, don't show a generic error.
- GET /bookings/{manage_token}/ics downloads a calendar file - link this as "Add to calendar" after a successful booking.

Please:
1. Match my site's existing design system (colors, spacing, fonts, button/card styles) - don't drop in a visually generic, bolted-on widget.
2. Show duration/location/timezone from the event-type response, not hardcoded values.
3. Group slots by day if there are more than about 15 (a full multi-day flat list is overwhelming) - simple day-tabs or a day-grouped layout is enough.
4. Handle the 409 conflict case explicitly, as described above.
5. After a successful booking, show the confirmed time in the visitor's own timezone, the "Add to calendar" link, and manage_url for reschedule/cancel - not just a bare "success" message.
6. Call SUBMIFY_HOST directly from the browser (CORS is already open) - no server-side proxy route needed unless my codebase's conventions require one for other outbound calls.

Wire up a form with an AI assistant

Paste the prompt below into your AI coding assistant (Cursor, Claude, or similar) to scaffold a form that posts to your Submify instance. Replace <MY-SUBMIFY-HOST> and <PROJECT_PUBLIC_KEY> with your own values first.

Paste into Cursor, Claude, or any coding assistant

You are helping me connect an HTML/JS contact form to my self-hosted Submify form backend.

Submify facts:
- Submit endpoint: POST https://<MY-SUBMIFY-HOST>/api/submit
- Auth header: "x-api-key: <PROJECT_PUBLIC_KEY>" (looks like pk_live_...). This key is safe to expose in the browser.
- Request body is JSON: { "data": { ...your form fields... }, "files": [] }
- Success is HTTP 201. On error, the JSON response has an "error" string.
- The project secret key (sk_live_...) is NEVER put in browser code. Use it only server-side to sign the raw body with HMAC-SHA256 and send it in the "x-signature" header.

Please:
1. Build an accessible contact form (name, email, message) with client-side validation and a hidden honeypot field named "gotcha". If the honeypot is filled, silently succeed without sending (it is a bot).
2. Submit with fetch() and AbortSignal.timeout(18000). Show inline success and error states, and disable the button while sending.
3. Prefer posting to a same-origin server route (e.g. a Next.js route handler) that forwards to the Submify endpoint, so the public key stays out of the client bundle and you can add the optional x-signature HMAC. If a server route is not possible, post directly from the browser with only the public key.
4. Read the Submify host and public key from environment variables — do not hard-code them.

After submissions arrive I review and export them in the Submify dashboard, or share a read-only client portal link (https://<MY-SUBMIFY-HOST>/<project-slug>) and its password with the client so they can view and export their own submissions.

Accessing from another device (LAN / network IP)

By default Nginx binds to 127.0.0.1, so the dashboard and API are reachable only from the same machine. To access Submify from another device on your network — or from the internet — follow these three steps.

1. Add these two lines to your .env file (create it from .env.example if it does not exist yet):

# Bind Nginx to all interfaces so other devices can reach port 2512.
# Replace 0.0.0.0 with a specific IP to restrict to one interface.
SUBMIFY_BIND_IP=0.0.0.0

# Add your server IP or hostname to the CORS allowlist.
# Replace 192.168.1.100 with your actual server IP or domain.
ALLOWED_ORIGINS=http://localhost:2512,http://127.0.0.1:2512,http://192.168.1.100:2512

2. Restart the stack:

docker compose up -d

3. Open port 2512 on your host firewall:

# UFW (Ubuntu / Debian)
sudo ufw allow 2512/tcp

# firewalld (Fedora / RHEL / CentOS)
sudo firewall-cmd --add-port=2512/tcp --permanent && sudo firewall-cmd --reload

# iptables
sudo iptables -I INPUT -p tcp --dport 2512 -j ACCEPT

Then open http://<server-ip>:2512 from any device on the same network. For internet-facing deployments, put Submify behind a reverse proxy (Nginx, Caddy, Traefik) or a Cloudflare Tunnel with TLS rather than exposing port 2512 directly.

Troubleshooting

Debian: Docker Compose v2 not found

If the installer fails with Docker Compose v2 plugin is required, you likely installed Docker from Debian's repositories (apt install docker.io) rather than Docker's official source. The Compose v2 plugin is not included in Debian's Docker package. Run these commands before retrying the installer:

1. Add Docker's official repository:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list

sudo apt update

2. Remove the conflicting Debian buildx package:

sudo apt remove docker-buildx

3. Install the official plugins:

sudo apt install docker-buildx-plugin docker-compose-plugin

If apt install reports broken packages, run sudo apt --fix-broken install first, then retry step 3.

4. Verify and re-run the installer:

docker compose version
curl -fsSL https://raw.githubusercontent.com/Raktim94/Submify/main/install.sh | bash

Other issues

  • API exits with a JWT_SECRET error — with GIN_MODE=release, the secret must be at least 32 characters. Set it in .env, or use ./scripts/compose-up.sh so .env.auto supplies one.
  • Nothing on the published port — check your firewall, docker compose ps, and the Nginx container logs.
  • Build fails on a small VPS — re-run with --progress=plain to read the full error; try --parallel 1 or add swap if the build runs out of memory.