# BetaList Agent API v1

BetaList features new startups to early adopters: around 20 a day on the site and in a newsletter with 70,000+ subscribers. This API lets a coding agent draft a startup submission with no account and no browser. **The agent never pays and cannot publish.** It creates a submission, receives one claim link and hands it to the founder, who opens it, identifies with an email, edits the submission, picks a plan and pays. Only then does the startup enter review.

Base URL `https://betalist.com/`. Bodies are JSON both ways (`Content-Type: application/json`). Creating needs no credentials; afterwards the submission token is the only one (see Token handling).

## Endpoints

| Method and path | Purpose |
|---|---|
| `POST /agent/submissions` | Create a submission. 201 with token, `claim_url` and `status_url`. |
| `GET /agent/submissions/:token` | Status of a submission. |
| `PATCH /agent/submissions/:token` | Change any field while the submission is `unclaimed`. |
| `POST /agent/submissions/:token/images` | Upload an icon or screenshot as a file (multipart). See Images. |
| `GET /agent/submissions`, `PATCH /agent/submissions` | Same, with the token in `Authorization: Bearer <token>` instead of the path. Preferred. |
| `GET /agent/topics` | Topics as JSON: `[{"slug": "finance", "name": "Finance"}, ...]`. |
| `GET /agent` | This document. |

## Create a submission

```bash
curl -X POST https://betalist.com/agent/submissions \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data @submission.json
```

`submission.json`:

```json
{
  "website_url": "https://acme-invoices.com",
  "name": "Acme",
  "pitch": "Invoices that write, send and chase themselves for freelancers",
  "description": "Acme turns a tracked hour or a one-line note into a finished invoice, sends it, and follows up on its own until it is paid. Connects to Stripe and your bank so you see what is outstanding without a spreadsheet. Built for freelancers who would rather do the work than the admin.",
  "topics": ["finance", "productivity"],
  "icon_url": "https://acme-invoices.com/press/icon-512.png",
  "screenshot_urls": ["https://acme-invoices.com/press/dashboard.png"],
  "founder_email": "jane@acme-invoices.com",
  "agent": { "name": "claude-code", "version": "2.1" }
}
```

### Fields

Text is validated with the web form's rules and messages; a failing field returns `validation_failed` (422) with the message and limit, and nothing is stored. Every listing is edited by hand before it is featured; [How to write a great submission](https://betalist.com/help/submitting.md) explains what each field should look like so it needs little or no editing.

| Field | Required | Limit | Hint |
|---|---|---|---|
| `website_url` | yes | 255 chars | The product's own custom domain, `https://...`. Not an app-store, Product Hunt, Notion or free-subdomain page. |
| `name` | yes | 255 chars | The product name, nothing else. |
| `pitch` | yes | 75 chars, one sentence | No slogans or buzzwords. Be descriptive. Don't include product name. |
| `description` | yes | 700 chars, plain text | No buzzwords. Talk like a person. Focus on product features and value proposition. |
| `topics[]` | no | max 5 | Slugs from `GET /agent/topics`. Free text is matched by name, then by meaning; what matches nothing is dropped with a `topics_unmatched` warning. |
| `icon_url` | no | 1 image | Square app icon, ideally 512 px or larger. |
| `screenshot_urls[]` | no | max 5 images | Product screenshots or App Store–style promotional images, in display order. Not the icon. |
| `video_url` | no | | A YouTube URL. |
| `testflight_url` | no | | Optional. Have an iOS beta? Paste your public TestFlight link (`https://testflight.apple.com/join/...`). |
| `signup_url` | no | | Where early adopters sign up, when that is not the website. |
| `promotion_details` | no | about 500 chars | Optional. Coupon code, discount, invite link for private beta, etc. Describe any special offers clearly. |
| `founder_email` | no | | The founder's address. Used only to warn when someone else claims; never shown, never emailed. |
| `agent` | no | | `{"name": "...", "version": "..."}`, so we can see which agents produce good submissions. |

### Images

Image URLs never fail the request: they are fetched afterwards and each reports `pending`, then `attached` or `failed` with an error in `images` on the status endpoint: `invalid_url`, `fetch_failed`, `unsupported_type`, `too_large`, `too_small`, `superseded` (replaced by a later PATCH or the founder). PATCH the same URL again to retry a `fetch_failed` image; the other errors are final for that URL. Rules: a public `http(s)` URL with a hostname (not an IP), no credentials, no custom port; at most 3 MB; 10–5000 px per side; one of image/png, image/jpeg, image/gif, image/webp, image/avif. Prefer app-store artwork and official screenshots over anything scraped from the homepage. Paying requires an icon and a screenshot, so a submission without usable ones carries `missing_icon` / `no_screenshots` in `warnings[]` for the founder to fix.

**Files instead of URLs.** Images on disk go up one per request as `multipart/form-data` with `kind` (`icon` or `screenshot`) and `file`; screenshots take an optional `position` (1–5, default the next free slot, an existing one is replaced). Same rules, checked at once: 201 returns the entry (`url` null, `filename` set) and the full `images`; 422 `validation_failed` carries the code on `file` or `position` (`too_many_screenshots`); over 3 MB is 413. A later `icon_url` / `screenshot_urls` PATCH replaces uploads in the slots it covers.

