# formdrop — integration guide for coding agents

You are wiring an application up to **formdrop**, a multi-tenant schemaless JSON
docstore. This file is self-contained: everything you need is here, and you do
not need to read the formdrop source to use it correctly.

Deployment for this account:

    API        https://formdrop.abhishek93t.workers.dev
    Dashboard  https://formdrop-dashboard.pages.dev

This guide is also published at
<https://formdrop-dashboard.pages.dev/integrate.md>, with a shorter agent-facing
index at <https://formdrop-dashboard.pages.dev/llms.txt> and the MCP server
downloadable at <https://formdrop-dashboard.pages.dev/mcp-server.js>. Point an
agent at any of those and it can integrate without repository access.

---

## 1. Pick the right access path FIRST

formdrop has two completely different ways in. Choosing wrong is the most common
integration mistake, and one of the wrong choices leaks a credential that
**cannot be revoked without hand-editing the database**.

| You are writing... | Use | Credential |
|---|---|---|
| Browser code — a contact form, signup form, feedback widget | **Public ingest URL** (`/p/in...`) | none; the URL *is* the capability |
| Browser code that displays stored data publicly | **Public read URL** (`/p/rd...`) | none |
| Server-side code, a build step, a backend job | **Authed API** with `Bearer sk_...` | tenant key |
| Your own tooling, as the agent | **MCP server** (section 3) | tenant key |

**Hard rule: the `sk_` key must never reach a browser.** Not in a bundle, not in
an env var inlined at build time, not in a framework's public config
(`NEXT_PUBLIC_*`, `VITE_*`, `PUBLIC_*`). It grants full read/write over every
collection in the tenant. The public ingest URL exists so you never need to.

If you are about to put the key in client-side code, you want a public ingest
URL instead — go to section 4.

---

## 2. Getting credentials

Ask the human for `FORMDROP_URL` and `FORMDROP_KEY`. **Do not run signup
yourself** — it creates a new billing tenant and returns a key exactly once,
with no way to recover or revoke it afterwards.

If the human has no key, tell them to run:

    curl -s -X POST https://formdrop.abhishek93t.workers.dev/signup \
      -H 'Content-Type: application/json' \
      -d '{"email":"them@example.com"}'

Returns `201 {"tenant_id":"t_...","api_key":"sk_...","note":"..."}`. The key is
shown once. `409` means that email already has a tenant.

Store the key wherever that project keeps secrets. Never commit it.

---

## 3. MCP server setup (optional but preferred for agent use)

`mcp-server.js` is a single CommonJS file, zero npm dependencies, Node >= 18
(needs global `fetch`). It speaks JSON-RPC over stdio.

Claude Code, from the consuming project's directory:

    claude mcp add formdrop \
      --env FORMDROP_URL=https://formdrop.abhishek93t.workers.dev \
      --env FORMDROP_KEY=sk_... \
      -- node /absolute/path/to/mcp-server.js

Or `.mcp.json` (same shape works for Cursor and Claude Desktop):

    {
      "mcpServers": {
        "formdrop": {
          "command": "node",
          "args": ["/absolute/path/to/mcp-server.js"],
          "env": {
            "FORMDROP_URL": "https://formdrop.abhishek93t.workers.dev",
            "FORMDROP_KEY": "sk_..."
          }
        }
      }
    }

The path must be absolute. The file is dependency-free, so copying it into the
consuming project is fine if you prefer self-containment.

**Tools exposed:**

| Tool | Use it to |
|---|---|
| `list_collections` | See what exists. Call before creating, to avoid duplicates. |
| `create_collection` | Make a collection and optionally mint public URLs. |
| `get_form_snippet` | Generate the paste-ready HTML form. Do not hand-write one. |
| `list_documents` | Read submissions back; exact-match filters, max 4. |
| `get_document` | Fetch one document by key. |
| `upsert_document` | Create/replace a document at a key. Replaces the whole payload. |

Deliberately absent: document deletion and signup. Do those by hand.

**If MCP is unavailable, skip this section entirely** — every tool is a thin
wrapper over the HTTP API in section 6, and plain `curl`/`fetch` is fully
supported.

---

## 4. The main flow: attaching a form to a website

### Step 1 — create the collection

    curl -s -X POST "$FORMDROP_URL/v1/collections" \
      -H "Authorization: Bearer $FORMDROP_KEY" \
      -H 'Content-Type: application/json' \
      -d '{"name":"contact","public_ingest":true,"allowed_origins":"https://theirsite.com"}'

