# curb.sale — extended agent docs

No account. No API key. No signup. Selling, searching, editing and contacting
are callable right now, exactly as written below. The account routes near the
end are the only ones that take a key, and you mint that key in one call —
nothing above them needs it.

curl -sX POST https://curb.sale/sell -H 'content-type: application/json' -d '{"text":"Grey IKEA Ektorp sofa, small stain, $150 obo","location":"Austin, TX"}'

## What this is for

A person owns a physical object, it sits at an address, and a stranger nearby is
going to come and collect it. You are the scribe and the broker; the object is
never yours. Use this when someone says sell this, list this, post this, get rid
of this, anyone want this, free to a good home — or when they are hunting for
something secondhand near them, which is a search rather than a listing.

Not for: services (sell the drill, not the drilling), files or digital goods,
gift cards, tickets, subscriptions, jobs, housing, renting or lending,
wanted ads, or anything that has to be shipped. There is no payment, escrow,
shipping or delivery here and there is no cart — two people meet. The rest of the
boundary is at https://curb.sale/prohibited.md.

Guessing wrong here does not cost you a 400. It publishes a real listing, at a
real place, that a real person may drive to.

## Representations

Every GET is content-negotiated. **Markdown is the default**; HTML is served only
when we can prove a browser is asking. In precedence order:

1. Path suffix — /index.md, /search.json, /l/{id}.md. This is URL identity and beats everything.
2. Query — ?format=md|json|html, ?agent=1 (markdown), ?html=1 (HTML).
3. Accept: text/markdown, then Accept: application/json.
4. Sec-Fetch-Dest: document (a real browser navigation) -> HTML.
5. Googlebot, bingbot, DuckDuckBot, Applebot -> HTML.
6. Otherwise markdown.

Responses carry `Vary: Sec-Fetch-Dest, Accept`. Never Vary: User-Agent.
Unsure what you will get? GET https://curb.sale/_probe — it echoes your headers and the verdict.

## Headers on every response

- `link` — the whole discovery surface, so one HEAD reveals the API without a body.
- `x-curb-sell` — the complete write contract on one line. It survives a HEAD, a
  truncated body, and any fetch tool that strips markup.
- `access-control-allow-origin: *` plus OPTIONS preflight, so an agent running
  inside a browser sandbox (Claude artifacts, ChatGPT canvas) can call this API.

## POST /sell — create a listing

Aliases, identical handler: POST /, /list, /listings, /post, /api/listings, /api/v1/listings.
No Authorization header. This route does not read one, so no credential —
valid, junk or absent — changes what it does; creating a listing is anonymous
and stays anonymous. The account routes are the opposite: there a credential
that is present and wrong is a 401, never a quiet downgrade to anonymous.

Accepted request bodies:

| Content-Type | Shape |
|---|---|
| multipart/form-data | `photo` file part(s) plus text fields. The dominant case for a CLI agent with a local file. |
| application/json | {text?, photo?: string \| string[], location?, price?, title?, description?, category?, condition?, currency?, contact_email?} |
| image/jpeg, image/png, image/webp, image/heic | Raw bytes are the entire body. Zero fields. |
| text/plain | The whole body is `text`. |
| application/x-www-form-urlencoded | Same fields as JSON. |

Fields:

- `text` — free text. At least one of text or photo is required; sending both is
  the best case and is what the example above does.
- `photo` — data: URI, https URL, multipart file part, or an array of up to 6.
  20 MB each, 48 MB for the whole request. Photos past the sixth are dropped and
  `notes` says so; PATCH is not a way to add them afterwards.
- `location` — "Austin, TX" | "78701" | "30.27,-97.74". Optional, strongly recommended.
- `price` / `price_cents` — the same field, and always **cents**: 300 = $3.00,
  15000 = $150.00. So are `min_price` and `max_price` on search. One unit across
  the whole API, so the same integer means the same thing wherever you send it.
- The one exception is a value that is plainly typed rather than filled in: a
  currency symbol, a decimal point, a `k`, or the word free. `"$3"`, `"3.00"` and
  `300` are all $3.00, and `"1.5k"` is $1,500. A person's typed budget and an
  agent's integer both land where they meant to.
- A missing price stays missing. We never write 0, because a listing that says free
  when it is not is a lie, and unstated is not free.
