Self-hosted

Form submissions for static sites.

Point your HTML form at one endpoint. Get an email, a webhook, and a searchable inbox — on your own server, in one SQLite file you can copy.

Self-host it Read the source
One Go binary SQLite, no ORM AGPL-3.0 ~20MB image No SaaS
contact.html
<form
  method="POST"
  action="https://forms.you.dev/f/4b91c0">

  <input name="name"    required>
  <input name="email"   required>
  <textarea name="message"></textarea>

  <!-- bots fill this, people don't -->
  <input name="_honeypot" hidden>

  <button>Send</button>
</form>
The dsforms overview: submissions over time, spam held, per-form breakdown and rate-limit activity.
How it works

Three steps, then it is someone else's problem.

No JavaScript on your site. No account. No third party between your visitor and your server.

01

Create a form

Name it, give it an address to notify, and copy the endpoint. About twenty seconds.

02

Point your form at it

A plain action attribute. Works on Netlify, Pages, S3, or a file on a USB stick.

03

Read it wherever

Email, a Slack webhook, CSV export, or the admin's own searchable inbox.

Spam filter

It holds spam. It does not silently eat mail.

A weighted filter with no configuration to get wrong. Anything it holds stays reviewable for thirty days, with the exact reason attached — because the expensive failure is not spam getting through, it is a real customer disappearing.

Link markup, injection probes, keyword pile-ups, synthetic-looking text and repeat-IP bursts — each weighted, never one signal deciding alone.

Every held submission keeps its breakdown: which rule, which field, which characters, worth how many points.

Restore puts it back in the inbox as unread and sends the notification that was withheld.

Block or allow an address, a domain, an IP or a CIDR range. Allow always wins.

why it was held score 11 / 6
Marketing Bot
45.155.204.7 · Contact · 2d
0threshold 612
Link markup +6
field message · matched
[url=
Keyword hit +5
field message · matched
backlinks
Not spam Block this IP
Webhooks

Straight into the channel you already watch.

Slack
Formatted attachment per submission
Discord
Embed with every field
Generic JSON
POST it anywhere you like
POST your-endpoint
{
  "form_id": "4b91c0",
  "form_name": "Contact",
  "submitted_at": "2026-09-09T12:04:11Z",
  "data": {
    "name": "Jane Doe",
    "email": "jane@example.com",
    "message": "Loved the article."
  }
}
Waitlist mode

The same endpoint shape, with positions.

POST /w/{id}
$ curl -X POST https://forms.you.dev/w/launch \
    -d "email=jane@example.com"

{
  "success": true,
  "position": 2481,
  "already_joined": false
}
Deduplicated by email

Signing up twice returns the original position instead of creating a second row.

Positions from #1

Handed out in signup order, and shown back so you can put them in a confirmation email.

Restart-safe broadcasts

A throttled queue with per-recipient retries. Restarting the server resumes it.

Admin

An inbox, not a database viewer.

A dark, compact dashboard: what arrived, what is unread, what got held, and why. Server-rendered — no JavaScript framework anywhere in it.

Overview

Submissions over time, spam rate, per-form breakdown and rate-limit activity.

Reader

Click a row and it opens over the list. Step through with the arrows without losing your place.

Quarantine

Everything the filter held, with its score breakdown and one click to put it back.

Filter rules

Block or allow addresses, domains, IPs and CIDR ranges. Add your own keywords.

MCP

Let an agent work your inbox, on a leash.

Point an MCP client at /mcp and it can list what you have not read, read one, file spam, or ask what is in the database. Off until you turn it on.

client config
{
  "mcpServers": {
    "dsforms": {
      "type": "http",
      "url": "https://forms.you.dev/mcp",
      "headers": {
        "Authorization": "Bearer dsf_…"
      }
    }
  }
}
Tokens you can take back

Shown once, stored as a hash, revocable one at a time. Scoped to read, write or delete — delete is its own scope, because a client that files spam should not also be able to erase it.

Or let it sign in

For clients that cannot carry a token, turn on OAuth: the client finds where to sign in, you approve it on a consent page that starts at read only, and it shows up under Connected apps until you disconnect it.

Bound to the forms you pick

A bound token does not see the others at all — not refused, absent. Every listing is filtered in the query itself, and an id from another form answers exactly as an id that does not exist.

Messages are not instructions

Chat-template markers and invisible Unicode are stripped from a submission before a client sees it, and the client is told what went — and told that the list of recognised markers is not exhaustive. What is stored never changes, and the admin flags it.

Refuses to start over plain http, since a bearer token rides every request — override it for localhost or a private network and it warns on every boot instead. Full setup, the tool list and what each scope costs: docs/mcp.md.

Everything, in one binary

No add-ons, no tiers, no per-submission pricing.

Notifications

Email per submission

Sent asynchronously, so a slow SMTP server never delays the visitor.

SMTP_HOST
Webhooks

Slack, Discord or generic JSON, testable from the form's settings page.

per form
Waitlist broadcasts

Throttled, retried per recipient, and resumed after a restart.

BROADCAST_THROTTLE_MS
Quarantine digest

One daily summary of what was held — never one email per spam message.

DIGEST_TO
Data

One SQLite file

Everything lives in it. Copy it and you have moved house.

DB_PATH
Backup and restore

A consistent snapshot while the server keeps running. API tokens, sessions and OAuth connections are stripped both ways.

/admin/backups
Full-text search

SQLite FTS5 across every field. No external search service.

⌘K
CSV export

Every field, with formula injection neutralised on the way out.

/export
Security

Sessions in the database

Opaque tokens, hashed at rest. Changing a password ends every session.

SECRET_KEY
Rate limiting

Per-IP token bucket on the public endpoints, plus a login lockout.

RATE_BURST
Honeypot field

A hidden input bots fill in and people never see.

_honeypot
Multiple users

bcrypt at cost 12, managed from the admin or the CLI.

dsforms user add
MCP endpoint

Scoped bearer tokens or OAuth sign-in, limitable to single forms. Off unless you enable it.

MCP_ENABLED · MCP_OAUTH
Self-host

Three commands and a domain.

Docker Compose with a published image, or go build if you would rather. The container is roughly 20MB and needs one writable volume for the database.

Image
ghcr.io/barancezayirli/dsforms:latest
Full deployment guide
clone and configure
$ git clone https://github.com/barancezayirli/dsforms
$ cd dsforms && cp .env.example .env
# set SECRET_KEY and your SMTP details
start it
$ docker compose up -d
log in
# admin / admin — change it immediately
$ open http://localhost:8080/admin

Your forms, your server, your data.

No submission quotas, no vendor reading your customers' messages, and nothing to migrate off when the pricing changes.

Star on GitHub