`name` must match `[a-z0-9_-]{1,64}`. Response:

    {"id":"c_...","name":"contact","ingest_url":"/p/in<32 hex>","read_url":null}

`ingest_url` is **relative** — prepend `$FORMDROP_URL` to get the absolute URL
you put in the form.

> **Set `allowed_origins` now.** It defaults to `*`, meaning any website on the
> internet can post into this collection. There is no endpoint to change it
> later — fixing it means hand-editing D1. This is a one-shot decision.
>
> **But** the origin check compares the browser's `Origin` header. A server-side
> POST or a `curl` sends no `Origin` and gets `403`. If anything non-browser must
> submit to this collection, leave `allowed_origins` as `*` and rely on the
> honeypot, or have that code use the authed API instead.

### Step 2 — generate the form

Via MCP, call `get_form_snippet` with the absolute ingest URL and your fields.
Without MCP, use this shape — it is what the generator emits:

    <form id="formdrop">
        <label>Name<br><input name="name" type="text" required></label>
        <label>Email<br><input name="email" type="email" required></label>
        <input name="_hp" tabindex="-1" autocomplete="off" aria-hidden="true"
               style="position:absolute;left:-9999px">
        <button type="submit">Send</button>
        <p id="formdrop-status" role="status"></p>
    </form>
    <script>
    document.getElementById("formdrop").addEventListener("submit", async function (e) {
      e.preventDefault();
      var status = document.getElementById("formdrop-status");
      var data = Object.fromEntries(new FormData(e.target));
      status.textContent = "Sending...";
      try {
        var res = await fetch("https://formdrop.abhishek93t.workers.dev/p/in<TOKEN>", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(data),
        });
        var body = await res.json();
        if (res.ok && body.ok) {
          e.target.reset();
          status.textContent = "Thanks — we got it.";
        } else {
          status.textContent = "Something went wrong: " + (body.error || res.status);
        }
      } catch (err) {
        status.textContent = "Network error. Please try again.";
      }
    });
    </script>

**Keep the `_hp` field and keep it hidden.** It is a honeypot: humans leave it
empty, bots fill it. A submission with `_hp` non-empty gets `{"ok":true}` and is
**silently discarded** — deliberately, so bots cannot tell they were rejected. If
you are debugging a "successful" submission that never appears in the
collection, check that nothing is auto-filling `_hp`.

Do not rename `_hp`. The server looks for that exact key.

### Step 3 — read submissions back

    curl -s "$FORMDROP_URL/v1/c/contact" -H "Authorization: Bearer $FORMDROP_KEY"
    curl -s "$FORMDROP_URL/v1/c/contact?filter.status=open" -H "Authorization: Bearer $FORMDROP_KEY"

---

## 5. Constraints that cause silent or confusing failures

- **Numeric-looking filter values are coerced to numbers.** A filter value
  matching `-?\d+(\.\d+)?` is bound as a number, so `?filter.zip=02134` becomes
  `2134` and will **never** match the stored string `"02134"`. Numeric-string
  fields (zips, phone numbers, zero-padded ids) cannot be filtered reliably.
- **Filters are exact-match only**, max 4 per query. No ranges, no `LIKE`, no
  sorting controls. Dot paths work for nested fields (`filter.user.role=admin`).
- **`PUT` replaces the entire document.** It is not a merge. Read-modify-write if
  you need to change one field.
- **Writes are metered, and rejected writes still count.** The counter increments
  *before* the quota check, so a `429` still consumed quota. Free plan is 500
  writes/month. Reads are free. Check with `GET /v1/usage`.
- **Body size caps**: `413` above ~300 KB `Content-Length`, or 256 KiB of JSON.
- **List limits**: authed list caps at 500 (default 100); public read caps at 100.
  Paginate with `?before=<created_at>`; there is no offset or total count.
- **Timestamps are UTC SQLite format** (`2026-07-31 12:33:31`), not ISO-8601 —
  space separator, no timezone suffix. Normalize before passing to `new Date()`
  rather than assuming every engine parses it.
- **Public tokens carry `in`/`rd` prefixes** (`/p/in...`, `/p/rd...`). Preserve
  them exactly; they are part of the route.
- **Every document has a `key`.** Auto-id inserts set `key = id`, so everything is
  addressable at `/v1/c/:name/:key`.
- **Submitter IPs are stored** on public ingest and returned by the authed list.
  That is personal data — do not surface it publicly or log it casually.