- `title`, `description`, `category`, `condition`, `currency` — optional. Anything
  you send is used verbatim. Anything you omit may be inferred, and the response's
  `inferred` array names every field we wrote so you know what to confirm.
- `contact_email` — accepted, and deliberately does almost nothing. It comes back
  in `next` as the address to type into the claim page. It is not stored, not
  verified, and nothing is sent to it: anyone can POST a stranger's address here,
  so it becomes real only when a code sent to it comes back. See claiming, below.

Response 201 carries `url`, `edit_token`, `manage_url`, `claim_url`, `inferred`,
`warning` and `next`. The listing is live at `url` the instant you get the 201.

**Two things are returned exactly once and cannot be recovered: `edit_token` and
`claim_url`.** Save them, or hand them to the user, before you do anything else.

**Search entry is delayed ~10 minutes.** The URL works immediately; `searchable`
flips to true after an index sweep. Never wait for it and never poll for it —
it is not a failure state.

## Location, and the datacenter problem

Precedence: explicit lat/lng, then `location` looked up in our gazetteer, then
the caller's IP. If your request arrives from a known cloud or AI-provider ASN we
will NOT guess — an IP fix would name your provider's datacenter, not the sofa.
You get a 400 with code `location_required` whose `hint` is your own call with
`location` filled in. Ask the user for a city or ZIP and re-send.

Coordinates are fuzzed to roughly 100 m before storage and listing pages show a
city-level label. A listing is someone's home address.

## GET /search

curl 'https://curb.sale/search?q=sofa&near=Austin,TX&radius_km=40&max_price=30000'

Params: `q`, `near` or `lat`+`lng`, `radius_km` (default 40, max 500), `min_price`,
`max_price` — cents, as on a listing, so `max_price=30000` is a $300 ceiling
and "$300" is too — `category`, `condition`, `sold=0|1`, `limit` (default 20,
max 50), `cursor`. Markdown by default; `Accept: application/json` or /search.json
for JSON. A `max_price` filter excludes listings with no stated price — an
unstated price cannot be proven to satisfy a bound.

If we cannot tell where the caller is, search does not fail: you get national
results ranked by recency and a `next` telling you to ask the user for a city.

## Managing a listing — the edit_token does everything

curl -X PATCH https://curb.sale/l/k7m2rq4 -H 'authorization: Bearer curb_e_…' -H 'content-type: application/json' -d '{"price_cents":12500}'
curl -X POST https://curb.sale/l/k7m2rq4/sold -H 'authorization: Bearer curb_e_…'
curl -X POST https://curb.sale/l/k7m2rq4/renew -H 'authorization: Bearer curb_e_…'
curl -X DELETE https://curb.sale/l/k7m2rq4 -H 'authorization: Bearer curb_e_…'
curl -s https://curb.sale/l/k7m2rq4/messages -H 'authorization: Bearer curb_e_…'

A `curb_k_` account key works in the same header on all five, and on
`POST /threads/{tid}/reply`, for the listings already claimed by its own email
address — see claiming, below. The `edit_token` is checked first and always, so
a caller who kept the token from the 201 never touches an account lookup, and
adopting a listing into an account adds a door without closing the first one.

## Contacting a seller

curl -sX POST https://curb.sale/l/k7m2rq4/contact -H 'content-type: application/json' -d '{"message":"Still available?","reply_to":"me@example.com"}'

Omit `reply_to` to get a `thread_url` and a `buyer_token` you can poll instead.
Phone numbers, emails and URLs are stripped from listing bodies and from the
opening messages of a thread, in both directions. Use the relay.

## Reading and answering messages — both sides, still no account

The half of the relay that is not a POST, and the half a seller comes back for.

curl -s https://curb.sale/l/k7m2rq4/messages -H 'authorization: Bearer curb_e_…'
curl -s https://curb.sale/threads/{thread_id} -H 'authorization: Bearer <buyer_token or edit_token>'
curl -sX POST https://curb.sale/threads/{thread_id}/reply -H 'authorization: Bearer <buyer_token or edit_token>' -H 'content-type: application/json' -d '{"message":"Yes — Saturday 10am works."}'

The listing's `edit_token` is the seller's inbox key; the buyer uses the
`buyer_token` returned when `reply_to` was omitted. `/threads/{id}` takes
either and reports `role` so you know which side you are on. Markdown by
default on both GETs, `.json` for JSON.

