{"openapi":"3.1.0","info":{"title":"Integration API","version":"1.0.0","description":"Token-authed v1 API: CRM reads and writes, lead and contact intake, transactional email, drip enrolment, and outbound webhook management. Authenticate with `Authorization: Bearer <api-token>`. Every token is scoped and bound to one tenant — call GET /me to confirm which."},"servers":[{"url":"/api/v1"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"API token"}},"schemas":{"Error":{"type":"object","properties":{"success":{"type":"boolean","enum":[false]},"error":{"type":"string"}}},"LeadRequest":{"type":"object","required":["contact"],"properties":{"source":{"type":"string","description":"Free-text source label within the channel, e.g. your site name or a campaign"},"channel":{"type":"string","enum":["WEBSITE_FORM","EMAIL","SMS","PHONE","WHATSAPP","MESSENGER","API","IMPORT","MANUAL","OTHER"],"default":"API","description":"How the enquiry reached you — the countable axis, as opposed to the free-text source. A site posting its own contact form should send WEBSITE_FORM. Omitted, the lead records API, which is what actually happened rather than a guess."},"contact":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"email":{"type":"string","format":"email"},"phone":{"type":"string"}}},"message":{"type":"string"},"sessionId":{"type":"string","description":"The id you sent to POST /web-sessions for this visitor. Links the lead to the page that produced it. An unrecognised or absent value links nothing and the lead is still recorded — attribution never costs you an enquiry."},"smsConsent":{"type":"object","description":"Express consent to be TEXTED, from a SEPARATE, UNCHECKED box on your form. Submitting a form is not consent to text, and neither is supplying a phone number — so this is absent by default and is never inferred. Omit it entirely unless the person ticked a box saying they may be texted. consentText is REQUIRED when granted is true and the grant is refused without it: under CASL the record of WHAT was agreed to is the thing being asked for, and a bare boolean is not evidence. Send the exact sentence shown beside your checkbox. Recording consent sends nothing.","properties":{"granted":{"type":"boolean"},"consentText":{"type":"string","description":"The exact wording the person agreed to, verbatim."},"consentedAt":{"type":"string","format":"date-time"}}},"context":{"type":"object","additionalProperties":true,"description":"Denormalized snapshot (e.g. stockNumber, listingTitle)"},"dedup":{"type":"boolean","default":true}}},"WebSessionRequest":{"type":"object","required":["sessionId","siteHost"],"properties":{"sessionId":{"type":"string","description":"Your own id for this visit, from a first-party cookie or storage. The join key you later send with the lead. Unique within your tenant."},"siteHost":{"type":"string","description":"Which of your sites this visit is on. Required rather than inferred from the Origin header: a value we invent is present on every row, so it wins every grouping and looks deliberate."},"url":{"type":"string","description":"The page, absolute or path-only. Stored split: the path drives grouping and the query string is kept separately, so /pricing?utm_campaign=spring and /pricing are one bucket rather than two."},"referrer":{"type":"string","description":"document.referrer. Only recorded on the first call of a visit."},"utm":{"type":"object","properties":{"source":{"type":"string"},"medium":{"type":"string"},"campaign":{"type":"string"},"term":{"type":"string"},"content":{"type":"string"}}},"gclid":{"type":"string"},"device":{"type":"string","description":"Coarse only — 'mobile', 'desktop' or 'tablet'. Not a fingerprint."}}},"WebhookSubscription":{"type":"object","properties":{"id":{"type":"string"},"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string","enum":["LISTING_CREATED","LISTING_UPDATED","LISTING_SOLD","LEAD_CREATED","DEAL_STAGE_CHANGED"]}},"enabled":{"type":"boolean"},"description":{"type":"string"}}}}},"security":[{"bearerAuth":[]}],"paths":{"/me":{"get":{"summary":"Identify the token","description":"Returns the tenant this token belongs to and the scopes it carries. Call it to confirm a token points at the tenant you expect before pushing data into it — routing by slug and routing by token are two different things, and a mismatch lands records in the wrong tenant.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"{ success, tenantId, tenantSlug, scopes[] }"},"401":{"description":"Missing/invalid token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/businesses":{"get":{"summary":"List businesses","description":"Paginated businesses for the token tenant. Scope: businesses:read.","security":[{"bearerAuth":[]}],"parameters":[{"name":"sessionId","in":"query","schema":{"type":"string"},"description":"Restrict to one search session"},{"name":"search","in":"query","schema":{"type":"string"},"description":"Matches name, email, city or address"},{"name":"limit","in":"query","schema":{"type":"integer","default":100,"maximum":1000}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}},{"name":"sort","in":"query","schema":{"type":"string","enum":["name","rating","createdAt","city","businessType"],"default":"createdAt"}},{"name":"order","in":"query","schema":{"type":"string","enum":["asc","desc"],"default":"desc"}},{"name":"hasEmail","in":"query","schema":{"type":"boolean"}},{"name":"hasPhone","in":"query","schema":{"type":"boolean"}},{"name":"hasWebsite","in":"query","schema":{"type":"boolean"}},{"name":"minRating","in":"query","schema":{"type":"number","minimum":0,"maximum":5}},{"name":"businessType","in":"query","schema":{"type":"string"}},{"name":"enrichmentStatus","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of businesses + pagination"},"401":{"description":"Missing/invalid token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Insufficient scope (businesses:read)"}}},"post":{"summary":"Create or update a business","description":"Upserts a business in the token tenant. Scope: businesses:write.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Merged into an existing record"},"201":{"description":"Created"},"400":{"description":"Validation error"},"403":{"description":"Insufficient scope (businesses:write)"}}}},"/businesses/{id}":{"get":{"summary":"Get a business","description":"Full detail including contacts and enrichment history. Accepts the internal id or a googlePlaceId. Scope: businesses:read.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Business detail"},"404":{"description":"Not found"}}},"patch":{"summary":"Update a business","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Updated"},"404":{"description":"Not found"}}}},"/sessions":{"get":{"summary":"List search sessions","description":"Search sessions in the token tenant, newest first. Scope: sessions:read. (Until 2026-09-05 this endpoint performed no authentication and no tenant scoping, despite this document declaring both.)","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"maximum":100}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}},{"name":"status","in":"query","schema":{"type":"string","enum":["idle","searching","completed","error"]}},{"name":"minBusinessCount","in":"query","schema":{"type":"integer"}}],"responses":{"200":{"description":"A page of sessions + pagination"}}}},"/web-sessions":{"post":{"summary":"Record a website visit","description":"Upserts the visit identified by sessionId in the token tenant, so a lead submitted later can report WHICH PAGE produced it. Scope: web_sessions:write. Call it on the first pageview and again on each subsequent one: the first call establishes landingPage, referrer and campaign parameters, and later calls only append to the page path. Nothing here is inferred — a field the site does not send is stored null, and a lead with no matching session reports as unattributed rather than being given a plausible page.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebSessionRequest"}}}},"responses":{"200":{"description":"Session recorded"},"400":{"description":"sessionId or siteHost missing, or invalid JSON"},"403":{"description":"Origin not allowed, or missing web_sessions:write"}}}},"/web-sessions/{sessionId}/events":{"post":{"summary":"Record a visitor action","description":"Records something the visitor DID beyond loading a page. Today that is one thing: CALL_INTENT, meaning they tapped the tel: link. Scope: web_sessions:write (the same scope as the session itself — it is the same site reporting on the same visit). IMPORTANT: this creates no lead. A tap is not a call — it may be a misfire, or someone noting the number to ring later — so recording it as a phone lead would put a guess into a column that is counted. Someone in the CRM confirms it, and the lead that results carries a channel a person asserted. An unknown sessionId answers 200 with recorded:false rather than an error: the caller is a snippet on a marketing page and a visitor must never see a failure because attribution missed.","security":[{"bearerAuth":[]}],"parameters":[{"name":"sessionId","in":"path","required":true,"schema":{"type":"string"},"description":"The sessionId you sent to POST /web-sessions."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","enum":["CALL_INTENT"],"default":"CALL_INTENT","description":"Named rather than assumed, so a second event type later is not a breaking change."},"url":{"type":"string","description":"The page they were on. Stored path-only."},"number":{"type":"string","description":"Which of YOUR displayed numbers was tapped. Not the caller — a browser cannot see who is holding the phone."}}}}}},"responses":{"200":{"description":"Recorded, or recorded:false when the session is unknown"},"400":{"description":"Unknown event type, or invalid JSON"},"403":{"description":"Origin not allowed, or missing web_sessions:write"}}}},"/tracking-number":{"get":{"summary":"Which phone number should this visitor see","description":"Lends a pooled tracking number to this visit, so a call made from the page can be traced back to it (Dynamic Number Insertion). Scope: web_sessions:write — asking for a number LENDS one, which mutates the pool, so a read-only scope would misdescribe it. IMPORTANT: `e164` is null whenever a number cannot be lent — no pool configured, pool exhausted, unknown session, or an error. That is a NORMAL answer and always arrives as 200: leave the real number on the page. Losing attribution is acceptable; losing the call is not, so never hide or blank a phone number because this returned nothing. `reason` says which case it was.","security":[{"bearerAuth":[]}],"parameters":[{"name":"sessionId","in":"query","required":true,"schema":{"type":"string"},"description":"The sessionId you sent to POST /web-sessions."}],"responses":{"200":{"description":"A number to display, or e164:null with a reason (no-pool, exhausted, unknown-session, error)"},"400":{"description":"sessionId missing"},"403":{"description":"Origin not allowed, or missing web_sessions:write"}}}},"/leads":{"post":{"summary":"Create a lead","description":"Records an inbound enquiry as a Lead in the token tenant. Scope: leads:write. It does NOT create a contact or a deal: those are made when a human converts the lead in the CRM, so contactId and dealId are null in the response until then. (Before 2026-08-28 this created both immediately.)","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeadRequest"}}}},"responses":{"201":{"description":"Lead captured — { success, leadId, contactId: null, dealId: null, deduped: false }"},"400":{"description":"Validation error"},"403":{"description":"Insufficient scope (leads:write)"}}}},"/contacts":{"post":{"summary":"Upsert a contact","description":"Upserts a CRM contact by email/phone in the token tenant (no deal). Body: { email?, phone?, firstName?, lastName?, fullName?, companyName?, city?, province?, source?, tags?[], customFields?{} }. `city`/`province` are where the PERSON is, not their business service area. Supplying `phone` records a number and implies no permission to text it — consent is a separate, explicit act. Scope: contacts:write.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Merged existing — { success, contactId, isNew:false }"},"201":{"description":"Created — { success, contactId, isNew:true }"},"400":{"description":"Validation error (email or phone required)"},"403":{"description":"Insufficient scope (contacts:write)"}}}},"/emails":{"post":{"summary":"Send an email","description":"Sends one email as the token's tenant: over that tenant's Mailstream sending identity when it has a verified one, with the suppression list consulted, and the send recorded. Body: { to, subject, html, text?, from?, replyTo?, cc?, bcc?, stream?:'TRANSACTIONAL'|'BROADCAST' (default TRANSACTIONAL), attachments?:[{filename, content(base64), contentType}] }. Scope: emails:send, AND the Email API must be enabled for the tenant. Every error carries a stable `code` — match on that, not on the prose.","security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Sent — { success, messageId, logId }"},"400":{"description":"Validation error — code: invalid_recipient | missing_subject | missing_html | invalid_json | invalid_attachment"},"401":{"description":"code: unauthenticated | invalid_token"},"403":{"description":"code: insufficient_scope (token lacks emails:send) | email_api_disabled (not enabled for this tenant)"},"422":{"description":"code: no_tenant | recipient_suppressed (permanent — stop sending to this address)"},"429":{"description":"code: rate_limited"},"502":{"description":"code: send_failed — the transport rejected it; retryable"}}}},"/workflows/{workflowId}/trigger":{"post":{"summary":"Trigger a drip workflow","description":"Enrolls a CRM contact into a drip — {workflowId} is an ACTIVE outreach campaign in the token tenant; steps are sent on their delay schedule by the drip worker. Body: { contactId, triggerData?{} } (triggerData keys become {{variables}} via the contact's customFields). Scope: workflows:trigger.","security":[{"bearerAuth":[]}],"parameters":[{"name":"workflowId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Already enrolled — { success, executionId, isNew:false }"},"201":{"description":"Enrolled — { success, executionId, isNew:true }"},"403":{"description":"Insufficient scope (workflows:trigger)"},"404":{"description":"Workflow or contact not found in tenant"},"409":{"description":"Workflow not active / has no steps"},"422":{"description":"Contact has no email or has opted out"}}}},"/workflows/{workflowId}/stop":{"post":{"summary":"Stop a contact's drip early","description":"Ends a contact's enrollment because the thing the sequence was chasing has happened (they ran the audit, booked the call, placed the order). Use this rather than a CONDITION step: the drip runner does not evaluate step conditions, so one authored there would look live and do nothing. Idempotent — stopping an already-finished enrollment returns alreadyStopped:true. Body: { contactId, reason? }. Scope: workflows:trigger (the same scope as /trigger, so an integration that can start a sequence can always halt one).","security":[{"bearerAuth":[]}],"parameters":[{"name":"workflowId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Stopped — { success, executionId, alreadyStopped }"},"400":{"description":"contactId missing or body is not JSON"},"403":{"description":"Insufficient scope (workflows:trigger)"},"404":{"description":"Workflow not found in tenant, or contact not enrolled in it"}}}},"/webhooks":{"get":{"summary":"List webhook subscriptions","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Subscriptions (secrets masked)"}}},"post":{"summary":"Create a webhook subscription","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscription"}}}},"responses":{"201":{"description":"Created — secret returned once"}}},"patch":{"summary":"Update a subscription (?id=)","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Updated"}}},"delete":{"summary":"Delete a subscription (?id=)","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Deleted"}}}}}}