- **No file/binary storage, no per-end-user auth, no rate limiting, no captcha**
  (unless a `turnstile_secret` was set on the collection at creation).

---

## 6. HTTP API reference

Authed routes need `Authorization: Bearer sk_...`. Bad or missing key → `401`.

| Method | Path | Notes |
|---|---|---|
| POST | `/signup` | `{email}` → `{tenant_id, api_key}`. `409` if taken. |
| POST | `/v1/collections` | `{name, public_ingest?, public_read?, allowed_origins?, webhook_url?, turnstile_secret?}` |
| GET | `/v1/collections` | List with tokens and origins. |
| GET | `/v1/usage` | `{month, writes_used, limit, plan}` |
| POST | `/v1/c/:name` | Insert, auto id → `201 {ok, id, key}` |
| PUT | `/v1/c/:name/:key` | Upsert (full replace) → `{ok, id, key}` |
| GET | `/v1/c/:name` | List; `?filter.f=v&limit=&before=` |
| GET | `/v1/c/:name/:key` | One document |
| DELETE | `/v1/c/:name/:key` | `{ok, deleted}` |
| POST | `/p/<ingest_token>` | **No auth.** JSON or form-encoded → `{ok, id}` |
| GET | `/p/<read_token>[/:key]` | **No auth.** Read-only |

Validation: collection `[a-z0-9_-]{1,64}`, document key `[A-Za-z0-9_.\-~]{1,128}`,
key `sk_` + >=20 hex. All responses are JSON, including errors: `{"error":"..."}`.

Optional per-collection extras, settable only at creation: `webhook_url` (fired
after each public ingest, fire-and-forget, failures ignored) and
`turnstile_secret` (verifies a `cf-turnstile-response` field before storing).

---

## 7. Verify the integration before reporting success

Substitute your values and run all of it. Every line should behave as commented.

    B=https://formdrop.abhishek93t.workers.dev
    K=sk_...

    # authed write + read-back
    curl -s -X POST "$B/v1/c/contact" -H "Authorization: Bearer $K" \
      -H 'Content-Type: application/json' -d '{"probe":true}'          # 201, d_ id

    # public ingest exactly as the browser sends it
    curl -s -X POST "$B/p/in<TOKEN>" -H 'Content-Type: application/json' \
      -H 'Origin: https://theirsite.com' \
      -d '{"name":"Probe","email":"p@example.com"}'                    # {"ok":true,"id":"d_..."}

    # honeypot must swallow it: ok:true but NO id, and nothing stored
    curl -s -X POST "$B/p/in<TOKEN>" -H 'Content-Type: application/json' \
      -H 'Origin: https://theirsite.com' \
      -d '{"name":"Bot","_hp":"filled"}'                               # {"ok":true} with no id

    # wrong origin must be refused when allowed_origins is set
    curl -s -X POST "$B/p/in<TOKEN>" -H 'Content-Type: application/json' \
      -H 'Origin: https://evil.example' -d '{"x":1}'                   # 403 origin not allowed

    # the key must be rejected without it
    curl -s "$B/v1/c/contact"                                          # 401 unauthorized

Then confirm the documents actually landed:

    curl -s "$B/v1/c/contact" -H "Authorization: Bearer $K"

Do not report the integration as working until you have seen the real submission
appear in that list. A `200` from the ingest URL alone is not proof — the
honeypot path also returns `200`.

---

## 8. Error reference

| Symptom | Cause | Fix |
|---|---|---|
| `401 unauthorized` | Missing/malformed/wrong key | Header must be `Bearer sk_<hex>` |
| `404 not found` on `/p/...` | Token mistyped or prefix dropped | Use the full `in`/`rd` token verbatim |
| `404 unknown collection` | Collection doesn't exist for this tenant | `GET /v1/collections` |
| `403 origin not allowed` | `Origin` not in `allowed_origins` | Add the origin, or use the authed API |
| `409` on collection create | Name already used in this tenant | Reuse it or pick another name |
| `413 too large` | Body over the cap | Trim the payload |
| `429 quota exceeded` | 500 writes/month used | Check `GET /v1/usage`; note rejects counted |
| `200 {"ok":true}` but no document | Honeypot `_hp` was filled | Stop auto-filling `_hp` |
| Filter returns nothing | Value coerced to a number | See section 5 |
| MCP server won't start | `FORMDROP_URL`/`FORMDROP_KEY` unset or malformed | It prints the reason on stderr and exits 1 |