`/l/{id}/messages.json` gives `{listing, thread_count, awaiting_reply,
threads[], next}`. A thread carries `awaiting_seller` — the queue in one
boolean — plus `buyer_label` (`buyer-9mva`; no address on either side),
`message_count` and `last_message_at`.

Nothing is emailed to the seller of an unclaimed listing, because there is no
address on file — that is what `delivery.seller.reason: "no_address_on_file"`
means, and it is not a failure. Poll this endpoint, or claim the listing and
give it an address. Errors: 409 `thread_closed`, 413 `message_too_long` at
2000 characters, 422 `message_is_only_contact_info`. Ten contacts an hour per
caller, 25 per listing, 40 replies per thread.

## Claiming a listing, and the api_key it mints

A 201 carries a `claim_url`, once. Opening it does two things and only two: the
listing stops expiring, and buyer messages start reaching an email address
instead of waiting in `/l/{id}/messages` to be polled. It does bind the listing
to an account keyed to that address — the next section is that surface — but
there is no plan to pick, no password, and nothing is unlocked. Do not sell it
to a user as registering or upgrading.

Two steps, both the same POST:

curl -sX POST https://curb.sale/claim/curb_c_AtOU3rzGy… -H 'content-type: application/json' -d '{"email":"you@example.com"}'
curl -sX POST https://curb.sale/claim/curb_c_AtOU3rzGy… -H 'content-type: application/json' -d '{"email":"you@example.com","code":"AB3D-7K9M"}'

The URL is the `claim_url` you were handed, unchanged — POST to it. The code
is eight characters in two groups, and the dash and the case are both optional
when you send it back. It is good for 15 minutes, and five wrong guesses burn
the code rather than the listing — ask for another (five codes an hour per
listing, ten per address). A claim link works exactly once; a second use is 409
`already_claimed`.

The second call returns `api_key`: a `curb_k_` string, in plaintext, exactly
once, and only when that account has no live key already. Claiming again from
the same address neither rotates the key nor re-issues it, and neither does
claiming into an account you already hold one for.

What the key is:

- One credential for the listings in its account — PATCH, sold, renew, delete,
  read the inbox, reply in a thread — instead of one `edit_token` per listing.
- A second door. Every `edit_token` still works, unchanged, and always will.
- Not claim-only: the same key can be minted with no listing and no mailbox at
  `POST /account`, and listings you already control join an account by
  presenting their own `edit_token`. The next section is that whole surface.

What it is not, which matters more:

- It does not create listings. `POST /sell` neither wants it nor reads it.
- It raises no rate limit and skips no moderation. Identity buys visibility here,
  never permission and never budget.
- It reaches no listing outside its own account, including every unclaimed one.
  Using it on someone else's is 403 `not_your_listing`.
- It is not required for anything you came here to do. `POST /sell`,
  `GET /search` and `/l/{id}/contact` never ask for one, and every call in
  this document runs without one.

Its blast radius is wider than an `edit_token`'s — that is one listing, this is
every listing in its account. Store it where only that person can read it, and
never print it into a shared channel.

## Accounts, for a caller with no mailbox

Claiming is how a person ends up with an account: an address, a code, a key. An
agent has no mailbox, so it can make the same bag directly. Same key, same rules,
same nothing-granted — see above for what the key is and is not.

curl -sX POST https://curb.sale/account -H 'content-type: application/json' -d '{"inbox":"agent","name":"my-tool"}'

One call. No email, no confirmation, no browser. It returns an `api_key`,
shown once and stored only as a hash. Write it to `~/.curb/credentials` with
mode 0600 and reuse it. Do not print it into the conversation — it is a bearer
credential and chat transcripts get shared.

curl -sX POST https://curb.sale/me/listings -H 'authorization: Bearer curb_k_…' -H 'content-type: application/json' -d '{"edit_tokens":["curb_e_…"]}'

Adopts listings posted anonymously. The `edit_token` is the proof and the only
one — it already deletes the listing, so nothing new is granted. It keeps
working afterwards: adoption adds a door and never closes one. Up to 25 per
call, and partial success is a 200 with per-item results, not an error.

curl -s https://curb.sale/me -H 'authorization: Bearer curb_k_…'

The register: the account, its listings, and its keys' metadata. Key values are
never returned by any read, here or anywhere.

curl -s https://curb.sale/me/inbox -H 'authorization: Bearer curb_k_…'
curl -s 'https://curb.sale/me/inbox?since=1755561600' -H 'authorization: Bearer curb_k_…'

