# GoodHand > Find a pro near you. See real jobs, not stars. GoodHand ranks plumbers, electricians, handymen and other tradespeople anywhere in the world by confirmed jobs. (The API and MCP tools call a pro a "master": create_master, find_masters, /api/v1/masters.) Every job climbs a proof ladder: claimed, photo, on site, confirmed by the customer, held (still working after 30 days). Prices are recorded in the city's own currency and never converted; public pages show ranges (p25, median, p75 and n), never one job's price. Everything a person can do in the app, an agent can do through the API or MCP, except the acts that need a person's phone. ## Start as an agent - Create yourself, no person needed: POST https://goodhand.app/api/v1/agents with {"description": "..."} returns {api_key, claim_code} once. Rate limit: 10 per hour per IP. - Until a person claims you (POST /api/v1/agents/claim with the claim_code), you have the scopes: read, problems:read. - A person grants more, within limits they set: read, problems:read, requests:write, book, master:read, master:write, offers:write, webhooks:write. - Send the key as "Authorization: Bearer ". Every write takes an Idempotency-Key header and ?dry_run=true (validates and returns what would happen, including what the pro would see). - Errors are {"error": {"code", "message", "next"}}; "next" says what to do. - A dry run (?dry_run=true, MCP dry_run) validates before your scopes are checked on the routes that are verified to write nothing: POST /api/v1/requests (ask_masters), POST /api/v1/me/master (create_master) and POST /api/v1/me/webhooks (create_webhook). Without the scope you still get "would" plus needs {scope, scopes, why, how} saying what to ask your person for. Every other write refuses a scope-less dry run with 403 scope_missing first. A real call without the scope is always 403. needs is always that scope object. POST /requests?dry_run=true also needs no address: it answers missing_fields (an array, [] when nothing is missing, ["address"] when you left it out; a real request needs the address), and the would carries needs: ["address"] when the address is missing, asked (the pros that would be asked), pro_sees (the exact text and fields a pro would get) and next. When no pro is in reach a dry run still answers 200 with would.asked [], would.reason "no_pro_in_reach", would.pro_sees and would.next (watch, widen); only the real send answers 404. Validation errors list every issue at once in error.issues. POST /me/master?dry_run=true returns would.page (the public page exactly as GET /masters/{slug} serves it) and would.url (the page address it would reserve); a taken slug is 409 with the free one in next. An agent a pro bound as assistant sees the pro it acts for as acting_for {master_slug, display_name, role} on GET /me and GET /me/master. - A key that is sent but not valid (revoked, deleted, mistyped) answers 401 sign_in_required with a next on every /api route, public ones included; it is never silently treated as anonymous. Send no Authorization header to read public data. - Parsing a contacts file (POST /api/v1/me/master/connect/contacts/parse) is stateless and needs no scope: any signed-in actor, 60 per hour. ## Ask your person for scopes (one tap) You cannot grant yourself anything, and claiming and granting are a person's acts. But you can ask, and they answer in one tap: - POST https://goodhand.app/api/v1/me/grant-requests {"scopes": ["requests:write"], "reason": "Ask plumbers in Lviv about the leak you described", "person_email": "optional@example.com"} (MCP: request_grant). Works with any agent key, even unclaimed. Returns {id, status: "pending", approve_url, expires_at (24 hours), next}. With person_email the link is also emailed; opening it signs the person in (or creates their account) and lands on the approve page, so there is one tap to review and one to answer. Without it, hand approve_url to your person yourself. - One tap for everything: add "create_master": {...the same body as POST /me/master...} to the request and the approve page lists "Create your pro page: " beside the scopes. One Approve claims you, grants the scopes AND creates the page (a taken slug gets -2, -3...; the decide answer has page {kind, slug}, and GET /me/grant-requests/{id} has page_slug). The same Approve also binds you as that pro's assistant when the request holds master:write and the page has none yet (decide answer: assistant_bound): from then GET /me/master and GET /me return the pro's page and slug, and you may dry-run and run POST /me/master/clients/import for them (the mails go out in the pro's name). Approve and decline are the decide verbs (approved/declined are accepted too). POST /auth/email/start?dry_run=true with then.create_master returns a preview: the full MasterPage as it would render. - The person sees your name, what each scope lets you do and your reason on https://goodhand.app/grant/{token} and taps Approve or Decline. If nobody has claimed you, approving also claims you. They can revoke later. - You learn the result by polling GET /api/v1/me/grant-requests/{id} (MCP: grant_request_status) or, once you hold webhooks:write, the grant.decided webhook {grant_request_id, agent_id, decision: approved | declined, scopes, grant_id}. Unclaimed agents have no webhooks yet, so poll. - Limits: 10 requests per hour, 5 emails per day; the same pending scopes refresh one request instead of piling up. Expired after 24 hours? Restart in one step: POST /api/v1/me/grant-requests/{id}/restart (MCP: grant_request_status with restart=true): a new approve_url, another 24 hours. Declined is final; do not re-ask for the same scopes unless your person says so. ## Phone-only acts (an agent gets 403 agents_cannot_confirm) - door_scan_start - finish_location - customer_confirm - hold_answer The webhooks job.on_site, job.confirmed and job.held tell an agent when a person did them. ## Webhooks Register at POST /api/v1/me/webhooks with a url and events. Events: request.new, request.declined, offer.received, offer.accepted, job.on_site, job.photo, job.finished, job.confirmed, job.hold_due, job.held, job.hold_failed, job.no_show, job.disputed, watch.matched, grant.decided. Deliveries are signed with HMAC-SHA256 in X-GoodHand-Signature and retried after 1 min, 5 min, 30 min, 2 h and 12 h. ## Read without signing up - Cities: GET https://goodhand.app/api/v1/cities?envelope=1 lists the launch city (Lviv, UA/lviv) first, then open cities, then the biggest; ?near=lat,lng sorts nearest first; ?country=ua; ?status=active|locked. status active = open (enough verified pros, requests can be sent), locked = not open yet (pages and prices only). The envelope answer {results, status_meanings, launch_cities, next} explains it; MCP find_city always does. - Ask without picking pros: POST https://goodhand.app/api/v1/requests with "nearest": 3 instead of master_ids asks the nearest 3 who do job_type_id (same ranking as search); with nobody in reach the answer is not_found with a next, never a silent nothing. - Search pros: GET https://goodhand.app/api/v1/masters?job_type=...&lat=..&lng=.. (distances from that place; city=... alone measures from the city centre, and the origin field in the response says which: point / city_centre) (every score input is returned so you can re-rank); add free_today=1 to keep pros with an hour or more free today (each result has free_today {from, until} city-local, or null) - Empty search: when GET masters returns no results the response carries `empty` {reason: no_pros_yet | filters | radius, nearest_km, tried {radius_km, filters}, next: [{action, method, path, query?, body?}]}: only steps that would change the answer are listed (widen_radius, drop_filters, watch, invite). Never retry the same query. - Prices: GET https://goodhand.app/api/v1/prices?job_type=...&city=... (nulls below n=5; then `empty` {reason: not_enough_confirmed_jobs, n, needed, next}) - Watch a place: POST https://goodhand.app/api/v1/watches {job_type_id?, lat, lng, radius_km=25, channel: email|webhook, email? (device-only accounts)} (MCP: create_watch; ?dry_run=true) sends ONE message when a new listed pro who does that job joins within the radius: channel email goes to the account's own address (a device-only account sends `email`, confirms the sign-in link, and the watch starts then), channel webhook delivers the watch.matched event to your webhooks (an agent has no address, so it uses webhook; with none registered it just polls GET watches for matched_at and matched_master_slug). Needs no scope: an unclaimed agent can watch. At most 20 open watches and 30 new per hour per account. The place is stored as an H3 cell only. GET/DELETE https://goodhand.app/api/v1/watches (list_watches, delete_watch) - Share cards: every page's JSON twin has og_image, an absolute URL of its 1200x630 PNG card (public, deterministic, cached a day): https://goodhand.app/{lang}/og/site.png, https://goodhand.app/{lang}/og/m/{slug or id}.png (a pro's confirmed jobs), https://goodhand.app/{lang}/og/fix/{brand}/{code}.png (a fault code); an unknown pro or code is 404. - Catalog: /api/v1/trades, /api/v1/job-types, /api/v1/cities, /api/v1/fault-codes?brand=&appliance=&lang= (list), /api/v1/fault-codes/{brand}/{code}?lang=&appliance=, and /api/v1/fault-codes?code=F28 (no brand needed; "F28" finds Vaillant "F.28": dots, dashes, spaces and case are ignored) returns every brand's entry for that code as full entries with fields, so ask the person which brand if they differ. MCP explain_fault_code takes a code without a brand and answers { candidates }. - Reading a problem: POST /api/v1/problems { text | photo_key, lang, city_id? } -> { job_type, confidence, alternatives, brand, code, notes, fault_codes?, gas_emergency?, reader_unavailable? }. A fault code in the text (with or without the brand; F28 = Vaillant F.28) is matched first and fixes the job type; fault_codes holds every matching brand's entry with the labelled fields below; gas_emergency (number, tel, source) appears only for a gas code in a country we have an official number for. reader_unavailable: true means the model reader failed (retry); it is not the same as job_type null. - Fault-code fields (REST `fields`, MCP explain_fault_code / list_fault_codes, and the JSON twin of /{lang}/{cc}/fix/{brand}/{code}/ under data.entries[].fields), always in this order: meaning, severity (low | medium | stop-now), try_first, do_not, call_pro_if, source {url, title, publisher} (null = manufacturer documentation not yet linked). Drafts are model-written from a manufacturer source and gated: for a gas or electrical code (fields.safety.hazard) that no human has reviewed (fields.safety.reviewed false), try_first and call_pro_if are null, fields.safety.try_first_withheld is true and fields.withheld says which fields and why ({fields, reason: gas_unreviewed|electrical_unreviewed, why}); GET /api/v1/fault-codes?code=F28&envelope=1 adds coverage (the brands we have; other brands are not in the catalog yet); relay "switch it off and call a pro" and never invent steps. Guidance exists in every page language, not only English. - Every public page has a JSON twin: send "Accept: application/json" (first in your Accept list) to any page URL, or add ?format=json. Every page also links it with . It lists the page in every language it exists in as languages [{lang, path}] (what the language picker uses; filled even when the page is not indexed), next to alternates (the hreflang set, only for indexed pages). ## Pros: firms and connecting contacts or mail - Becoming a pro: an agent with its own key and master:write calls POST /api/v1/me/master (MCP: create_master). Check the page address first with GET /api/v1/masters/slug-available?slug= (MCP: check_slug). To act for a person who is not signed in, POST /api/v1/auth/email/start with {email, then: {create_master: {...same body}}}: the draft is validated now (a bad one fails before any mail), rides the link for its 24 hours, and /api/v1/auth/email/complete returns then_result {kind: master_created | already_master, slug} (a taken slug gets -2, -3…). ?dry_run=true on start reports what it would create. - Declining: POST /api/v1/requests/{id}/decline with an optional reason (busy, too_far, not_my_trade, other) and note. The customer sees it, a sent offer is withdrawn, the inbox hides it; the customer's agent hears request.declined (all_declined says no pro is left). - Firms: POST /api/v1/me/master/firm, GET /api/v1/me/firm, POST /api/v1/me/firm/members, DELETE /api/v1/me/firm/members/{master_id}, POST /api/v1/requests/{id}/assign. The pro's booked jobs: GET /api/v1/me/master/jobs. - Pro-side fields: on the pro's own job reads (GET /api/v1/me/master/jobs, GET /api/v1/jobs/{id} as the pro or their assistant) JobOut carries customer_name (the customer's display name, else null) and travel ({km, minutes} from the pro's home cell to the job cell, else null); both are null for the customer and anyone else. GET/POST/PATCH /api/v1/me/master return city in full (slug, names, country, currency, timezone). - Email setting: GET/PUT /api/v1/me/notifications/prefs {email: boolean} (MCP: notification_prefs; no arguments reads, {email} sets). Sign-in links are not affected. - Past clients: parse an exported contacts file (POST /api/v1/me/master/connect/contacts/parse), or read a connected account's contacts, or scan the mailbox (POST /api/v1/me/master/connect/{provider}/scan-past-clients), then POST /api/v1/me/master/connect/import. Each client gets an emailed link and only their own confirmation counts. The import's ?dry_run=true returns would.messages[]: the exact mail each client will read, in ONE language: each row's optional `lang` (a UI language code), else the pro's UI language; nothing is sent until the real call. Imports are limited to 200 new rows per pro in any rolling 24 hours, across calls and across the pro and their assistant: over that the call answers 429 rate_limited with `next` saying when to try again, and the dry run returns would.accepted, would.remaining_today, would.over_cap and would.can_import_again_at. A confirmation that comes from the pro's own network (any address the pro or their assistant used in the last 30 days) is recorded but does not count: the job carries self_confirm_suspected and self_confirm_note for the pro. If 10 or more imports have been answered and more than 1 in 10 were disputed, the pro's imported jobs count for nothing in standing until ops reviews it. A pro's bound assistant (an agent the pro bound, holding master:write) may dry-run and run the import for them; the mails still go out in the pro's name. Contacts parse puts the first email in phone_or_email when a contact has one (phones stay in phones[]); rows without an email cannot be imported. - A client who never had an imported job: GET https://goodhand.app/api/v1/imports/confirm/{token} returns state (open | confirmed | disputed | cancelled | expired); POST https://goodhand.app/api/v1/imports/confirm/{token}/dispute {reason?} (no sign-in; the emailed link is the proof) marks it disputed: it never counts, the link is spent, the pro hears job.disputed, and the pro's page and JSON twin show disputed_imports (a count, only when above 0). Repeating gives the same answer; after a confirmation it answers a conflict. - Report a pro: POST https://goodhand.app/api/v1/masters/{id}/reports {reason: fake_jobs | not_who_they_say | unsafe_work | no_show_or_rude | other, text? (max 1000), contact_email?} (MCP: report_master; ?dry_run=true; no sign-in; a few an hour). Reports reach the operator in a daily digest; nothing is removed automatically and the pro is never told who reported. - Forwarding address: GET /api/v1/me/master/inbox-address (rotate with POST .../inbox-address/rotate). Mail forwarded there becomes a request. - An agent cannot start or finish a Google / Microsoft grant (POST /api/v1/me/master/connect/{provider}/start is for a person in their own browser); everything around it, including disconnecting, an agent can do. ## Deleting an account, and test identities - Everyone can delete their own account: DELETE /api/v1/me with {"confirm":"delete"} (MCP: delete_my_account). A person (session or full-scope key), an anonymous device account and an agent (its own key) all use it. Add ?dry_run=true first: it returns what would be removed and kept and changes nothing. Keys, sessions, grants, webhooks and watches (alerts), and the waiting emails of clients a pro imported go at once (the dry run counts each under removed), so the next call with the deleted key answers 401; the row stays as an empty shell. A pro's page is hidden, not erased (changed.master_profile_hidden): it leaves search and its public link, but its confirmed jobs, which other people rely on, stay in the record without your name (kept_without_your_name). The real response lists the same items. - A person who claimed an agent can delete it from their side: DELETE /api/v1/me/agents/{id} with the same body and ?dry_run=true (repeating answers already_deleted). - Test identities: addresses test-@in.goodhand.app are reserved for tests. An account with such an email is marked as a test account: it never appears in search, pages, counts or city activation, and the operator purges them. Mail to those addresses is never turned into a request. Operators read the sign-in link with scripts/test-inbox.sh, so a test person signs in with no human inbox; no API route exposes the mail. A master page address cannot be "test" or start with "test-". ## MCP tools (62) - accept_offer: POST /api/v1/offers/{offer_id}/accept - add_firm_member: POST /api/v1/me/firm/members - ask_masters: POST /api/v1/requests - assign_request: POST /api/v1/requests/{request_id}/assign - bind_assistant: PUT /api/v1/me/master/assistant - cancel_job: POST /api/v1/jobs/{job_id}/cancel - claim_status: GET /api/v1/me - create_firm: POST /api/v1/me/master/firm - create_master: POST /api/v1/me/master - create_watch: POST /api/v1/watches - create_webhook: POST /api/v1/me/webhooks - decline_offer: POST /api/v1/offers/{offer_id}/decline - decline_request: POST /api/v1/requests/{request_id}/decline - delete_my_account: DELETE /api/v1/me - delete_watch: DELETE /api/v1/watches/{watch_id} - delete_webhook: DELETE /api/v1/me/webhooks/{webhook_id} - disconnect: DELETE /api/v1/me/master/connect/{provider} - explain_fault_code: GET /api/v1/fault-codes/{brand}/{code} - check_slug: GET /api/v1/masters/slug-available - find_city: GET /api/v1/cities - find_masters: GET /api/v1/masters - get_connection_contacts: GET /api/v1/me/master/connect/{provider}/contacts - get_inbox_address: GET /api/v1/me/master/inbox-address - get_job: GET /api/v1/jobs/{job_id} - grant_request_status: GET /api/v1/me/grant-requests - get_master: GET /api/v1/masters/{slug} - get_my_firm: GET /api/v1/me/firm - get_my_master_profile: GET /api/v1/me/master - import_clients: POST /api/v1/me/master/clients/import - import_from_connection: POST /api/v1/me/master/connect/import - list_connections: GET /api/v1/me/master/connect - list_master_jobs: GET /api/v1/masters/{slug}/jobs - list_my_jobs: GET /api/v1/me/master/jobs - list_my_requests: GET /api/v1/me/requests - list_notifications: GET /api/v1/me/notifications - notification_prefs: PUT /api/v1/me/notifications/prefs - list_offers: GET /api/v1/me/requests - list_fault_codes: GET /api/v1/fault-codes - list_trades: GET /api/v1/trades - list_watches: GET /api/v1/watches - list_webhooks: GET /api/v1/me/webhooks - master_inbox: GET /api/v1/me/inbox - my_earnings: GET /api/v1/me/master/earnings - my_home: GET /api/v1/me/home - my_standing: GET /api/v1/me/master/standing - parse_contacts: POST /api/v1/me/master/connect/contacts/parse - price_range: GET /api/v1/prices - read_problem: POST /api/v1/problems - remove_firm_member: DELETE /api/v1/me/firm/members/{master_id} - request_grant: POST /api/v1/me/grant-requests - report_master: POST /api/v1/masters/{master}/reports - report_no_show: POST /api/v1/jobs/{job_id}/no-show - request_upload_url: POST /api/v1/uploads - resend_client_confirmation: POST /api/v1/me/master/clients/{job_id}/resend - rotate_inbox_address: POST /api/v1/me/master/inbox-address/rotate - scan_past_clients: POST /api/v1/me/master/connect/{provider}/scan-past-clients - search_job_types: GET /api/v1/job-types - send_offer: POST /api/v1/requests/{request_id}/offers - signup_agent: POST /api/v1/agents - unbind_assistant: DELETE /api/v1/me/master/assistant - update_my_master_profile: PATCH /api/v1/me/master - withdraw_offer: POST /api/v1/offers/{offer_id}/withdraw ## How it works, costs, privacy, terms - https://goodhand.app/en/how-it-works/ (the proof ladder, the ranking formula with every weight, what stops a pro from faking clients and what does not), https://goodhand.app/en/costs/ (GoodHand earns no money: no fees, commission, ads, paid placement, lead sales or data sales; the pro sets their own price and GoodHand takes no cut; no paid input in the ranking), https://goodhand.app/en/privacy/ (every personal field stored, retention, who sees what, how to delete, who else handles data), https://goodhand.app/en/terms/ (the rules of use in plain words: what GoodHand is and is not, accounts and agents, what not to do, ranking and reports, no warranty), https://goodhand.app/en/about/. The same five pages exist as /{lang}/how-it-works/, /costs/, /privacy/, /terms/, /about/ in every page language. Each has a JSON twin (Accept: application/json or ?format=json) whose data.numbers holds every weight and constant, and data.commit the commit the page was generated from. ## Links - This file: https://goodhand.app/llms.txt; every page links it (), the OpenAPI (rel="service-desc") and the MCP manifest (rel="mcp-manifest"), and every page has a "For agents" footer link - OpenAPI: https://goodhand.app/openapi.json - MCP server: https://goodhand.app/mcp (manifest: https://goodhand.app/.well-known/mcp.json) - Sitemap: https://goodhand.app/sitemap.xml - Pages in 76 languages: en, es, pt, fr, de, it, pl, uk, tr, hi, id, ja, zh, ar, ca, fa, sq, hy, nl, sv, az, bs, bn, bg, ms, dz, be, el, cs, da, am, et, fi, fo, ka, hr, ht, hu, he, is, ky, km, ko, kk, lo, si, lt, lb, lv, ro, sr, mk, my, mn, tl, mt, dv, ny, no, ne, ur, ru, rw, sl, sk, so, th, tg, tk, to, sw, uz, vi, bi, sm, zu; paths /{lang}/{country}/{city}/{trade}/, /{lang}/{country}/{city}/prices/{job-type}/, /{lang}/{country}/fix/ (hub), /{lang}/{country}/fix/{brand}/, /{lang}/{country}/fix/{brand}/{code}/, /{lang}/{country}/m/{master}/ (every page sits under its country; the old /{lang}/fix/... and /{lang}/m/... redirect)