# Jaotin listing API — for AI assistants

Base URL: `https://web-staging-732d.up.railway.app/api`
All request and response bodies are JSON unless stated. All error messages are
Thai, written for the MEMBER to read — pass them through rather than rewording
them.

## 1. Authenticate

Every call below carries a personal API token:

    Authorization: Bearer jtn_<48 hex characters>

The member creates one on their own profile page — https://web-staging-732d.up.railway.app/profile, the
"API Token" card — and it is shown ONCE, in the create response, and can never
be refetched. Ten active tokens per member; revoking one takes effect on the
next request.

If you do not have a token, stop and ask the member for one. Do not attempt any
other authentication: there is none for this API.

## 2. What a token may NOT do, and why

Read this before you write anything, because otherwise the refusals below will
look like bugs and you will retry them.

A token is a MACHINE credential. It may draft, edit its own drafts, add photos,
duplicate and close. It may **never**:

| Action | HTTP | What you get |
|---|---|---|
| Publish (`/submit`) | 403 | `API Token ทำรายการนี้ไม่ได้ (เผยแพร่ประกาศ)` |
| Create anything that is not a draft | 403 | `API Token สร้างได้เฉพาะแบบร่าง (draft: true)` |
| Edit a listing that is already published | 403 | `API Token ทำรายการนี้ไม่ได้ (แก้ไขประกาศที่เผยแพร่แล้ว)` |
| Bump, renew, reopen, buy Spotlight | 403 | `API Token ทำรายการนี้ไม่ได้` |
| Create, list or revoke API tokens | 403 | `ต้องเข้าสู่ระบบบนเว็บเจ้าถิ่น` |
| Delete the account, top up credits, write a review | 403 | `ต้องเข้าสู่ระบบบนเว็บเจ้าถิ่น` |

A credential may not manage credentials, and that is not an oversight to route
around: if a token could mint another token, revoking a leaked one would
accomplish nothing. If the member needs a new token, they make it on their
profile page themselves.

These are not rate limits and retrying will not help. **Publishing is the
member's action, on the website.** When a draft is ready, send them to
https://web-staging-732d.up.railway.app/post?edit=<id> and say what is left to check. That is the end of your
job, not a failure of it.

You may also hold at most **20 open drafts** at a time. Past that,
creating another answers 400 — the member has to review, publish or delete some
first. It is self-clearing, not a permanent ceiling.

## 3. Create a draft

    POST /me/listings
    { "title": "...", "dealType": "rent", "propertyType": "condo",
      "price": 18000, "zone": "อโศก", "draft": true }

`draft: true` is REQUIRED. Without it the call is refused, not silently
downgraded.

| Field | Required | Notes |
|---|---|---|
| `title` | yes | 6–120 chars. Thai. No phone numbers, no URLs, no markup — the server refuses them and says where the phone goes instead. |
| `dealType` | yes | `sale` · `rent` · `lease` · `down` |
| `propertyType` | yes | `condo` · `house` · `townhome` · `land` · `office` · `apartment` · `shophouse` · `duplex` · `homeoffice` · `retail` · `showroom` · `hospitality` · `factory` · `coworking` · `poolvilla` |
| `price` | yes | Baht. Per MONTH when `dealType` is `rent`. |
| `zone` | yes | The ย่าน a Thai buyer would name out loud ("อโศก", "นิมมาน"). Free text; an unknown name is minted and flagged for review. |
| `description` | no | Up to 4000 chars. Same content rules as the title. |
| `sizeSqm`, `bedrooms`, `bathrooms`, `floor` | no | Numbers. |
| `province`, `district`, `subdistrict` | no | The ADMINISTRATIVE address, a different thing from `zone`. |

**Write the address the way a Thai person writes it.** "เขตวัฒนา" and "วัฒนา",
"อ.เมืองเชียงใหม่" and "เมืองเชียงใหม่", "แขวงคลองเตยเหนือ" and "คลองเตยเหนือ"
are all accepted; the server stores the canonical spelling either way. Do not
strip the prefix yourself and do not add one.

