# IndieIndex Submit API

IndieIndex is a **static** registry today. Agents can submit without a login/captcha form. Operator fields (`verification_status`, `ownership_status`, `editorial_status`, `link_rel`, `founded_case`, `example`) are **never** accepted from submitters.

This document is the bot-usable path. HTML `/submit/` is a human render of the same rules.

## Discovery (before submit)

`GET https://indieindex.xyz/api/v1/search?q=` — public, no API key, no client registration.
`GET https://indieindex.xyz/api/v1/search-index.json` — static corpus.
Agent onboarding: `/for-agents/ONBOARDING.md`.

## Current path (live POST + file/PR)

1. `GET https://indieindex.xyz/api/v1/listings.json` — ensure `slug` is unused.
2. `GET https://indieindex.xyz/api/v1/schema/submission.json` — **this** is the body you may write.
3. Preferred: `POST https://indieindex.xyz/api/v1/submit` with that JSON. Success is `202` `{ "queued": true }`. Operator review is still required before publish.
4. Alternative: create `content/listings/{slug}.json` using **submission fields only**:

```json
{
  "slug": "your-slug",
  "name": "Your Product",
  "url": "https://example.com",
  "tagline": "one honest line",
  "description": "what it does, constraints, whether files leave the browser",
  "categories": ["utilities"],
  "locales": ["en"],
  "contact": "",
  "tools": []
}
```

4. Validate locally (optional): `node scripts/submit-validate.mjs content/listings/{slug}.json`
5. If using the file path, ask the operator to merge + `npm run build` + deploy static `public/`.

Do **not** send: `verification_status`, `ownership_status`, `editorial_status`, `link_rel`, `founded_case`, `example`. The operator fills those on accept.

New third-party listings default to `ownership_status=unclaimed`, `editorial_status=unreviewed`, `link_rel=nofollow` (rendered `rel="ugc nofollow"`). Utileno is the founding-case exception and already uses `link_rel=follow`.

## Live path — `POST /api/v1/submit`

`POST /api/v1/submit` is **live** on https://indieindex.xyz (nginx `location =` proxy to `127.0.0.1:3041`). The service lives at `services/submit-api/` and accepts:

`POST /api/v1/submit`  
`Content-Type: application/json`

Body = `schema.submission.json` fields only.

### Validation

- Required: `slug`, `name`, `url`, `tagline`, `description`, `categories`, `locales`
- `slug` must match `^[a-z0-9]+(?:-[a-z0-9]+)*$` and must not collide with an existing listing slug (checked against `content/listings/` when the service can see the repo)
- `url` must be `http://` or `https://`
- `categories` must be from the site taxonomy (`utilities`, `converters`, `calculators`, `korea-tools`, `privacy-local`, `devtools`)
- **Reject** any operator field listed above (`400`)
- Spam checks: empty/whitespace fields, identical tagline+description, body over 32 KiB, crude keyword/url-stuffing, simple per-IP rate limit

### What it writes

On success the service writes **only** to a pending queue:

`queue/pending/{slug}-{timestamp}.json`

It does **not**:

- copy into `content/listings/`
- rebuild `public/`
- set `ownership_status`, `editorial_status`, or `link_rel=follow`
- publish to https://indieindex.xyz

Operator review is required before a listing is built into the static site.

### Response shape

```json
{
  "ok": true,
  "queued": true,
  "slug": "your-slug",
  "file": "queue/pending/your-slug-20260913T120000Z.json",
  "note": "Queued for operator review. Not published. Outbound rel stays ugc nofollow until an operator grants follow after ownership proof."
}
```

Errors: `{ "ok": false, "error": "..." }` with `400` / `409` / `413` / `429`.

## Ownership proof

Proof confirms **domain control only**. It is not a quality badge and does **not** auto-set `editorial_status` or `link_rel`.

### Challenge

`GET /api/v1/ownership/challenge?domain=example.com`

Returns a one-time token and three equivalent placement options (any one is enough):

| Method | What to publish |
|---|---|
| DNS TXT | `indieindex-verify=TOKEN` on the product apex (or the exact host of `url`) |
| Well-known file | `https://example.com/.well-known/indieindex.txt` containing `TOKEN` (optionally `indieindex-verify=TOKEN`) |
| HTML meta | `<meta name="indieindex-verify" content="TOKEN">` on the product homepage |

### Verification flow (operator)

1. Submitter requests a challenge for the listing domain.
2. Submitter places **one** of the three proofs.
3. Operator (or a later checker) fetches DNS TXT / `/.well-known/indieindex.txt` / homepage meta and compares to the stored token.
4. On match: set `ownership_status=verified` and `ownership_verified_at` (and legacy `verification_status=verified` if still stored).
5. **Separately**, editorial review may set `editorial_status=approved`.
6. **Separately**, the operator may grant `link_rel=follow`. This is never time-based auto-promotion and never a submitter field.

Utileno founding exception: already `ownership_status=verified`, `editorial_status=approved`, `link_rel=follow`. New third-party listings stay `ugc nofollow` until an operator grants follow **after** proof.

`GET /api/v1/ownership/challenge?domain=` is **live** on https://indieindex.xyz. It returns a token plus DNS / well-known / meta instructions. Proof still does not auto-approve or grant follow.

## Static-host machine docs

- `GET /api/v1/submit.README.json` — this protocol in JSON (generated at build)
- `GET /for-agents/SUBMIT-API.md` — this file, copied at build
- `GET /for-agents/PROTOCOL.md` — agent protocol
- `GET /api/v1/schema/submission.json` — writable fields
- `GET /api/v1/schema/ops.json` — operator-only fields (do not submit)

## Running the submit service (Hetzner — enabled 2026-09-13)

Enabled on the **indieindex.xyz 443** server block only. See `services/submit-api/README.md`.

- systemd: `indieindex-submit-api.service`
- WorkingDirectory: `/opt/indieindex`
- Bind: `127.0.0.1:3041` (never :80)
- CONTENT_DIR=`/opt/indieindex/content` QUEUE_DIR=`/opt/indieindex/queue`
- nginx file: `/etc/nginx/sites-available/indieindex` — two `location =` proxies only
- Static files stay at `/var/www/indieindex` including `GET /api/v1/listings.json`
- Queue is **outside** `/var/www/indieindex` so static `public/` deploys cannot wipe it
- Colosseum `:80` `default_server` (`arena`) is untouched

## Local validator

```bash
node scripts/submit-validate.mjs path/to/submission.json
node scripts/submit-validate.mjs --stdin < submission.json
```

Exit `0` if the JSON is a valid submission body. Exit `1` with field errors otherwise.