Every buyer message on every listing in the account, newest first, 25 to a page.
An agent inbox is never emailed anywhere, so **this poll is the delivery**.
`?since=` takes a Unix second or an ISO instant and returns only what arrived
after it; send the `etag` back as `If-None-Match` and a poll with no news is
a 304 with no body. Each entry carries the listing, the counters and the last
message; `GET /threads/{thread_id}` has the whole conversation and
`POST /threads/{thread_id}/reply` answers it with this same key. Page with
`next_url` verbatim — it is a keyset cursor, so a message arriving between
pages cannot make a thread appear twice or vanish.

curl -sX POST https://curb.sale/me/inbox/read -H 'authorization: Bearer curb_k_…' -H 'content-type: application/json' -d '{"thread_ids":["th_7x2m9k"]}'

Marks conversations read. Listing the inbox never does — an agent polling every
minute would clear its user's mail before they saw it — so this is the only
thing that changes `unread`. `{"until":1755561600}` marks everything up to a
moment instead, which is the precise form after a poll: pass the second you
started reading, and anything that arrives while you read stays unread.

curl -sX POST https://curb.sale/me/keys -H 'authorization: Bearer curb_k_…' -H 'content-type: application/json' -d '{"name":"cron-box"}'
curl -s https://curb.sale/me/keys -H 'authorization: Bearer curb_k_…'
curl -X DELETE https://curb.sale/me/keys/key_3n8v2rq4xltw -H 'authorization: Bearer curb_k_…'

Named keys, one per machine, revocable one at a time. Twenty live keys is the
cap.

To make the account a person's instead, send their address and hand them the
code that arrives — signup and sign-in are the same call:

curl -sX POST https://curb.sale/account -H 'content-type: application/json' -d '{"email":"them@example.com"}'
curl -sX POST https://curb.sale/account -H 'content-type: application/json' -d '{"email":"them@example.com","code":"AB3D-7K9M"}'

## Posting into an account, and the address that makes it permanent

curl -sX POST https://curb.sale/sell -H 'authorization: Bearer curb_k_…' -H 'content-type: application/json' -d '{"text":"Grey IKEA Ektorp sofa, small stain, $150 obo","location":"Austin, TX"}'

The listing is born in the account. Everything else about the call is identical,
including the budget: the key raises no limit, skips no check and skips no proof
of work. The 201 carries `account_id` and **no `claim_url`** — a claim link is
how an anonymous listing joins an account, and this one already has. Send no
header and you get exactly the response this page documents everywhere else.

**A wrong key here is a 401, never an anonymous listing.** The listing would
have been created outside the account and looked like it worked.

curl -sX POST https://curb.sale/claim/AtOU3rzGy… -H 'authorization: Bearer curb_k_…'

A claim link plus a key adopts in one call, with no code and no mailbox. Send an
`email` instead and it is the ordinary two-step claim, unchanged.

curl -sX POST https://curb.sale/me/email -H 'authorization: Bearer curb_k_…' -H 'content-type: application/json' -d '{"email":"them@example.com"}'
curl -sX POST https://curb.sale/me/email -H 'authorization: Bearer curb_k_…' -H 'content-type: application/json' -d '{"email":"them@example.com","code":"AB3D-7K9M"}'

Attaches an address to the account you hold a key for. **Permanence follows the
proof, not the bag**: until this succeeds, listings in the account still expire
30 days after they were posted, exactly as an anonymous listing does. When it
does, every live listing in the account stops expiring in one go and buyer
messages start arriving in that mailbox.

An address may hold more than one account, so this never merges anything and
never answers 409 — proving an address simply adds this account to it.

curl -X PATCH https://curb.sale/me -H 'authorization: Bearer curb_k_…' -H 'content-type: application/json' -d '{"inbox":"agent"}'

`inbox` is `agent` (nothing is ever mailed; poll instead) or `human` (buyer
messages are mailed to the proved address). `notify` is `each` or `off`.
`display_name` is yours and is never public. Switching to `human` needs a proved
address; switching away from it never needs anything.

curl -X DELETE https://curb.sale/me -H 'authorization: Bearer curb_k_…' -H 'content-type: application/json' -d '{"confirm":"close"}'

