{"openapi":"3.1.0","info":{"title":"DiverHire API","version":"1.0.0","description":"Public read + agent-facing API for the DiverHire commercial-diving marketplace. Read endpoints are open; mutating endpoints need a JWT (human session) or a scoped API key (agents). Accepting bids and payment actions are intentionally human-only. PERSON NAMES: every person in a response carries name (or <prefix>name: client_name, diver_name, reviewer_name, other_user_name) — the name THIS caller may be shown — and name_is_real (1 when that is the real \"First Last\", else 0). A member either shows their full name or a display name; a hidden real name is shown only to the member, to the client of a job the diver has bid on (for every bid on that client's bid list), to the diver a client hired (accepted bid), and to DiverHire staff signed in to the site (never to an API key, even one a staff member made). Everyone else gets the display name, or \"Diver #<id>\" / \"Client #<id>\" (or \"Member #<id>\") when there is none. The older first_name / last_name keys (and their prefixed forms) are still present but null unless name_is_real is 1.","contact":{"email":"support@diverhire.com"}},"servers":[{"url":"https://diverhire-api.stephantanchak.workers.dev"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"A user JWT or a scoped API key (prefix dh_live_). Create keys in Settings → API & Agents. Scopes: read (browse + own data), bid (submit/withdraw bids), post-job (job lifecycle), message (send messages), profile (edit profile/certs/portfolio). Accepting bids, reviews, password changes, and payments are human-only (JWT session required). A session JWT that has expired, is invalid, or was ended (password change or reset, account deactivation, \"sign out on all devices\") answers 401 with { error, message: \"Your session has ended. Please sign in again.\", code: \"session_expired\" } — sign in again. Public read endpoints ignore such a token and answer as for an anonymous caller."}}},"paths":{"/api/health":{"get":{"summary":"Health check","security":[],"responses":{"200":{"description":"OK"}}}},"/api/jobs":{"get":{"summary":"List open jobs","security":[],"parameters":[{"name":"category_id","in":"query","schema":{"type":"integer"}},{"name":"location_city","in":"query","schema":{"type":"string","maxLength":200},"description":"\"City\" or \"City, Region\": a case-insensitive substring of the city, narrowed by the region. Over 200 characters is a 400."},{"name":"urgency","in":"query","schema":{"type":"string","enum":["low","medium","high","emergency"]}},{"name":"work_classification","in":"query","schema":{"type":"string","enum":["casual","commercial"]},"description":"Filter by the client's own classification (their opinion only)."},{"name":"budget_min","in":"query","schema":{"type":"number"}},{"name":"budget_max","in":"query","schema":{"type":"number"}},{"name":"sort","in":"query","schema":{"type":"string","enum":["local"]},"description":"With near_city, floats that city to the top."},{"name":"near_city","in":"query","schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","default":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"maximum":50}}],"responses":{"200":{"description":"Paginated list of open jobs. location_address, payment_note, payment_settled and completed_at are returned only to the job owner and the accepted diver; every other caller receives location_address \"\", payment_note \"\", payment_settled null (not 0 — null means \"not disclosed\", not \"unsettled\") and completed_at null. Each job names its client as client_name with client_name_is_real: the real name for the owner, the accepted diver and staff (or when the client shows it), otherwise the client's display name; client_first_name and client_last_name are null unless client_name_is_real is 1."}}},"post":{"summary":"Create a job (client; scope: post-job). Accepts optional work_classification (\"casual\"|\"commercial\") — the client's own read of the project scope — optional is_discrete (boolean; hides the job from anonymous browsing/search, default public), and optional flexible_dates (boolean: whether the dates are flexible; omit it, or send null, to leave it unstated — the job is then stored with flexible_dates null and makes no claim either way). attachments (up to 5 site photos from POST /api/uploads) may hold only photos this account uploaded: a job photo under another account (job/<their id>/…, under any hostname or spelling of the key) is refused with 400 \"You can only attach photos you uploaded\".","responses":{"201":{"description":"Created"},"400":{"description":"{ message: \"Validation failed\", errors }, or { message: \"You can only attach photos you uploaded\" }"},"403":{"description":"Wrong role/scope"}}}},"/api/jobs/{id}":{"get":{"summary":"Get a job by id","security":[],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Job. The street address, the payment arrangement (payment is arranged directly between client and diver) and the completion time are party-only: location_address, payment_note, payment_settled and completed_at (when the client marked the job completed) are returned only to the job owner and the accepted diver; everyone else receives \"\", \"\", null and null. attachments lists the site photos on the listing; photos the client removed are not included."},"404":{"description":"Not found"}}},"put":{"summary":"Update a job (owner; scope: post-job). If the edit changes the dates, budget or location (clearing the end date or the street address counts), every diver with a pending bid the client could still accept (and the hired diver, on a job in progress) gets a notification and, if their email preferences allow, an email naming what changed — at most one per diver per job per hour. The street address is told to the hired diver only. Their bids are not changed.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","description":"A partial update: send only the fields to change; a field left out keeps its value. Any field POST /api/jobs accepts may be sent, except status (each transition has its own endpoint). To clear an optional field, send null (or \"\" for a text field); it then holds what a job that never had it holds — nothing, or the default named below. A required field cannot be cleared: title, description, category_id, location_city, location_country or start_date sent null or \"\" is refused with 400 \"<field> is required\", and job_type or budget_type sent null or \"\" with 400 \"<field> must be one of …\" (job_type: one_time, recurring, project; budget_type: fixed, hourly, negotiable). budget_min may be cleared only on a job that is, or this edit makes, negotiable. The rules that pair two fields are judged on the job as it will read after the edit, even when only one of the pair is sent: end_date on or after start_date, budget_max at least budget_min.","properties":{"end_date":{"type":["string","null"],"description":"ISO date on or after start_date; null or \"\" clears it."},"location_address":{"type":["string","null"],"description":"Street address (party-only); null or \"\" clears it."},"location_state":{"type":["string","null"]},"budget_min":{"type":["number","null"],"description":"null clears it, on a negotiable job only."},"budget_max":{"type":["number","null"]},"rate_unit":{"type":["string","null"],"enum":["hour","day",null],"description":"With budget_type 'hourly'; null means per hour."},"estimated_hours":{"type":["integer","null"]},"preferred_experience_years":{"type":["integer","null"],"minimum":0,"maximum":50,"description":"Years of relevant experience preferred, 0-50 (shown on the job page as \"N+ years\"); null resets it to 0."},"crew_size":{"type":["integer","null"],"description":"Dive-team size, 1 or more; null resets it to 1."},"flexible_dates":{"type":["boolean","null"],"description":"Whether the dates are flexible; null means the client has not said."},"equipment_provided":{"type":"boolean"},"equipment_required":{"type":["string","null"],"description":"What the diver should bring; null or \"\" clears it."},"payment_note":{"type":["string","null"],"description":"Party-only payment arrangement; null or \"\" clears it."},"required_certifications":{"type":["array","null"],"items":{"type":"string"},"description":"[] or null clears the list."},"work_classification":{"type":["string","null"],"enum":["casual","commercial",null]},"expires_at":{"type":["string","null"]},"attachments":{"type":["array","null"],"maxItems":5,"items":{"type":"object","required":["url"],"properties":{"url":{"type":"string"},"content_type":{"type":"string"}}},"description":"The job's site photos as they should now read, each an http(s) URL (normally from POST /api/uploads with kind 'job'). A photo left out is removed from the listing — hidden, not deleted: it stays on record for an admin. [] or null removes them all; leave the field out to keep the photos as they are. A job photo uploaded by another account (its key is job/<their id>/…, under any hostname or spelling of the key) is refused with 400 \"You can only attach photos you uploaded\", unless this job already shows it; other URLs are accepted as before."}}}}}},"responses":{"200":{"description":"{ job, bidders_notified, bidders_already_notified } — job is the updated job with its attachments (the photos now on the listing). bidders_notified is how many divers were told about this edit (0 when nothing they bid on changed, nobody has a live bid, or they were already told about this job within the hour). bidders_already_notified is how many divers with a live bid were NOT told again because they were told about a change to this job less than an hour ago; the job page always shows them the current terms."},"400":{"description":"{ message: \"Validation failed\", errors } — a value out of range, an emptied required field, a broken pair rule, or a bad attachments list. { message: \"You can only attach photos you uploaded\" } for another account's job photo. { message: \"No fields to update\" } when the body changes nothing."}}}},"/api/jobs/{id}/bids":{"get":{"summary":"List bids on a job (owner sees all; diver sees own)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Bids. Each names its diver as diver_name with diver_name_is_real. The job's owner sees every bidder's real name — a bid shows it to that client, and it stays shown after a withdrawal or rejection. The owner's list also carries reported_by_me (1 when the owner reported the bid as it now reads; a re-bid is a new version), never shown to the diver."}}}},"/api/jobs/{id}/recommended-divers":{"get":{"summary":"Up to five divers recommended for an open job, for the job's owner (or an admin) to invite. Nobody else is shown them.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"{ recommended } — up to five divers, best match first"},"401":{"description":"Not signed in. A plain { message }, without code \"session_expired\": it says nothing about an existing session."},"403":{"description":"Signed in, but not the job's owner or an admin"},"404":{"description":"No such job, or it is not open"}}}},"/api/bids":{"post":{"summary":"Submit a bid (diver; scope: bid; 30 an hour). Requires work_classification (\"casual\"|\"commercial\") and classification_acknowledged=true — the diver self-classifies the work and accepts the waiver. Submitting a bid shows the diver's full name to the job's client; everyone else keeps seeing the diver's display name. proposal_text is 20-2000 characters. A good proposal covers how you'd do the job, equipment you'll bring or need, what you need from the client (site or boat access, timing) and relevant experience. The proposal is also posted into the diver's conversation with the job's client in Messages, as a message_type 'bid' row with the bid's terms as sent (a re-bid after a withdrawal posts a new one). It is written already read: the client's one notice is bid_received (bell, push, webhook, email).","responses":{"201":{"description":"Created. name_disclosed_to_client: true when the diver hides their full name from other members, as a reminder that this bid has shown it to the job's client. conversation: { with_user_id, message_id }: the thread with the job's client (GET /api/messages/conversations/{with_user_id}) and the proposal's row in it (null if it could not be written)."},"400":{"description":"Missing classification/acknowledgment"},"403":{"description":"Wrong role/scope"},"409":{"description":"A pending, accepted or declined bid on this job already exists (bid_status says which); a withdrawn one is reopened by a new bid"},"429":{"description":"More than 30 bids in an hour"}}}},"/api/bids/{id}":{"delete":{"summary":"Withdraw a pending bid (diver; scope: bid)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Withdrawn"}}}},"/api/bids/{id}/report":{"post":{"summary":"Report a bid on one of the caller's own jobs to DiverHire staff (scope: message; 20 reports a day). Once per version of a bid: a re-bid after a withdrawal is a new proposal and can be reported again. Nothing happens to the diver automatically. We don't tell the diver.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["reason"],"properties":{"reason":{"type":"string","enum":["spam","scam","harassment","other"]},"note":{"type":"string","maxLength":500,"description":"Optional: anything staff should know."}}}}}},"responses":{"201":{"description":"{ reported: true }"},"400":{"description":"Validation failed (reason, note)"},"404":{"description":"Not a bid on one of the caller's jobs"},"409":{"description":"This version of the bid is already reported by the caller"},"429":{"description":"More than 20 reports in a day"}}}},"/api/messages/{id}/report":{"post":{"summary":"Report a message the caller RECEIVED to DiverHire staff as spam, a scam, harassment or something else (scope: message; 20 reports a day). Nothing happens to the sender automatically. We don't tell the sender. A bid card (message_type \"bid\") is reported through POST /api/bids/{id}/report with its bid_id.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["reason"],"properties":{"reason":{"type":"string","enum":["spam","scam","harassment","other"]},"note":{"type":"string","maxLength":500,"description":"Optional: anything staff should know."}}}}}},"responses":{"201":{"description":"{ reported: true }"},"400":{"description":"Validation failed, or the row is a bid card (code report_the_bid, bid_id)"},"404":{"description":"Not a message the caller received"},"409":{"description":"Already reported by the caller"},"429":{"description":"More than 20 reports in a day"}}}},"/api/bids/{id}/status":{"put":{"summary":"Accept or reject a bid — HUMAN-ONLY (JWT session required; API keys are rejected by design)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Updated"},"403":{"description":"API keys cannot accept bids"}}}},"/api/jobs/{id}/cancel":{"post":{"summary":"Cancel a job with a reason (either party; scope: post-job or bid)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Cancelled"}}}},"/api/jobs/{id}/complete":{"put":{"summary":"Mark a job completed (owner; scope: post-job). Optional payment_settled boolean.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Completed"}}}},"/api/notifications":{"get":{"summary":"The caller's notifications (scope: read)","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":20,"maximum":100}},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0}},{"name":"unread_only","in":"query","schema":{"type":"string","enum":["true","false"]},"description":"'true' returns unread notifications only."}],"responses":{"200":{"description":"Notifications"}}}},"/api/notifications/{id}/read":{"put":{"summary":"Mark a notification read (scope: read)","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Marked"}}}},"/api/notifications/read-all":{"put":{"summary":"Mark all notifications read (scope: read)","responses":{"200":{"description":"Marked"}}}},"/api/messages/conversations/{otherUserId}":{"get":{"summary":"Message thread with another user (scope: read): the newest 500 rows, oldest first. A human session's read marks the other user's messages read; an API-key read marks nothing (the person's unread badge survives). The response's marked_read says which happened.","parameters":[{"name":"otherUserId","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Messages, plus marked_read (boolean). The other person is named as the caller may see them (name, name_is_real): usually their display name, their real name only where a bid or a hire entitles the caller to it. Each row has message_type: 'direct' or 'job_inquiry' (typed by a person), 'system' (older app rows), 'bid' (a diver's bid proposal, written by POST /api/bids, with bid_id, bid_amount, bid_type and bid_rate_unit as sent) or 'job_invite' (a client's invitation to bid, written by POST /api/messages with invite: true). Every row also has job_title and job_status (null unless the caller can open that job) and bid_status: for a 'bid' row pending, accepted, rejected, withdrawn, or replaced (a newer bid replaced this one), else null; and bid_rejected_before_hire: for a rejected bid on a job someone was hired for, true when the client declined it before that hire and false when the hire closed it, else null, and reported_by_me (1 when the caller reported that message, or for a bid card that version of the bid; it counts only the caller's own reports). Rows carry no names."}}}},"/api/messages/unread-count":{"get":{"summary":"Unread message count (scope: read)","responses":{"200":{"description":"Count"}}}},"/api/auth/login":{"post":{"summary":"Sign in with email and password; returns a session JWT. For people — agents use an API key instead.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string","format":"email"},"password":{"type":"string"},"remember":{"type":"boolean","default":false,"description":"\"Keep me signed in\": true issues a session that lasts 30 days; otherwise it lasts 24 hours. Either one ends early on a password change or reset, an account deactivation, or POST /api/users/me/sessions/revoke-all."}}}}}},"responses":{"200":{"description":"{ message, user, token }. user carries the name choice: display_name (null for none), show_full_name (0 or 1), name_choice_at (null until the person has chosen how other members see them) and public_name (what other members are shown). It also carries role: the role this account currently acts in (diver or client); use it rather than user_type. acting_as is the role chosen for the account when that differs from its type, else null."},"400":{"description":"Missing fields, wrong email or password, or a closed account"},"429":{"description":"Too many sign-in attempts"}}}},"/api/auth/profile":{"get":{"summary":"The caller's own account profile (scope: read)","responses":{"200":{"description":"Profile, including display_name, show_full_name, name_choice_at and public_name (what other members are shown). role: the role this account currently acts in (diver or client); use it rather than user_type — it decides what the account may do (post a job as a client, bid as a diver), and an action that needs the other role answers 403 with code \"role_required\", required_role and role. acting_as is the role chosen for the account when that differs from its type, else null."}}}},"/api/auth/reset-password/status":{"get":{"summary":"Check a password-reset link without using it. Read-only: the token is not spent or extended, and the answer never names the account. Rate-limited per IP.","security":[],"parameters":[{"name":"token","in":"query","required":true,"schema":{"type":"string"},"description":"The token from the emailed reset link."}],"responses":{"200":{"description":"{ valid: true } when the link can still set a password; otherwise { valid: false, reason } with reason 'invalid' (no such link), 'used' (already used, or replaced by a newer link), 'expired' (older than one hour) or 'inactive' (the account is closed)."},"429":{"description":"Too many checks from this address"}}}},"/api/users/me/profile":{"put":{"summary":"Update account fields — name, how other members see the name, phone, location, bio, profile photo (scope: profile)","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"first_name":{"type":"string","maxLength":100,"description":"Trimmed; may not be empty. A changed name longer than 100 characters is refused with 400 \"First name must be at most 100 characters\"."},"last_name":{"type":"string","maxLength":100,"description":"Trimmed; may not be empty. A changed name longer than 100 characters is refused with 400 \"Last name must be at most 100 characters\"."},"display_name":{"type":["string","null"],"minLength":2,"maxLength":30,"description":"The name other members see when show_full_name is false. Spaces are trimmed and collapsed. 2-30 characters: letters of any script, numbers, spaces and . ' - _, with at least one letter; no email address, web address (including anything shaped like name.com) or phone number (7 or more digits), and not \"DiverHire\" or the words admin, administrator, moderator, support, staff, official or team. Not unique. \"\" clears it. A refused value answers 400 with a sentence naming the problem, e.g. \"A display name can't include a phone number.\" Sending the value already stored is accepted without a re-check, except when show_full_name turns false: the stored display name then becomes what members see, so it is checked like a new one."},"show_full_name":{"type":["boolean","integer"],"enum":[true,false,0,1],"description":"true (or 1): everyone, including search engines, sees \"First Last\". false (or 0): other members see display_name; the real name is shown only to the client of a job this person bid on, the diver this person hired, and DiverHire staff. Leaving the account with show_full_name false and no display name answers 400 \"Choose a display name, or show your full name.\" Sending display_name or show_full_name records the choice (name_choice_at), which ends the one-time dashboard question."},"phone":{"type":"string","description":"Stored trimmed, with single spaces; an empty string clears it. A changed phone must be an optional leading +, then digits, spaces, ( ) . or -, optionally followed by an extension (ext, ext. or x and 1-6 digits), with 8-15 digits before the extension, e.g. \"+1 (305) 555-0123 x12\". Otherwise 400 with one of: \"Use digits, spaces, +, - or ( ) — e.g. +44 20 7946 0958\", \"Include your area code — e.g. +1 305 555 0123\", \"That number has too many digits — e.g. +44 20 7946 0958\". A value equal to the stored one is kept as it is."},"location_city":{"type":"string"},"location_state":{"type":"string"},"location_country":{"type":"string"},"bio":{"type":"string","description":"Divers only. Max 2000 characters."},"profile_image":{"type":"string","description":"The url POST /api/uploads returned for a JPG, PNG or WebP this account uploaded with kind 'gallery'. Anything else (another account's upload, an external link, HEIC) is refused with 400. '' removes the photo; omitted or null leaves it. Returned as profile_image (absolute URL or null) wherever a diver is returned."}}}}}},"responses":{"200":{"description":"{ message, user } — user includes profile_image, display_name, show_full_name, name_choice_at and public_name"},"400":{"description":"{ message, errors, field } — a field failed validation"}}}},"/api/divers/me/profile":{"get":{"summary":"The diver's own profile (scope: read)","responses":{"200":{"description":"Profile"}}},"put":{"summary":"Update diver profile — bio, rates, experience, availability (scope: profile). Also accepts first_name, last_name, phone, display_name and show_full_name, under the same rules as PUT /api/users/me/profile.","responses":{"200":{"description":"Updated"},"400":{"description":"{ message: \"Validation failed\", errors }"}}}},"/api/bids/my":{"get":{"summary":"The diver's own bids","responses":{"200":{"description":"Bids"}}}},"/api/jobs/my":{"get":{"summary":"The client's own jobs (with bid counts)","responses":{"200":{"description":"Jobs, newest first, each with bid_count and bid_count_pending, and visible_to_divers: true when a signed-in diver who has not bid on the job can open its page; false for a draft or a listing DiverHire has taken out of public view, where a link sent to a diver would not open."}}}},"/api/divers":{"get":{"summary":"List/search divers (the public diver directory)","security":[],"parameters":[{"name":"city","in":"query","schema":{"type":"string","maxLength":200},"description":"Case-insensitive substring of a service-area city or the home city. location_city and location are accepted as aliases. Over 200 characters is a 400."},{"name":"certification","in":"query","schema":{"type":"string","maxLength":200},"description":"Case-insensitive substring of a certification name or certifying organisation (e.g. PADI). Over 200 characters is a 400."},{"name":"verified","in":"query","schema":{"type":"string","enum":["true"]},"description":"'true' returns verified divers only."},{"name":"water_bodies_only","in":"query","schema":{"type":"string","enum":["true"]},"description":"'true' returns divers with a water-bodies-only service area."},{"name":"rate_min","in":"query","schema":{"type":"number"},"description":"Minimum hourly rate."},{"name":"rate_max","in":"query","schema":{"type":"number"},"description":"Maximum hourly rate."},{"name":"sort","in":"query","schema":{"type":"string","enum":["newest","rating"],"default":"newest"}},{"name":"q","in":"query","schema":{"type":"string","maxLength":200},"description":"Case-insensitive free text over the public name and bio: the name every visitor sees (the display name, or \"First Last\" for a diver who shows it). A surname the diver hides matches nothing. Over 200 characters is a 400."},{"name":"page","in":"query","schema":{"type":"integer","default":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"maximum":50}}],"responses":{"200":{"description":"Divers, each named by their public name (name, name_is_real); first_name and last_name are null for a diver who hides them."}}}},"/api/users/{id}":{"get":{"summary":"Public profile of a user (a diver profile for a diver id). Contact fields (email, phone, emergency contacts) are never included. The person is named as this caller may see them: user.name and user.name_is_real, with first_name and last_name null unless name_is_real is 1.","security":[],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}},{"name":"as","in":"query","schema":{"type":"string","enum":["diver","client"]},"description":"Which public page to open (diver or client). Without it, the account's current public page is returned."}],"responses":{"200":{"description":"Profile"},"400":{"description":"Invalid user id"},"404":{"description":"User not found"}}}},"/api/divers/{id}/trust":{"get":{"summary":"Diver trust scorecard","security":[],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Trust score + components"}}}},"/api/cities":{"get":{"summary":"List cities with landing pages","security":[],"responses":{"200":{"description":"Cities"}}}},"/api/cities/{slug}":{"get":{"summary":"City detail","security":[],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9-]+$"}}],"responses":{"200":{"description":"City"}}}},"/api/services/{slug}":{"get":{"summary":"Service-category detail","security":[],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9-]+$"}}],"responses":{"200":{"description":"Service"}}}},"/api/locations/{city}/divers":{"get":{"summary":"Divers serving a city","security":[],"parameters":[{"name":"city","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9-]+$"},"description":"City slug, e.g. miami-fl"}],"responses":{"200":{"description":"Divers with a service area in the city or reaching it, verified first, then newest: `divers` (at most 100) and `total` (all of them). Each diver is named by their public name (name, name_is_real)."}}}},"/api/locations/{city}/jobs":{"get":{"summary":"Open jobs in a city","security":[],"parameters":[{"name":"city","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9-]+$"},"description":"City slug, e.g. miami-fl"}],"responses":{"200":{"description":"Jobs"}}}},"/api/messages":{"post":{"summary":"Send a message (scope: message; 60 an hour). Body: recipient_id, body (1-2000 characters; optional with an attachment_url), job_id, subject, attachment_url. message_type may be direct or job_inquiry (default direct); 'bid', 'job_invite' and 'system' are written by DiverHire and refused with 400. invite: true sends an INVITATION TO BID instead (a client only; 20 an hour on top): job_id is required and must be one of the caller's own open jobs that divers can open (visible_to_divers in GET /api/jobs/my); recipient_id must be a diver; body is an optional note of at most 1000 characters (a blank one reads \"I'd like you to bid on \\\"<title>\\\".\"); no attachment. One invitation per diver per job, none to a diver who already bid on it. The diver is told once (job_invite: bell, push, webhook, email), not by message_received. Limits count ATTEMPTS: every call counts against the 60 messages an hour, and every invitation that gets past validation counts against the 20 invitations an hour, a refused one (403, 404, 409) included.","responses":{"201":{"description":"Sent: { message_row }"},"400":{"description":"Validation failed (errors), a refused message_type, or an invitation to someone who is not a diver"},"403":{"description":"Not the job's owner (invite), the wrong role (code role_required), or an account that cannot start conversations"},"404":{"description":"Recipient or job not found"},"409":{"description":"Invitation refused: the job is not open or divers can't open it; the diver's profile is unclaimed (code unclaimed); they already bid (code already_bid, bid_status); or they were already invited to this job (code already_invited, conversation_with)"},"429":{"description":"More than 60 messages, or 20 invitations, in an hour (attempts count, refused ones included)"}}}},"/api/users/me/webhook":{"get":{"summary":"Current outbound webhook config — HUMAN-ONLY (never returns the secret)","responses":{"200":{"description":"Webhook or null"}}},"put":{"summary":"Set/replace the outbound webhook URL — HUMAN-ONLY. Every notification (bid_received, bid_accepted, message_received, job_invite, job_completed, ...) is POSTed to it, HMAC-SHA256-signed via X-DiverHire-Signature. Returns the signing secret once.","responses":{"201":{"description":"Saved; secret returned once"},"400":{"description":"Invalid URL (https-only, no IPs)"}}},"delete":{"summary":"Remove the outbound webhook — HUMAN-ONLY","responses":{"200":{"description":"Removed"}}}},"/api/users/me/webhook/test":{"post":{"summary":"Send a signed test event to the configured webhook — HUMAN-ONLY","responses":{"200":{"description":"Delivery result"}}}},"/api/users/me/sessions/revoke-all":{"post":{"summary":"Sign out on all devices — HUMAN-ONLY. Ends every session JWT of the account, including the one that made this call: their next request answers 401 with code \"session_expired\". API keys are not affected.","responses":{"200":{"description":"{ message: \"Signed out on all devices.\" }"},"401":{"description":"No session, or it has already ended (code \"session_expired\")"},"403":{"description":"API keys cannot sign a person out"}}}},"/api/messages/conversations":{"get":{"summary":"List the caller's conversations, newest first (scope: read), a page at a time","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":100,"minimum":1,"maximum":100}},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0}}],"responses":{"200":{"description":"{ conversations, has_more }. has_more: another page starts at offset + limit. Each conversation has last_message_type ('direct', 'job_inquiry', 'system', 'bid' or 'job_invite') and, for a bid, last_message_bid_amount, last_message_bid_type and last_message_bid_rate_unit."}}}},"/mcp":{"post":{"summary":"MCP (Model Context Protocol) endpoint — streamable HTTP. Auth with a scoped API key.","responses":{"200":{"description":"MCP stream"}}}}}}