```bash
curl -X POST https://betalist.com/agent/submissions/images \
  -H "Authorization: Bearer $TOKEN" \
  -F kind=icon -F file=@icon.png
```

### Duplicates

A `website_url` featured on BetaList in the last 12 months is refused with `duplicate_website` (409); `duplicate` carries that startup's `name` and `url`. Do not retry with a variant of the URL; tell the founder.

### Idempotency

Send an `Idempotency-Key` header (8–128 chars, e.g. a UUID) with every POST. A repeat with the same key from the same IP within 24 hours returns the original submission (200, same token) instead of creating another; the same key with a different `website_url` is `idempotency_key_reused` (422). Without the header every POST creates a new submission.

## Responses

201 (created) and 200 (GET, PATCH, idempotent replay) share one shape. While `unclaimed`:

```jsonc
{
  "status": "unclaimed", "terminal": false, "next_action": "share_claim_url", "poll_after_seconds": 30,
  "token": "...", "status_url": "https://betalist.com/agent/submissions/...", "claim_url": "https://betalist.com/claim/...",
  "expires_at": "2026-10-28T12:00:00Z",
  "submission": { "name": "Acme", "pitch": "...", "topics": [{"slug": "finance", "name": "Finance"}], "agent": {"name": "claude-code", "version": "2.1"} },
  "images": { "icon": {"url": "...", "filename": null, "status": "pending", "error": null}, "screenshots": [] },
  "warnings": [ {"code": "no_screenshots", "message": "..."} ],
  "payment": { "payer": "human", "required": true, "url": null }
}
```

`payment` is always present: `payer` is `human`; `url` is `null` until claim, then the founder's checkout link (not yours). Later states drop `submission`, `images` and `claim_url` and add state fields (below).

Every error is `{"error": "<code>", "message": "...", "errors": []}`; `validation_failed` fills `errors[]` with `{"field", "code", "limit", "message"}`.

| HTTP | `error` | Meaning |
|---|---|---|
| 400 | `bad_request` | The body is not a JSON object, or `Content-Type` is not `application/json` (image uploads: not `multipart/form-data`). |
| 404 | `not_found` | No submission for this token. Same body shape as `expired`. |
| 409 | `duplicate_website` | See Duplicates. |
| 409 | `claimed` | PATCH after claim. Edits are now the founder's, through their dashboard. |
| 410 | `expired` | Unclaimed for 7 days. Create a new submission and send a new link. |
| 413 | `payload_too_large` | Body over 64 KB (image uploads: over 3 MB). |
| 422 | `validation_failed` | Field errors in `errors[]`. |
| 422 | `idempotency_key_reused` | See Idempotency. |
| 429 | `rate_limited` | Wait the seconds in `Retry-After`, then retry. |

## Status lifecycle

Poll `GET /agent/submissions/:token` no sooner than `poll_after_seconds`; stop when `terminal` is `true`.

| `status` | `next_action` | Meaning and extra fields |
|---|---|---|
| `unclaimed` | `share_claim_url` | Waiting for the founder to open `claim_url`. Expires at `expires_at`. |
| `claimed` | `wait_for_payment` | Founder identified; editing and choosing a plan. `payment.url` set. No more PATCH. |
| `paid` | `wait_for_review` | Paid; `plan` `{id, name}` present. A human reviews within the plan's time frame. |
| `accepted` | `none` | Scheduled; `scheduled_for` present. |
| `featured` | `none` | Live at `startup_url` since `featured_at`. Terminal. |
| `rejected` | `wait_for_payment` | Declined in review; `refunded` says whether the payment went back. The founder may revise and resubmit. |
| `deleted` | `none` | Removed by the founder or BetaList. Terminal. |
| `expired` | `none` | Never claimed (410). Terminal. |

## What to tell the founder

Hand over `claim_url` and say: "I drafted your BetaList submission. Open this link to review it. You can edit anything, then choose a plan and pay. The link expires in 7 days." Mention the plans; prices are shown when the founder picks one:

- **Lite**: featured within about 14 days.
- **Standard**: featured within about 3 days.
- **Premium**: featured within about 1 day.

Do not promise a date: every paid submission is reviewed and declined ones are refunded. If there were warnings, say which images the founder must add.

## Token handling

The token is a secret capability: whoever has it can read the submission and, until claim, rewrite it; the claim link contains it. Send it in the `Authorization: Bearer <token>` header rather than the path so it stays out of URLs and access logs. Never log it, commit it, or put it anywhere but the founder's hands. BetaList never emails it. Responses never contain the founder's email, name, account or payment details.

## Limits

POST: 10 per hour per IP. PATCH: 60 per hour per token. GET: 120 per hour per token, and 30 per hour per IP for unknown tokens. Image uploads: 30 per hour per token. Request bodies: 64 KB (image uploads 3 MB). A global daily cap on creations and edits also exists; once reached, writes return `rate_limited` until the next UTC day. `rate_limited` always carries `Retry-After` in seconds; wait, do not spin.

BetaList Agent API v1. Canonical copy: `https://betalist.com/agent`. Topics: `GET /agent/topics`.