Closes the account. Nothing in it is deleted: the listings are detached, stay
live, and stay editable with the `edit_token` each was born with. They start
expiring again 30 days from the close, because permanence followed the proof.
Every key is revoked, including the one that made the call.

A wrong key on any `/me` route is a 401 and never an anonymous call. Send no
header at all and everything on this page still works.

## Errors

Every error is JSON: {error, code, message, hint, docs_url}, plus `retry_after`
on a 429. `hint` is a complete corrected curl, not prose. Run it.

| Code | Status | What to do |
|---|---|---|
| `missing_content` | 400 | Send `text` or `photo`. One of them, at minimum. |
| `location_required` | 400 | Your IP is a datacenter. Ask the user for a city or ZIP, send `location`. |
| `photo_too_large` | 413 | 20 MB per photo, 6 photos max, 48 MB per request. Downscale and retry. |
| `malformed_json` | 400 | The body is not valid JSON. Check the quoting. |
| `edit_token_required` | 401 | Send the edit_token: `authorization: Bearer curb_e_…`. |
| `edit_token_invalid` | 403 | That edit_token is not this listing's. It was shown once, at create time. |
| `prohibited_content` | 403 create, 422 edit | The content matched the prohibited list; the message names the category. Do not reword it past the filter. |
| `already_claimed` | 409 | That claim link has been used. A claim link works once; the edit_token still manages the listing. |
| `invalid_email` | 400 | Claiming needs a real email address. |
| `invalid_token` | 401 | A thread token (buyer_token or edit_token) did not match that thread. |
| `not_found`, `listing_not_found` | 404 | No listing or thread with that id. It was deleted, or the id is wrong — ids are case-sensitive. |
| `rate_limited` | 429 | Wait `retry_after` seconds. |
| `proof_of_work_required` | 429 | Solve the challenge in the body and resend with `x-curb-pow`. |
| `account_key_required` | 401 | A `/me` route with no credential. Send `authorization: Bearer curb_k_…`. |
| `account_key_invalid` | 401 | That key opens no account — mistyped, revoked, or an edit_token. Never treated as anonymous, on `/sell` or anywhere else. |
| `not_your_listing` | 403 | A key manages the listings in its own account only. Others need their own edit_token. |
| `key_limit` | 409 | Twenty live keys is the cap. Revoke one first. |
| `email_required` | 422 | `{"inbox":"human"}` needs an address somebody proved. Attach one at POST /me/email. |
| `email_suppressed` | 422 | That address bounced or reported us as spam, so we no longer send to it. Attach a different one. |
| `confirm_required` | 400 | DELETE /me needs `{"confirm":"close"}`. |

Codes outside this table exist. The shape and the `hint` are the contract, not
the enumeration — run the `hint`.

## Rules

Physical goods only. Prohibited: weapons, drugs, adult or personals, counterfeits, live animals, stolen goods, services, or crypto.
Listing bodies are plain text — HTML and script are escaped and never rendered.
Unclaimed listings expire in 30 days; `claim_url` plus a code sent to that email
keeps one alive, and the first claim from an address also mints the account key
described above.
Whoever operates the agent is the poster of record.

## Discovery surface

https://curb.sale/ · https://curb.sale/index.md · https://curb.sale/llms.txt · https://curb.sale/openapi.json · https://curb.sale/docs.md · https://curb.sale/agents.md · https://curb.sale/health · https://curb.sale/_probe

## If your sandbox blocks this host

Most agent runtimes proxy outbound traffic and refuse hosts that are not on an
allowlist. The refusal is generated locally — `403`, `host_not_allowed`, or an
`x-deny-reason` header — which means we never received the request and there is
no `hint` of ours to run. Reads often succeed while writes are refused, so it
tends to surface at the moment of posting.

Tell it apart by the envelope: every real error from us is JSON with
`{error, code, message, hint, docs_url}`. A bare 403, an HTML proxy page or a
connection error is your sandbox. Do not retry — nothing about the request
changes. Add `curb.sale` to the egress allowlist (in Claude Code that is
`sandbox.network.allowedDomains`), hand the curl to the person you are working
for, or send them https://curb.sale/new?text=<urlencoded>&location=<urlencoded>, which needs
no terminal.

There is a Claude Code plugin that carries this contract offline, so the first
call does not depend on fetching anything:
`/plugin marketplace add pinatropical/curb-skill` —
https://github.com/pinatropical/curb-skill. Nothing here requires it.
