Skip to content
You are reading the docs for ShopClass 7.1.0.dev1, still in development. Read the stable docs.

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.
GET /account/conversations conversations:read Your conversations, newest activity first. meta has your unread total.
POST /account/conversations conversations:write Write to a listing’s seller. 201.
GET /account/conversations/{id} conversations:read One of your conversations. The Location of a new one.
GET /account/conversations/{id}/messages conversations:read Its messages, newest first, or the newer ones with after. Marks nothing read.
POST /account/conversations/{id}/messages conversations:write Reply. 201.
POST /account/conversations/{id}/read conversations:write Mark it read.
POST /account/conversations/{id}/block conversations:write Block it: nobody writes in it until you unblock.
DELETE /account/conversations/{id}/block conversations:write Lift your own block.
POST /account/conversations/{id}/report conversations:write Report it to the admins.
GET /cart/quote listings:read Price a cart. Needs no sign-in.
GET /account/orders orders:read Your orders, newest first.
POST /account/orders orders:write Place an order. 201.
GET /account/orders/{id} orders:read One of your orders. The Location of a new one.
POST /account/orders/{id}/pay orders:write Start paying a pending card order again; next says where to go.
POST /account/orders/{id}/cancel orders:write Cancel a pending bank transfer or cash on delivery order.
GET /account/cart cart:read Your saved cart: variant ids and quantities, never prices.
PUT /account/cart cart:write Replace your saved cart. A variant that cannot be bought is dropped. Up to 50 lines, 20 of each. 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/orders /account/orders/{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." }
]
}

A site that sells listing slots may save the listing without making it live. Then data.status is unpaid, the listing_unpaid warning is set, and payment says what it costs. The listing is hidden from everyone but its owner until it is published. Its published_at is when it was saved and its expires_at is the payment deadline.

{
"data": { "id": 412, "status": "unpaid", "title": "Vintage Hardcover Classics Collection", "…": "…" },
"warnings": [
{ "code": "listing_unpaid", "message": "The listing is saved and waits for payment. Publish it with credits or at payment.publish_url." }
],
"payment": { "credits": 2, "balance": 0, "shortfall": 2, "publish_url": "https://example.com/…" }
}

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 and the site does not take payment at the end of posting. detail says which. Otherwise the listing is saved and payment says what it costs.
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.
403 feature_disabled The site is a shop: only an admin adds listings.
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 listing with status: "unpaid" waits for payment. POST /listings/{id}/publish puts it live. Only its owner may call it, with a credential that holds both listings:write and orders:write: either path spends money.

Terminal window
curl -i -X POST $API/listings/412/publish \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $(uuidgen)"

With an empty body the site takes the listing’s credits from the user’s balance. To buy the credits that are missing, send a package and a payment method. The site starts an order, as POST /account/orders does. Follow next to pay, then watch GET /account/orders.

{ "package": 5, "gateway": "offline" }
Answer When
200 The listing is published. data.status is active, or pending if the site checks new listings.
200 The listing was already published. Nothing is spent.
200 with order and next A package and gateway were sent. The order is started and the listing stays unpaid until it is paid. payment is set.
422 insufficient_credits The balance is too low. payment says the price and the shortfall.
403 insufficient_scope The credential lacks orders:write.
404 Not your listing, or it does not exist.

payment.publish_url is the site’s pay page. It needs the seller signed in on the site, so an app should use the order path instead.

A repeat with the same Idempotency-Key gets the first answer back. Starting orders is limited to 20 an hour per user: 429 rate_limited. A refused request is not counted. listing.created is sent to webhooks when the listing goes live.

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, spam or unpaid.
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.

Messages between a buyer and a listing’s seller. Every call acts as the signed-in user, so a conversation you are not part of is a 404.

Terminal window
curl -i -X POST $API/account/conversations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "listing": 412, "body": "Is the bike still for sale?" }'

201 with the message and Location: …/account/conversations/31. A second message about the same listing goes into the conversation already there: still 201 with the new message, and the Location of that conversation.

{
"data": {
"id": 88, "conversation_id": 31, "sender_id": 23,
"body": "Is the bike still for sale?", "held": false, "created_at": "2026-10-23T09:30:00Z"
}
}

Reply in a conversation:

Terminal window
curl -i -X POST $API/account/conversations/31/messages \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "body": "Yes, it is." }'

List and poll. GET /account/conversations carries meta.unread and meta.latest_at, so limit=1 is a cheap check for news. Messages come newest first and page with cursor. To poll, pass after with the last message id you hold: you get the newer ones, oldest first, and no cursor.

Terminal window
curl "$API/account/conversations/31/messages?after=88" -H "Authorization: Bearer $TOKEN"

Reading never marks anything read, so an app syncing in the background does not clear the badge. Call POST …/read when the person has seen the thread.

Terminal window
curl -X POST $API/account/conversations/31/read -H "Authorization: Bearer $TOKEN"
curl -X POST $API/account/conversations/31/block -H "Authorization: Bearer $TOKEN"
curl -X DELETE $API/account/conversations/31/block -H "Authorization: Bearer $TOKEN"
curl -X POST $API/account/conversations/31/report \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{ "reason": "spam" }'

read, block, DELETE block and report answer 200 with the conversation as it is now.

