Developers
Have & Seek API (v1)
A JSON API for other apps and programs. Anyone can browse everything public without an account. A member can also let your app act for them, with their own sign-in, to post, upload photos, save, block, report and send messages. The machine-readable description is at /api/v1/openapi.json.
Using ChatGPT, Claude, Gemini or Perplexity? See Connect AI assistants for the MCP connector.
Basics
- Success:
{ "data": …, "meta": … }(meta on lists). Failure:{ "error": { "code", "message", "details" } }. - Money is always integer US cents (USD). Distances are whole miles. Times are ISO 8601.
- Public endpoints need no account and return only published or closed posts and public profiles of active members. Signing in does not change what they return.
- Member endpoints read and change only that member’s own data: their posts and drafts, photos, profile, saved posts, blocks and conversations. Other members’ private data is never returned. There are no admin or moderation operations, even for admin accounts.
- Cross-origin requests are allowed from any site without cookies. Responses are never cached and are not for search engines.
- Rate limits apply per server, so treat them as approximate: about 600 public reads per minute in total, 120 reads and 30 writes per member per minute, and 10 image uploads per member per minute. A 429 response says how long to wait in
Retry-After. Posts, messages, reports and saves also have the same database limits as the website.
Endpoints
Public browsing (no account)
| Method | Path | Access | What it does |
|---|---|---|---|
| GET | /api/v1/auth/config | Public | Public connection settings and enabled sign-in methods for integrations |
| GET | /api/v1 | Public | API discovery: version, documentation and endpoint list |
| GET | /api/v1/openapi.json | Public | OpenAPI 3.1 description of this API |
| GET | /api/v1/listings | Public | Search published Listings |
| GET | /api/v1/listings/{id} | Public | Get a public Listing |
| GET | /api/v1/listings/{id}/photos | Public | Get short-lived photo URLs for a public Listing |
| GET | /api/v1/quests | Public | Search published Quests |
| GET | /api/v1/quests/{id} | Public | Get a public Quest |
| GET | /api/v1/quests/{id}/photos | Public | Get short-lived photo URLs for a public Quest |
| GET | /api/v1/categories | Public | Category groups, subcategories and their detail fields |
| GET | /api/v1/locations | Public | Look up US places and ZIP codes |
| GET | /api/v1/locations/{id} | Public | Get one place or ZIP code |
| GET | /api/v1/areas | Public | Named local areas: a center place and default radius for searches |
| GET | /api/v1/members/{id} | Public | An active member's public profile and current profile image |
| GET | /api/v1/members/{id}/posts | Public | An active member's newest published posts |
| GET | /api/v1/posts/{id}/map-candidates | Public | Opposite-side posts inside a map viewport, with match rules |
Your posts and photos
| Method | Path | Access | What it does |
|---|---|---|---|
| GET | /api/v1/me | Member token | The signed-in member |
| GET | /api/v1/me/posts | Member token | List the member's own posts (all statuses) |
| GET | /api/v1/me/posts/{id} | Member token | Get one of your own posts (with its ETag) |
| POST | /api/v1/posts | Member token | Create a draft or published post |
| PUT | /api/v1/posts/{id} | Member token | Replace every editable field of your post (status unchanged) |
| POST | /api/v1/posts/{id}/status | Member token | Publish, pause, close or archive your post |
| DELETE | /api/v1/posts/{id} | Member token | Permanently delete your own draft (published posts are archived instead) |
| GET | /api/v1/me/posts/{id}/photos | Member token | Photos of your own post in any status |
| POST | /api/v1/posts/{id}/photos | Member token | Upload a photo to your post (raw JPEG, PNG or WebP bytes) |
| DELETE | /api/v1/posts/{id}/photos/{photoId} | Member token | Delete a photo from your post |
| PUT | /api/v1/posts/{id}/photos/order | Member token | Set the display order of your post's photos |
Your profile
| Method | Path | Access | What it does |
|---|---|---|---|
| GET | /api/v1/me/profile | Member token | Your profile (display name, home place, profile image) |
| PUT | /api/v1/me/profile | Member token | Replace your display name and home place |
| PUT | /api/v1/me/avatar | Member token | Set your profile image (raw JPEG, PNG or WebP bytes) |
| DELETE | /api/v1/me/avatar | Member token | Remove your profile image |
Saved posts, blocks and reports
| Method | Path | Access | What it does |
|---|---|---|---|
| GET | /api/v1/me/saved | Member token | Your saved posts |
| PUT | /api/v1/me/saved/{postId} | Member token | Save a post (idempotent) |
| DELETE | /api/v1/me/saved/{postId} | Member token | Remove a post from Saved (idempotent) |
| GET | /api/v1/me/blocks | Member token | Members you blocked |
| PUT | /api/v1/me/blocks/{memberId} | Member token | Block a member (idempotent) |
| DELETE | /api/v1/me/blocks/{memberId} | Member token | Unblock a member (idempotent) |
| POST | /api/v1/reports | Member token | Report a post or member to the moderators (create-only) |
Messages
| Method | Path | Access | What it does |
|---|---|---|---|
| GET | /api/v1/me/conversations | Member token | Your conversations, most recent first |
| POST | /api/v1/posts/{id}/conversations | Member token | Send the first message about a published post |
| GET | /api/v1/conversations/{id}/messages | Member token | Messages in one of your conversations |
| POST | /api/v1/conversations/{id}/messages | Member token | Reply in one of your conversations |
| POST | /api/v1/conversations/{id}/read | Member token | Mark one of your conversations as read |
Signing in for member endpoints
Member endpoints take the member’s access token in Authorization: Bearer …. Cookies and tokens in the URL are ignored, and the API never creates accounts, sessions or tokens. Your app gets the member’s own session:
- Read /api/v1/auth/config for the public Supabase connection settings, enabled methods and email return address. The publishable key identifies the project; it is not a member login.
- In your app, use the Supabase Auth client library with those details and let the member sign in. Google sign-in only returns to your app after the operator adds your app’s exact return address to the project’s allowed list. Email codes work with any email provider when enabled; request them with the returned
email_redirect_url, then useauth.verifyOtpin your app. A member who has set an optional password on the website (My account) can use it where password sign-in is enabled. If the project requires a security check (CAPTCHA) for sign-in, your app must pass one too. - The account must be an active member (a confirmed email address or Google). Otherwise the API answers 403 with
not_member. New members can join through the website or the enabled Supabase Auth signup flow; email accounts must confirm their address first. - Send the session’s access token. It expires after about an hour; refresh it with the same library.
What a token allows. The access token is the member’s full session: whoever holds it can do everything listed under the member endpoints as that member, just like on the website. There are no per-app permissions, consent screens or per-app revocation yet. Signing out ends refresh access; an issued access token may remain valid until it expires. Account access revocation is checked on every member request. Treat tokens like passwords: never put them in URLs, logs, messages or shared code. Scoped permissions for third-party apps are a possible future addition, not something this API offers today.
Examples
Shell examples use Bash. Set the uppercase variables from the API responses before running a command.
# HS_API is the site origin, e.g. https://haveandseek.com. No account needed. curl -s "$HS_API/api/v1/listings?kind=product&location_id=$LOCATION_ID&radius_miles=25&limit=10" curl -s "$HS_API/api/v1/quests/$POST_ID" curl -s "$HS_API/api/v1/listings/$POST_ID/photos" curl -s "$HS_API/api/v1/members/$MEMBER_ID" curl -s "$HS_API/api/v1/members/$MEMBER_ID/posts?limit=10" curl -s "$HS_API/api/v1/posts/$POST_ID/map-candidates?w=-78.2&s=38.4&e=-76.6&n=39.3" curl -s "$HS_API/api/v1/locations?q=Arlington,%20VA"
# HS_TOKEN is the access token of a member signed in through your app.
curl -s "$HS_API/api/v1/me" -H "Authorization: Bearer $HS_TOKEN"
curl -s -X POST "$HS_API/api/v1/posts" \
-H "Authorization: Bearer $HS_TOKEN" -H "Content-Type: application/json" \
-d @- <<JSON
{
"type": "listing", "kind": "product",
"group_id": "$GROUP_ID", "subcategory_id": "$SUBCATEGORY_ID",
"title": "Oak writing desk", "description": "Solid oak, 48 in wide, light wear.",
"location_id": "$LOCATION_ID", "condition": "good",
"price": { "mode": "fixed", "amount_cents": 12500, "negotiable": true },
"status": "draft"
}
JSON
# Add a photo: the body is the raw image file
curl -s -X POST "$HS_API/api/v1/posts/$POST_ID/photos" \
-H "Authorization: Bearer $HS_TOKEN" -H "Content-Type: image/jpeg" \
--data-binary @desk.jpg
# Publish, using the ETag you last received
curl -s -X POST "$HS_API/api/v1/posts/$POST_ID/status" \
-H "Authorization: Bearer $HS_TOKEN" -H "Content-Type: application/json" \
-H "If-Match: $ETAG" -d '{ "status": "published" }'
# Or throw a draft away (drafts only; published posts are archived)
curl -s -X DELETE "$HS_API/api/v1/posts/$POST_ID" \
-H "Authorization: Bearer $HS_TOKEN" -H "If-Match: $ETAG"# Save a post and block a member (both idempotent)
curl -s -X PUT "$HS_API/api/v1/me/saved/$POST_ID" -H "Authorization: Bearer $HS_TOKEN"
curl -s -X PUT "$HS_API/api/v1/me/blocks/$MEMBER_ID" -H "Authorization: Bearer $HS_TOKEN"
# Ask about a post. Keep CLIENT_TOKEN unchanged when retrying this same message.
CLIENT_TOKEN="$(uuidgen)"
curl -s -X POST "$HS_API/api/v1/posts/$POST_ID/conversations" \
-H "Authorization: Bearer $HS_TOKEN" -H "Content-Type: application/json" \
-d @- <<JSON
{ "body": "Is the desk still available?", "client_token": "$CLIENT_TOKEN",
"proposed_price": { "basis": "total", "amount_cents": 11000 } }
JSON
# Read new messages (keep the last next_cursor and poll with it), then mark read
curl -s "$HS_API/api/v1/conversations/$CONVERSATION_ID/messages?cursor=$CURSOR" -H "Authorization: Bearer $HS_TOKEN"
curl -s -X POST "$HS_API/api/v1/conversations/$CONVERSATION_ID/read" -H "Authorization: Bearer $HS_TOKEN"Creating and editing posts
POST /api/v1/posts creates a post. PUT /api/v1/posts/{id} replaces every editable field (anything optional you leave out is cleared) and keeps the status. The same rules as the website apply: drafts still need type, kind, category, title, place and price; anything that is or was public must also meet the publishing rules. Unknown fields, and fields that do not apply (for example a condition on a service), are refused.
| Field | Rules |
|---|---|
| type | "listing" (something you have) or "quest" (something you are looking for). Fixed once published. |
| kind | "product" or "service". Fixed once published. |
| group_id, subcategory_id | From /api/v1/categories. The subcategory must belong to the group, and the group to the kind. |
| title | One line, 3–90 characters after whitespace cleanup. |
| description | Up to 4,000 characters. Optional for drafts; at least 10 characters to publish. |
| location_id | A place ("p…") or ZIP ("z…") id from /api/v1/locations. The label and map position come from it. |
| condition | Products: new, like_new, very_good, good, fair, for_parts. Required for product Listings, optional for product Quests. Omit for services. |
| price.mode | fixed, free, quote, range. Listings use fixed, free or quote; Quests use range (a budget), free or quote. |
| price.amount_cents | Integer US cents (1250 = $12.50), 0 to 100,000,000. Required for fixed; optional minimum for range. A fixed price of 0 becomes free. |
| price.max_amount_cents | Integer US cents, required for range (the maximum budget). |
| price.negotiable | Optional boolean; not allowed with free or quote. |
| price.rate_basis | Services: hour, job or day (quote when the mode is quote). Omit for products. |
| service | Services only: mode (onsite, remote, either), radius_miles (Listings with onsite/either: how far you travel), availability (≤120 chars), scope (≤300 chars). |
| search_radius_miles | Quests only, required: whole miles 1–1500. |
| attributes | Optional detail fields for the subcategory (keys and options from /api/v1/categories), text up to 120 characters each. |
| status (create only) | "draft" (default) or "published". |
| expected_updated_at (PUT and status) | The updated_at you last read. Or send it as If-Match (the ETag header). |
Author, status (on PUT), publication and moderation fields can never be set. DELETE /api/v1/posts/{id} permanently removes a draft (one that was never published) and needs its If-Match; published, paused and closed posts are never deleted, only archived.
Status changes and conflicts
- Draft → published or archived. Published → paused, closed or archived. Paused → published, closed or archived. Closed → published or archived. Archived is final.
- Closing needs
closed_reason(sold, fulfilled, closed): product Listings can be sold or closed, service Listings closed, Quests fulfilled or closed. - Every change needs the version you last read:
expected_updated_atin the body orIf-Matchwith theETagheader. If the post changed meanwhile you get 409conflict; read it again and retry.
Photos and profile images
- Uploads send the raw image file as the request body (not a form, not base64) with
Content-Type: image/jpeg,image/pngorimage/webp, up to 4 MB. The content must match the type. - The server re-encodes every image to WebP and removes all metadata, including location. Post photos are at most 2000 px on the long side; profile images are cropped to 512 × 512. A post can have 6 photos.
- Image links stop working after about a minute. Request them again when you need them; do not store them. Your profile image is public on your member page and posts.
- If storing succeeds but the database then refuses the photo, the new file is removed again and the error says whether that worked (
uploaded_file). Deletes report whether the stored file was removed (removedorcleanup_pending).
Messages
POST /api/v1/posts/{id}/conversationssends the first message about a published post; replies go toPOST /api/v1/conversations/{id}/messages. Messages are plain text up to 2000 characters, with an optional proposed price (basis total, hour, job, day, quote; an amount in cents unless the basis is quote).- Every message needs a
client_token: a new UUID you generate per message. If a request fails or times out, retry with the same token; the message is never sent twice. - Only the two participants can read a conversation. Reading messages does not mark them read; call
POST …/readwhen the member has seen them. The inbox shows the newest conversations (up to 50); older ones are not reachable through v1. - Messages from your app are real messages to real people. Blocks, display-name and rate limits apply as on the website. Never send passwords, tokens or card details in a message.
Saved posts, blocks and reports
PUTandDELETEon/api/v1/me/saved/{postId}and/api/v1/me/blocks/{memberId}can be repeated safely; the answer says whether anything changed.- Reports (reasons: spam, scam, prohibited, offensive, harassment, other) go to the moderators and cannot be read back through the API.
Paging
Lists return up to limit items (default 20, maximum 50). meta.has_more is true only when another item exists; pass meta.next_cursor as cursor with the same filters for the next page. Cursors only work for the list, sort or conversation they came from. A member’s public posts and your inbox have no cursor: they show the newest items only (a member’s posts up to 49); use search for complete browsing.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_query | Unknown, repeated or invalid query parameter (including bad category, radius, sort or map bounds). |
| 400 | invalid_json | Empty body, invalid UTF-8 or invalid JSON. |
| 400 | invalid_body | The JSON does not match the schema (unknown field, wrong type, field that does not apply), or a body was sent to an operation that takes none. |
| 400 | invalid_cursor | The cursor is malformed or belongs to another list, sort or conversation. |
| 400 | invalid_location | location_id is not a known place or ZIP. |
| 400 | invalid_id | An id in the path is not a UUID (or not a place/ZIP id). |
| 400 | invalid_header | If-Match is not one strong ETag returned by the API. |
| 401 | missing_token | No Authorization header. |
| 401 | invalid_token | Malformed, expired or revoked access token. |
| 403 | not_member | Valid token, but the account is not an active member (details.reason). |
| 404 | not_found | Nothing visible to you has this id. Other members’ private items look exactly like missing ones. |
| 405 | method_not_allowed | The method is not supported on this path (see the Allow header). |
| 409 | conflict | The item changed since you read it (details.current_updated_at), or a photo was added at the same moment. |
| 409 | invalid_transition | That status change is not allowed (details.allowed). |
| 409 | post_archived | Archived posts cannot be changed. |
| 409 | not_draft | Only drafts can be deleted; archive published posts instead. |
| 409 | post_unavailable | The post is not accepting messages (not published, or no longer available). |
| 409 | blocked | One of you blocked the other, so no messages can be exchanged. |
| 409 | recipient_unavailable | The other member is no longer active. |
| 413 | payload_too_large | JSON body over 32 KB, or image over 4 MB. |
| 415 | unsupported_media_type | Content-Type is not application/json, or not image/jpeg, image/png or image/webp for uploads. |
| 422 | validation_failed | Field rules failed (details.fields lists each field and message). |
| 422 | immutable_field | type or kind changed on a post that was published before. |
| 422 | profile_incomplete | Publishing and messaging need a display name (PUT /api/v1/me/profile). |
| 422 | post_limit_reached | Too many active posts; archive some first. |
| 422 | photo_limit_reached | The post already has 6 photos. |
| 422 | saved_limit_reached | You saved the maximum number of posts (500). |
| 422 | invalid_image | The bytes do not match the declared image type, or cannot be read. |
| 422 | self_not_allowed | You cannot block, report or message yourself (or your own post). |
| 428 | precondition_required | Edits, status changes and draft deletion need the version you last read (expected_updated_at or If-Match). |
| 429 | rate_limited | Too many requests; wait for Retry-After seconds. |
| 500 | internal_error | Unexpected error. |
| 503 | service_unavailable | The service is not configured or temporarily unreachable. |
Questions: see How it works.