Skip to content

API writes

Writes act for a signed-in user. Sign in first: Authentication.

Terminal window
API=https://example.com/api/v1
TOKEN=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.

  • Bodies are JSON. Send Content-Type: application/json. Bodies are limited to 1 MB; photo uploads to 16 MB. An unknown member is a 422, not ignored.
  • A 201 has a Location header with the new resource’s address, and a GET of that address works. See Location targets.
  • The saved resource is in data, in the owner’s view.
  • warnings sits next to data when 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_write bucket.

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.

Terminal window
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 Created
Location: 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.

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.

PATCH sends only what changes. Everything not sent keeps its value.

Terminal window
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.

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.

Terminal window
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.

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.

Terminal window
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).
Terminal window
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.

Terminal window
curl -X POST $API/listings/412/photos -H "Authorization: Bearer $TOKEN" -F [email protected]

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.

Terminal window
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.

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.

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 http and https addresses 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 422 on /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-Match check, so a 412 can 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.
Terminal window
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.

A saved search mails the user new matches. It takes the filters of GET /listings.

Terminal window
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.

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.

Terminal window
curl -i -X POST $API/users -H "Content-Type: application/json" \
-d '{ "name": "Jane Doe", "email": "[email protected]", "password": "…" }'
{ "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.

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 its GET does (404) and runs nothing.
  • A tag a proxy made weak (W/"…") still matches.
  • A caller who can PATCH or DELETE a listing, comment, photo, the account, a user, key, saved search, category, field, currency, location, webhook or the settings gets an ETag that holds the stored version ("<version>.<hash>"). Any GET of the path works, whatever its fields, include or locale. 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 no fields or include.
  • The check and the write run as one, so no other write can land between them.
  • A PATCH sent with If-Match answers with the new ETag, ready for the next edit.
  • A credential that cannot read the path’s GET is refused, as the check cannot be made. A resource it cannot see answers 404, never 412.
  • Without If-Match the write is never refused.

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.

Terminal window
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.
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.

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 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.