API writes
Writes act for a signed-in user. Sign in first: Authentication.
API=https://example.com/api/v1TOKEN=sca_… # an access token, or a personal key| Endpoint | Scope | Does |
|---|---|---|
POST /listings |
listings:write |
Post a listing. 201. |
PATCH /listings/{id} |
listings:write |
Edit your listing. |
DELETE /listings/{id} |
listings:delete |
Delete your listing. 204. |
POST /photos |
listings:write |
Upload a photo before its listing exists. 200 with a token, no Location. |
POST /listings/{id}/photos |
listings:write |
Add a photo to your listing. 201. It counts as an edit (listing.updated). |
GET /listings/{id}/photos/{photo} |
listings:read |
One photo. The Location of an added photo. |
DELETE /listings/{id}/photos/{photo} |
listings:write |
Remove a photo. 204. |
POST /listings/{id}/comments |
comments:write |
Comment on a listing. 201. |
GET /comments/{id} |
listings:read |
One comment: an approved one on a listing you can see, or your own. The Location of a new comment. |
DELETE /comments/{id} |
comments:write |
Delete your own approved comment. 204. |
GET /account/alerts |
alerts:read |
Your saved searches. |
POST /account/alerts |
alerts:write |
Save a search. |
GET /account/alerts/{id} |
alerts:read |
One saved search. The Location of a new one. |
DELETE /account/alerts/{id} |
alerts:write |
Stop a saved search. 204. |
POST /users |
none | Sign up, when the site allows it. 201. The password needs at least 5 characters. |
A write that is not allowed gets 403. A user who is not the owner of a live listing gets
403 not_owner. A listing the user cannot see is a 404.
What every write does
Section titled “What every write does”- Bodies are JSON. Send
Content-Type: application/json. Bodies are limited to 1 MB; photo uploads to 16 MB. An unknown member is a422, not ignored. - A
201has aLocationheader with the new resource’s address, and aGETof that address works. See Location targets. - The saved resource is in
data, in the owner’s view. warningssits next todatawhen something did not go as asked. See Warnings.- One transaction. A listing save that fails part way leaves nothing behind.
- The same rules as the web forms. Moderation, the listing limit, spam checks, bans, e-mails and plugin hooks all run, because the API calls the same code.
- Write calls also count in the
api_writebucket.
Location targets
Section titled “Location targets”Every Location can be read with GET.
| Created by | Location |
|---|---|
POST /listings |
/listings/{id} |
POST /listings/{id}/photos |
/listings/{id}/photos/{photo} |
POST /listings/{id}/comments |
/comments/{id} |
POST /account/alerts |
/account/alerts/{id} |
POST /account/keys |
/account/keys/{id} |
POST /users has no Location either: see Sign up.
POST /photos answers 200, not 201, and has no Location. A staged token is not a
resource you can read back.
Post a listing
Section titled “Post a listing”curl -i -X POST $API/listings \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6f1c2a80-4d1e-4a53-9c6b-0d9a8f3e51aa" \ -d '{ "category_id": 12, "title": "Vintage Hardcover Classics Collection", "description": "20 books in good condition.", "price": "90.00", "currency": "USD", "country": "US", "city": "Austin", "contact_phone": "+1 555 0100", "show_email": false, "custom_fields": { "4": "Blue" }, "photo_tokens": ["9f2b6c1e0a7d4e3b8c5a1d2e3f405162"] }'HTTP/1.1 201 CreatedLocation: https://example.com/api/v1/listings/412{ "data": { "id": 412, "status": "pending", "title": "Vintage Hardcover Classics Collection", "…": "…" }, "warnings": [ { "code": "listing_pending", "message": "The listing goes live once it is activated or approved." } ]}Required: category_id, title, description.
| Member | Type | Notes |
|---|---|---|
category_id |
integer | A category of the site. |
title |
string, up to 1000 | In locale, or the site’s default language. |
description |
string, up to 20000 | The same. |
locale |
string | The language of title and description, such as en_US. Must be one of the site’s languages. |
translations |
object | {"de_DE": {"title": "…", "description": "…"}} for other languages. |
price |
string, number or null |
A decimal such as "12.50". null for no price. |
currency |
string | Three capitals, such as USD. |
country |
string | A two-letter code. Empty clears it. |
region_id, city_id |
integer or null |
Ids from GET /countries/…/regions and the like. An unknown id is a 422. |
region, city |
string | A name, when there is no id. |
city_area, address, zip |
string | |
lat, lng |
number or null |
|
contact_phone |
string, up to 45 | |
show_email |
boolean | Show the seller’s e-mail on the listing. |
custom_fields |
object | Custom field id to value. See below. |
photo_tokens |
array, up to 50 | Tokens from POST /photos, added in this order. |
photo_urls |
array, up to 20 | Web addresses the site downloads, when the site allows it. |
ext |
object | Plugin members, under the plugin’s slug. |
custom_fields are limited to the listing’s category and cleaned as the web form cleans them.
A field of another category is dropped without an error, so an edit can move the listing. An id
that is no field is a 422 with unknown, and a value of the wrong shape one with type. A date is "2026-01-31" or an
RFC 3339 date-time, and a date range is {"from": "2026-01-01", "to": "2026-01-31"}. A date that
cannot be read is a 422. null removes a value.
Plugins can change the body before it is saved with the api_listing_input filter.
What can refuse a post
Section titled “What can refuse a post”| Answer | Why |
|---|---|
422 validation_failed |
A bad member, or the web form’s own refusal: a missing field, or the posting wait. errors points at the member. |
422 listing_limit |
The user has reached the site’s listing limit. detail says which. |
403 banned |
The account or address is banned from posting. |
403 wrong_credential |
The site lets only signed-in users post, and the caller is not one. |
429 rate_limited |
Over the hourly cap. |
Edit a listing
Section titled “Edit a listing”PATCH sends only what changes. Everything not sent keeps its value.
curl -X PATCH $API/listings/412 \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/merge-patch+json" \ -d '{ "price": "75.00", "custom_fields": { "4": null } }'Send application/merge-patch+json or application/json. The answer repeats Accept-Patch.
An empty patch ({}) changes nothing and answers 200 with the current resource. This holds for every PATCH except PATCH /admin/areas/{id}, where name is required.
The patch applies to the write shape above, the same members POST takes, not to the
nested shape a GET answers with. Send "price": "75.00", not "price": {"amount": "75.00"}.
| In the body | Effect |
|---|---|
| A member | Replaces that value. |
null on any member that can be empty: price, the location members, contact_phone, lat, lng |
Clears it, as "" does. |
translations: {"de_DE": null} |
Removes that language. |
custom_fields: {"4": null} |
Removes that custom field value. |
translations |
Merges by locale. Other locales stay. |
photo_tokens, photo_urls |
Adds photos. It never replaces or removes any. |
The answer is the saved listing, with 200. If the site holds edited listings for review,
the status becomes disabled until an admin approves it and warnings says listing_pending.
An edit to a listing still waiting for its activation link stays pending and also says
listing_pending.
Write members and where they read back
Section titled “Write members and where they read back”A write body is flat; the listing you read back nests some of the same values. A GET
cannot be sent back as a PATCH as it is: map these members.
| Write member | Reads back as |
|---|---|
category_id |
category.id |
price, currency |
price.amount, price.currency |
country |
location.country.code |
region_id, region |
location.region.id, location.region.name |
city_id, city |
location.city.id, location.city.name |
city_area |
location.city_area.name |
address, zip, lat, lng |
location.address, location.zip, location.lat, location.lng |
contact_phone |
contact.phone |
custom_fields: {"4": "Blue"} |
custom_fields: [{"id": 4, "value": "Blue", …}] |
owner_id (admin) |
seller.id |
contact_name, contact_email (admin) |
contact.name, contact.email |
A comment’s author_name and author_email read back as author.name and author.email.
Delete a listing
Section titled “Delete a listing”curl -X DELETE $API/listings/412 -H "Authorization: Bearer $TOKEN"204, no body. The listing and its photos are removed as the web delete does it.
Photos
Section titled “Photos”A photo is checked as the listing form checks it: a type the site accepts (JPEG, PNG, GIF or WebP), an image that decodes, within the pixel limit and the site’s largest photo size.
Upload first, then attach
Section titled “Upload first, then attach”curl -X POST $API/photos \ -H "Authorization: Bearer $TOKEN" \{ "data": { "token": "9f2b6c1e0a7d4e3b8c5a1d2e3f405162", "expires_at": "2026-10-03T11:12:00Z" } }Send the file one of two ways:
| Way | How |
|---|---|
| Multipart | multipart/form-data, the file in a field named photo. |
| Raw | The image itself as the body, with Content-Type: image/jpeg (or image/png, image/gif, image/webp). |
curl -X POST $API/photos -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: image/jpeg" --data-binary @books.jpg| Rule | Detail |
|---|---|
| Token life | Two hours. The cron removes older photos. |
| Whose | A token works only for the user who uploaded it. An unknown, expired or foreign token is a 422 on /photo_tokens/<n>. |
| Waiting | A user may hold 50 staged photos at once. The 51st is a 422. |
| Size | The site’s largest photo size, and never over 16 MB. Bigger is 413 too_large. |
| Wrong type | 415 unsupported_media_type when the request is neither multipart nor an image. A file that is not an allowed image, or has too many pixels, is 422. |
The token is used once. After the listing saves, it is gone. If the save fails, it stays valid until it expires.
Add to a listing you already have
Section titled “Add to a listing you already have”201 with the photo (id, thumbnail, preview, normal, original) and a Location
header. If the listing already holds as many photos as the site allows, it is a 422 on
/photo. If the site holds edited listings for review, adding a photo sends the listing back
to review.
curl -X DELETE $API/listings/412/photos/881 -H "Authorization: Bearer $TOKEN"204. A photo id that is not on this listing is a 404.
How many photos a listing holds
Section titled “How many photos a listing holds”The site sets a maximum number of photos per listing. When photo_tokens or photo_urls name more than the
listing has room for, the listing still saves with the first ones, and warnings carries
photo_skipped with the count.
Photos by address
Section titled “Photos by address”Off by default. The site owner switches it on with Let new listings name photos by web
address. With it off, photo_urls is a 422 on /photo_urls: upload to /photos instead.
{ "category_id": 12, "title": "…", "description": "…", "photo_urls": ["https://cdn.example.com/books.jpg"] }The site downloads each address itself:
- Only public
httpandhttpsaddresses on their usual ports. Private and local addresses are refused. - Redirects are not followed. A download stops after 15 seconds, when it stays below 1 KB a second for 5 seconds, and at the site’s largest photo size.
- Up to 4 downloads of one request run at the same time; the next starts as one finishes. All of them must finish within 30 seconds. A server without PHP’s cURL extension refuses every address.
- One bad address fails the whole request with a
422on/photo_urls/<n>. Nothing is saved. A download that fails says only that the photo could not be downloaded, and a refused address only that the site does not download from it. - Downloads happen before an
If-Matchcheck, so a412can still cost a download. A write that is refused after the downloads keeps none of them. - Each download counts toward 30 an hour per user on
PATCH. See Limits.
Comments
Section titled “Comments”curl -i -X POST $API/listings/412/comments \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "title": "Still available?", "body": "Can you ship to Canada?" }'201 with the comment and Location: …/comments/9.
{ "data": { "id": 9, "listing_id": 412, "title": "Still available?", "body": "Can you ship to Canada?", "author": { "name": "Jane Doe", "user_id": 23 }, "published_at": "2026-10-03T09:30:00Z" }, "warnings": [ { "code": "comment_pending", "message": "The comment shows once it is approved." } ]}| Rule | Detail |
|---|---|
body |
Required, 1 to 5000 characters. A body that is only tags or spaces is a 422. |
title |
Optional, up to 200. |
| Author | Taken from the account. You cannot set a name or e-mail. |
| Moderation | If the site approves comments first, or the spam check holds one, you get comment_pending. |
| Refusals | 403 when comments are off, or the account is banned. 404 when the listing is pending, disabled or spam. |
| Delete | DELETE /comments/{id} removes your own approved comment. Someone else’s is 403; one still waiting for approval is 409. |
Saved searches
Section titled “Saved searches”A saved search mails the user new matches. It takes the filters of GET /listings.
curl -i -X POST $API/account/alerts \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "filters": { "q": "bicycle", "category": "for-sale", "price_max": 300, "with_photos": true } }'{ "data": { "id": 5, "filters": { "q": "bicycle", "category": [1], "price_max": 300, "with_photos": true }, "type": "daily", "active": true, "created_at": "2026-10-03T09:40:00Z" }}| Rule | Detail |
|---|---|
| Filters | q, category, country, region, city, city_area, user, locale, price_min, price_max, with_photos, premium, custom_field. At least one. |
| Same search again | Answers 200 with the saved one, not a second 201. |
| Limit | A user keeps at most 20 saved searches; the site owner can change it. One more is 422 validation_failed at /. The hourly alert limit in Spam and bots also applies: over it is 429 rate_limited. |
type |
How often it mails: daily or weekly. |
| List and stop | GET /account/alerts, DELETE /account/alerts/{id} (204). |
| Shown on the web | The same search appears on the account’s alerts page. |
Sign up
Section titled “Sign up”Off by default. The site owner switches on Allow sign-up through the API, and the site must have registration on. The password needs at least 5 characters.
curl -i -X POST $API/users -H "Content-Type: application/json" \{ "data": { "confirmed": false } }confirmed is false until the link in the activation e-mail is opened. The user cannot sign in
before that: the sign-in answers as for a wrong password. An e-mail that already has an account
gets the same 201 and body, and no account is made, so sign-up does not tell which addresses
are taken. A username sent with a taken
e-mail is held as a new account would hold it, so a later sign-up meets it taken either way.
Optional: username, phone_land, phone_mobile. A site that is off answers
403 feature_disabled. A banned e-mail or address answers 403 banned.
The sign-up e-mails are sent from the job queue, so they arrive when cron next runs. This keeps a new address from answering slower than a taken one. The queue holds only the account id for the activation e-mail; its link is made when the job runs.
The hiding needs the activation e-mail. If the site switches off Users need to validate their
account, a new account could sign in at once and a taken e-mail could not, so the hiding is off:
a taken e-mail is 422 with “The specified e-mail is already in use”, and the new account’s
e-mails go out at once.
There is no captcha to show, so sign-up is limited: 5 per address and 100 for the whole site an hour (the site’s number is a setting). Only a sign-up that passed its checks counts for the site. If the site cannot count, it refuses.
Editing safely with If-Match
Section titled “Editing safely with If-Match”To avoid overwriting a change made meanwhile, send the ETag of your last GET in
If-Match on a PUT, PATCH or DELETE. If the resource changed you get 412 precondition_failed.
If-Match: *only checks that the resource exists. If it is gone, the write answers as itsGETdoes (404) and runs nothing.- A tag a proxy made weak (
W/"…") still matches. - A caller who can
PATCHorDELETEa listing, comment, photo, the account, a user, key, saved search, category, field, currency, location, webhook or the settings gets anETagthat holds the stored version ("<version>.<hash>"). AnyGETof the path works, whatever itsfields,includeorlocale. For a listing or comment that means its owner, or an admin key. A listing’s version covers its texts, location, custom fields and photos. - Other callers get a plain hash of the body. Use the tag of the plain
GET, with nofieldsorinclude. - The check and the write run as one, so no other write can land between them.
- A
PATCHsent withIf-Matchanswers with the newETag, ready for the next edit. - A credential that cannot read the path’s
GETis refused, as the check cannot be made. A resource it cannot see answers404, never412. - Without
If-Matchthe write is never refused.
Retrying safely with Idempotency-Key
Section titled “Retrying safely with Idempotency-Key”A network drop after a POST leaves you not knowing if it worked. Send an Idempotency-Key
and retry the same call: the server runs it once.
curl -X POST $API/listings -H "Authorization: Bearer $TOKEN" \ -H "Idempotency-Key: 6f1c2a80-4d1e-4a53-9c6b-0d9a8f3e51aa" … # same call, any number of times| Rule | Detail |
|---|---|
| Where | POST, PUT, PATCH, DELETE with any credential. Anonymous calls ignore it. |
| Key | 1 to 255 visible ASCII characters. A random UUID is fine. Otherwise 400 invalid_header. |
| Kept | 24 hours. Keys belong to one sign-in (across its refreshed tokens) or one key. Another user cannot use yours. |
| Replay | The same status, headers and body come back, plus Idempotency-Replayed: true. |
| Different request | The same key with another method, path, query, If-Match or body is 422 idempotency_key_reused. |
| Still running | The same key while the first call runs is 409 idempotency_in_flight, with Retry-After: 1. A call that died is retried only after the longest a PHP request may run. |
| Errors | A 5xx or 429 is not kept: retry runs again. Any other 4xx is kept and replays. Fix the request and use a new key. |
| Not covered | POST /auth/token, POST /account/password and POST /account/keys. They carry secrets, so they never replay. |
Limits
Section titled “Limits”| What | Limit | Over it |
|---|---|---|
| Requests | The usual limits, and 30 writes a minute | 429 rate_limited |
| New listings | Per user an hour: the site’s setting, or 30 when it is 0 (10 while the listing form asks for a captcha). Per address: three times that. |
429 rate_limited |
| Comments | 20 an hour per user, counted with the ones they post on the site’s comment form | 429 rate_limited |
| Photos fetched by address | 30 an hour per user by default (a setting), counted on PATCH |
429 rate_limited |
| E-mail changes on the account | 5 an hour per user | 429 rate_limited |
| Saved searches | The site’s hourly alert limit, counted with the web form | 429 rate_limited |
| Staged photos waiting | 50 per user | 422 on /photo |
photo_tokens / photo_urls in one body |
50 / 20 | 422 validation_failed |
| Sign-ups | 5 an hour per address; 100 for the site by default (a setting), counting only those that passed their checks | 429 rate_limited |
Each 429 has Retry-After. The site owner sets the listing number: Set up the REST API.
Account
Section titled “Account”| Endpoint | Scope | Does |
|---|---|---|
GET /account |
account:read |
Your profile, in the owner’s view. |
PATCH /account |
account:write |
Edit the profile: name, website, phone_land, phone_mobile, country, region_id, city_id, city_area, address, zip, lat, lng, is_company, email (with current_password). A website that is not a web address is a 422. |
GET /account/listings |
account:read |
Your listings in any status, newest first, as your listings page shows them. Filters: status (active, pending, disabled, expired, spam), limit, cursor, count. |
POST /account/password |
account:write |
Change the password. The new one needs at least 5 characters, as on the site’s forms; shorter is a 422 on /new_password. |
GET /account/sessions |
account:read |
Your live sign-ins. Keys are at /account/keys. |
DELETE /account/sessions/{id} |
account:write |
End one sign-in. 204. |
GET, POST, DELETE /account/keys |
account:write |
Personal keys. |
A new email needs current_password in the same body, or the answer is a 422 on
/current_password. It is not applied at once. A confirmation link goes to the new address, and
the answer carries the warning email_confirmation_sent. The e-mail changes when the link is opened.
Warnings
Section titled “Warnings”warnings is a list of { "code", "message" }. A warning is not an error: the write worked.
| Code | On | Meaning |
|---|---|---|
listing_pending |
POST, PATCH /listings |
The listing is not live yet. It needs approval or activation. |
photo_skipped |
POST, PATCH /listings |
Some photos were not added: the listing holds as many as it may. |
comment_pending |
POST …/comments |
The comment shows once it is approved. |
email_confirmation_sent |
PATCH /account |
A link went to the new address. |
Errors are in API errors.