Rule Detail
listing Required, a live listing.
body Required, up to the site’s Longest message setting (5000 characters by default).
reason One of spam, scam, offensive, other. Reporting also blocks the conversation for you.
State A conversation is open, held, closed or reported. blocked_by_me and blocked_by_them say who blocked.
Moderation If the site holds the message for review, you get 201 with held: true and the warning message_held. The other person sees it once an admin releases it.
Webhooks message.created and conversation.reported carry ids only, never the text. See Webhooks.

What a refusal means:

Status Meaning
403 feature_disabled Messages are switched off on this site, or the account is not live.
403 wrong_credential The call needs a signed-in user.
403 forbidden The other person blocked the conversation.
403 not_owner DELETE .../block on a block the other person set. Only they can lift it.
403 banned The e-mail address or IP address is banned.
404 not_found No such live listing, or a conversation you are not part of. A seller gets 404 for a thread still held for review.
409 conflict Your own listing, a listing with no seller, a closed, held or reported conversation, or a report on one already reported. detail says which.
422 validation_failed Empty or too long body, or an unknown reason. A body the site’s spam, link or word rules refuse has the field code rejected.
429 rate_limited Over the hourly limit. See Limits.

An app that gets its own push endpoint can register it. The push carries no text; the app asks GET /account/conversations what is new.

Terminal window
curl -X PUT $API/account/push-subscriptions \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "endpoint": "https://fcm.googleapis.com/fcm/send/abc" }'

200 with { "data": { "browser": "…", "expires_at": "…" } }. Register it again before expires_at (90 days) to keep it. Stop with DELETE /account/push-subscriptions/{browser} (204). There is no list. Needs the conversations:write scope.

Status Meaning
404 not_found Browser notifications are switched off on this site, or on DELETE the browser is not yours.
422 validation_failed endpoint is empty, longer than 1024 bytes, not https://, or not a public address.

The request names variants and quantities. Prices, delivery, fees, totals and stock are worked out on the server; an amount in the body is ignored.

Terminal window
curl "$API/cart/quote?lines=12:2,15:1&gateway=offline"

lines is variant:qty pairs, at most 50, a quantity of 1 to 20. A variant that is gone, hidden or out of stock does not fail the call: it is listed in problems (not_buyable, out_of_stock, qty) with its position in lines, and an out-of-stock line stays out of the totals. shipping picks a delivery method from shipping_methods; gateway adds that payment method’s fee. Every amount is {amount, currency, formatted}. A quote is never cached.

Before checkout a shopper may add country (two letters), state (up to 100 characters), postcode (up to 20) and coupon (letters, digits and -, up to 40, read in upper case). Delivery, tax and discount plugins use them; a value that does not fit is left out, not refused.

Field Detail
needs_shipping false when every line is a download: no address, delivery or cash on delivery.
free_over {amount, remaining}: free delivery from this subtotal and what is left to reach it, or null. A delivery plugin may set its own.
taxes[].included true for tax already in the prices: shown, not added to total.
totals.tax / totals.tax_included Tax added on top of the prices, and tax already in them.
problems Each has line, code, pointer and message. A line problem has a line and a null pointer. A plugin’s problem has line null, a pointer (/coupon, /address/postcode, /address/state, /address/country or /shipping_method) and a message to show the shopper.
Terminal window
curl -i -X POST $API/account/orders \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
-d '{ "lines": [{ "variant": 12, "qty": 2 }], "shipping_method": "standard", "gateway": "offline",
"coupon": "WELCOME10",
"address": { "name": "Tom Lee", "phone": "5550100", "line1": "1 Main St", "city": "Austin", "state": "TX", "postcode": "73301", "country": "US" } }'

The 201 has the order in data and where to pay in next: { "kind": "redirect", "payload": "<url>" } to send the buyer to, or { "kind": "html", "payload": "<markup>" }, the gateway’s own page to print as it is.

Rule Detail
Who A signed-in user. A guest page token can price a cart but not place one.
Refused 422 with a pointer: /lines/n (out_of_stock, not_buyable), /shipping_method (unknown), /gateway (unavailable, too_many_open), /address/<field> (required, maxLength). A plugin’s problem (a bad coupon, a postcode outside every delivery zone) is rejected at its pointer, with its message. Nothing is written.
coupon Optional, the same rule as on the quote.
Included tax A tax line already in the prices is stored with amount 0, unit the tax and included true, so the lines still add up to total.
Retry Send an Idempotency-Key: a repeat gets the first order back and does not ask the gateway again.
Limit 20 orders an hour per user: 429 rate_limited. A refused request (422) is not counted.
status pending, paid, shipped, delivered, failed, refunded, cancelled.
Lines kind is item, shipping, tax, discount or fee; a discount’s amount is negative. listing links the live listing and is null once it is gone.
Cancel POST /account/orders/{id}/cancel, only while can_cancel is true: a pending bank transfer or cash on delivery order. 200 with the order, else 409 conflict.
Pay again A pending card order has can_pay true instead. POST /account/orders/{id}/pay asks the gateway again and answers 200 with the order and a new next; any other order is 409 conflict.
Other users Another user’s order is 404.

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
Payments started for a listing 20 an hour per user, with the orders above 429 rate_limited
Messages 30 an hour per user for replies (throttle_message_send), 10 an hour for new conversations (throttle_message_start). Both are in Spam and bots and count with the site’s own message forms. 429 rate_limited
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, unpaid, 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.
listing_unpaid POST, PATCH /listings The listing is saved but not live: it waits for payment. payment says what it costs and where to pay.
message_held POST /account/conversations, POST …/messages The message waits for review before the other side sees it.

Errors are in API errors.