**About the address.** `province` is what puts a listing on
https://web-staging-732d.up.railway.app/zone/{จังหวัด}/... — without it the listing is invisible to every
province landing, which is where upcountry demand arrives. The server validates
the whole chain against the official register: a district that is not in its
province is a 400, and the postal code is derived server-side and never read
from you. So filling it is safe — a wrong address cannot be stored, only
refused. **Filling it by guessing is not:** if the member has not said where the
property is, ask. Do not infer a district from a project name.

**You cannot set coordinates.** There is no lat/lng field on purpose: a guessed
pin drops a listing on the wrong street. Every draft you create arrives without
a map, and only the member can place it, in the editor.

Response: `201` with `{ listing: {...} }`. Keep `listing.id`.

## 4. Read it back

    GET /me/listings

Returns `{ rows: [{ listing, status, views30, ... }], stats, balance }` for the
whole account. **There is no `GET /me/listings/:id`** — to inspect one
listing, fetch this and find it by `listing.id`. This is also the payload the
member's own screen renders, so what you see is what they see.

## 5. Edit a draft

    PUT /me/listings/<id>
    { "title": "...", "price": 21000, "description": "..." }

**This is a WHOLE-LISTING write, not a patch.** `title` and `price` are
required on every call. An absent optional field keeps its stored value, but do
not rely on that for the two required ones: read `GET /me/listings` first,
merge your changes onto the row you find, and send the result. Sending only the
field you meant to change will fail with `กรอกหัวข้อและราคาให้ครบ`.

Drafts only. A published listing answers 403 — see section 2.

## 6. Photos

    POST /me/listings/<id>/photos
    Content-Type: multipart/form-data
    photos=@bedroom.jpg  photos=@kitchen.jpg

Up to **4 files per call**, **10MB each**, **12 per
listing**. Repeat the call for more. Full resolution is fine and preferred — the
bytes travel over HTTPS and never through your context, so size is not your
problem here. HEIC from an iPhone is accepted; everything is converted to WebP,
resized and watermarked server-side.

Drafts only, same rule as editing.

If you cannot reach the files — you are a browser-only assistant with no shell
and no filesystem — do NOT try to send image bytes as JSON. Ask the member to
add the photos themselves at https://web-staging-732d.up.railway.app/post?edit=<id>.

The same endpoint still accepts `{ "images": ["data:image/png;base64,..."] }`
for a small generated sketch. It is not for photographs: a 3MB photo is ~4MB of
base64 that you would have to emit token by token.

## 7. The rest

| Call | Effect |
|---|---|
| `POST /me/listings/<id>/duplicate` | A copy of the property as a fresh draft, titled "… (สำเนา)". Keeps the description, photos, address and nearby stations; drops the publishing state, the counters and any paid placement. |
| `DELETE /me/listings/<id>` | Deletes a DRAFT. Anything published refuses — it has a paid history and public traces. |
| `POST /me/listings/<id>/close` | Takes a live listing down. Allowed: it removes something from the public rather than adding it. |
| `GET /me` | The member's name, level and credit balance. |

## 8. Errors

`400` — the request is wrong, and the Thai message says how. Read it; it is
usually a missing required field or a value outside an allowlist.
`401` — no token, a malformed one, or a revoked one. Do not retry; ask the
member for a new token.
`403` — you are not allowed to do this AT ALL, ever, with this credential.
Retrying differently will not help. Section 2 lists them.
`404` — not the member's listing, or it does not exist.

## 9. What "done" looks like

A draft with a title, a price, a location, a description and real photos, and a
message to the member telling them what to check and where to publish it:
https://web-staging-732d.up.railway.app/post?edit=<id>. You do not publish. That is not a limitation to work
around — it is the reason a member can safely give an assistant a token.
