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

Endpoints

Public browsing (no account)

MethodPathAccessWhat it does
GET/api/v1/auth/configPublicPublic connection settings and enabled sign-in methods for integrations
GET/api/v1PublicAPI discovery: version, documentation and endpoint list
GET/api/v1/openapi.jsonPublicOpenAPI 3.1 description of this API
GET/api/v1/listingsPublicSearch published Listings
GET/api/v1/listings/{id}PublicGet a public Listing
GET/api/v1/listings/{id}/photosPublicGet short-lived photo URLs for a public Listing
GET/api/v1/questsPublicSearch published Quests
GET/api/v1/quests/{id}PublicGet a public Quest
GET/api/v1/quests/{id}/photosPublicGet short-lived photo URLs for a public Quest
GET/api/v1/categoriesPublicCategory groups, subcategories and their detail fields
GET/api/v1/locationsPublicLook up US places and ZIP codes
GET/api/v1/locations/{id}PublicGet one place or ZIP code
GET/api/v1/areasPublicNamed local areas: a center place and default radius for searches
GET/api/v1/members/{id}PublicAn active member's public profile and current profile image
GET/api/v1/members/{id}/postsPublicAn active member's newest published posts
GET/api/v1/posts/{id}/map-candidatesPublicOpposite-side posts inside a map viewport, with match rules

Your posts and photos

MethodPathAccessWhat it does
GET/api/v1/meMember tokenThe signed-in member
GET/api/v1/me/postsMember tokenList the member's own posts (all statuses)
GET/api/v1/me/posts/{id}Member tokenGet one of your own posts (with its ETag)
POST/api/v1/postsMember tokenCreate a draft or published post
PUT/api/v1/posts/{id}Member tokenReplace every editable field of your post (status unchanged)
POST/api/v1/posts/{id}/statusMember tokenPublish, pause, close or archive your post
DELETE/api/v1/posts/{id}Member tokenPermanently delete your own draft (published posts are archived instead)
GET/api/v1/me/posts/{id}/photosMember tokenPhotos of your own post in any status
POST/api/v1/posts/{id}/photosMember tokenUpload a photo to your post (raw JPEG, PNG or WebP bytes)
DELETE/api/v1/posts/{id}/photos/{photoId}Member tokenDelete a photo from your post
PUT/api/v1/posts/{id}/photos/orderMember tokenSet the display order of your post's photos

Your profile

MethodPathAccessWhat it does
GET/api/v1/me/profileMember tokenYour profile (display name, home place, profile image)
PUT/api/v1/me/profileMember tokenReplace your display name and home place
PUT/api/v1/me/avatarMember tokenSet your profile image (raw JPEG, PNG or WebP bytes)
DELETE/api/v1/me/avatarMember tokenRemove your profile image

Saved posts, blocks and reports

MethodPathAccessWhat it does
GET/api/v1/me/savedMember tokenYour saved posts
PUT/api/v1/me/saved/{postId}Member tokenSave a post (idempotent)
DELETE/api/v1/me/saved/{postId}Member tokenRemove a post from Saved (idempotent)
GET/api/v1/me/blocksMember tokenMembers you blocked
PUT/api/v1/me/blocks/{memberId}Member tokenBlock a member (idempotent)
DELETE/api/v1/me/blocks/{memberId}Member tokenUnblock a member (idempotent)
POST/api/v1/reportsMember tokenReport a post or member to the moderators (create-only)

Messages

MethodPathAccessWhat it does
GET/api/v1/me/conversationsMember tokenYour conversations, most recent first
POST/api/v1/posts/{id}/conversationsMember tokenSend the first message about a published post
GET/api/v1/conversations/{id}/messagesMember tokenMessages in one of your conversations
POST/api/v1/conversations/{id}/messagesMember tokenReply in one of your conversations
POST/api/v1/conversations/{id}/readMember tokenMark 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:

  1. 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.
  2. 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 use auth.verifyOtp in 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.
  3. 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.
  4. 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.

FieldRules
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_idFrom /api/v1/categories. The subcategory must belong to the group, and the group to the kind.
titleOne line, 3–90 characters after whitespace cleanup.
descriptionUp to 4,000 characters. Optional for drafts; at least 10 characters to publish.
location_idA place ("p…") or ZIP ("z…") id from /api/v1/locations. The label and map position come from it.
conditionProducts: new, like_new, very_good, good, fair, for_parts. Required for product Listings, optional for product Quests. Omit for services.
price.modefixed, free, quote, range. Listings use fixed, free or quote; Quests use range (a budget), free or quote.
price.amount_centsInteger 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_centsInteger US cents, required for range (the maximum budget).
price.negotiableOptional boolean; not allowed with free or quote.
price.rate_basisServices: hour, job or day (quote when the mode is quote). Omit for products.
serviceServices only: mode (onsite, remote, either), radius_miles (Listings with onsite/either: how far you travel), availability (≤120 chars), scope (≤300 chars).
search_radius_milesQuests only, required: whole miles 1–1500.
attributesOptional 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

Photos and profile images

Messages

Saved posts, blocks and reports

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

StatusCodeMeaning
400invalid_queryUnknown, repeated or invalid query parameter (including bad category, radius, sort or map bounds).
400invalid_jsonEmpty body, invalid UTF-8 or invalid JSON.
400invalid_bodyThe 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.
400invalid_cursorThe cursor is malformed or belongs to another list, sort or conversation.
400invalid_locationlocation_id is not a known place or ZIP.
400invalid_idAn id in the path is not a UUID (or not a place/ZIP id).
400invalid_headerIf-Match is not one strong ETag returned by the API.
401missing_tokenNo Authorization header.
401invalid_tokenMalformed, expired or revoked access token.
403not_memberValid token, but the account is not an active member (details.reason).
404not_foundNothing visible to you has this id. Other members’ private items look exactly like missing ones.
405method_not_allowedThe method is not supported on this path (see the Allow header).
409conflictThe item changed since you read it (details.current_updated_at), or a photo was added at the same moment.
409invalid_transitionThat status change is not allowed (details.allowed).
409post_archivedArchived posts cannot be changed.
409not_draftOnly drafts can be deleted; archive published posts instead.
409post_unavailableThe post is not accepting messages (not published, or no longer available).
409blockedOne of you blocked the other, so no messages can be exchanged.
409recipient_unavailableThe other member is no longer active.
413payload_too_largeJSON body over 32 KB, or image over 4 MB.
415unsupported_media_typeContent-Type is not application/json, or not image/jpeg, image/png or image/webp for uploads.
422validation_failedField rules failed (details.fields lists each field and message).
422immutable_fieldtype or kind changed on a post that was published before.
422profile_incompletePublishing and messaging need a display name (PUT /api/v1/me/profile).
422post_limit_reachedToo many active posts; archive some first.
422photo_limit_reachedThe post already has 6 photos.
422saved_limit_reachedYou saved the maximum number of posts (500).
422invalid_imageThe bytes do not match the declared image type, or cannot be read.
422self_not_allowedYou cannot block, report or message yourself (or your own post).
428precondition_requiredEdits, status changes and draft deletion need the version you last read (expected_updated_at or If-Match).
429rate_limitedToo many requests; wait for Retry-After seconds.
500internal_errorUnexpected error.
503service_unavailableThe service is not configured or temporarily unreachable.

Questions: see How it works.