{"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. Every authentication refusal has the same shape, `{ success: false, error, code }`: 401 `unauthenticated` (no token), 401 `invalid_token`, 429 `rate_limited` (with Retry-After), 403 `insufficient_scope`, 403 `origin_not_allowed`, 422 `no_tenant`. Match on `code`, never on `error`."},"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","description":"Prose for a person. Its wording may change."},"code":{"type":"string","description":"Stable and machine-readable — match on this. On authentication refusals it is one of the values listed; some routes add their own (e.g. /viewings `slot_taken`).","examples":["unauthenticated","invalid_token","rate_limited","insufficient_scope","origin_not_allowed","no_tenant"]}}},"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"},"ownReceipt":{"type":"string","maxLength":120,"description":"Name the email YOUR site sends this enquirer (e.g. \"audit report email\"), or why they get none (e.g. \"renter register: promised no email\"). When present, the account's generic acknowledgement is not sent for this lead, so the person does not get two emails that say different things. Recording, assignment and the team alert are unchanged. Omit it for a plain enquiry."},"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."},"visitor":{"type":"object","description":"Who the PERSON is, as your server saw them — for a lead posted from your server without a sessionId FullArc recognises. Fill it from YOUR inbound request: the first hop of x-forwarded-for, and x-vercel-ip-city, x-vercel-ip-country-region and x-vercel-ip-country when you run on Vercel. Top level, like sessionId. Without it a server-posted lead has no location: this request's own IP describes your server and is never used. Each field is validated on its own; a bad one is dropped and the lead is still recorded. A recognised sessionId takes precedence.","properties":{"ip":{"type":"string","description":"Public IPv4 or IPv6. Private and loopback addresses are dropped."},"city":{"type":"string","description":"Plain or URL-encoded. Capped at 100 characters."},"region":{"type":"string","description":"ISO 3166-2 subdivision code, e.g. NS."},"country":{"type":"string","description":"ISO 3166-1 alpha-2, e.g. CA."}}},"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). For a Rentals account, listingSlug names the listing the enquiry is about; FullArc links the lead to it and fills in listingId and 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":["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","leadScore"],"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"}}}},"/listings":{"get":{"summary":"List published rental listings","description":"Published rental listings in the token tenant, so a paired site can render its own vacancies pages. Scope: listings:read. Serves only AVAILABLE, APPLICATION_PENDING and LEASED. A DRAFT is never returned, because a draft holds facts nobody has verified yet (unmeasured square footage, an unconfirmed heat type) and advertising one is how a listing ends up wrong. An unrecognised status filter falls back to the whole public set rather than erroring, so a typo cannot widen it. The response is assembled field by field, never spread from the row: the unit market rent, the owner contact, the PID and internal notes are all deliberately absent, and the registry number rides only on a listing that is actually advertised. LEASED rows carry daysToLease, computed from the real publishedAt and closedAt events rather than from updatedAt, which moves for a typo fix. Each listing carries photos: [{ url, alt, srcset }] in the order chosen; a photo url is public (a browser loads it with no token) and answers only while its listing is advertised or leased. url is the stored full-size photo; srcset is that same photo at 480w, 960w and 1200w, ready to hand to an <img srcset> so a card in a grid does not download the full-size one. Only those widths are served: any other &w= gives the full-size photo. Each listing also carries features (short labels) and property.propertyType (HOUSE, DUPLEX, TRIPLEX, FOURPLEX, APARTMENT, CONDO, TOWNHOUSE, OTHER, or null when nobody has recorded it). details carries the choices renters filter on as keys (unitKind, hasDen, furnished, airConditioning, smoking, catsOk, dogsOk, parkingSpaces, parkingType, laundryType, heatingFuel, heatingSystem, accessibility,videoUrl), null where not recorded; property.addressLine omits the street number when the listing hides it. rentIncluded lists the Form P section 13 keys the rent includes (STOVE, REFRIGERATOR, WASHER_DRYER, DISHWASHER, FURNITURE, COIN_LAUNDRY, CABLE, HEAT, WATER, HOT_WATER, ELECTRICITY, PARKING, RECYCLING_FACILITIES, LAWN_CARE, SNOW_REMOVAL, GARBAGE_REMOVAL) and rentIncludedOther anything else, in words; rentIncludes is the same information as display words. tenantResponsible lists Form P section 13 keys (LAWN_CARE, LATE_PAYMENT_CHARGES, SNOW_REMOVAL, RETURNED_CHEQUE_CHARGES, GARBAGE_REMOVAL, PARKING, TENANT_INSURANCE, LOCKOUT_KEYS, RECYCLING_SEPARATION, ASSIGNMENT_SUBLET) and tenantResponsibleOther anything else the tenant looks after, in words. neighbourhood is null or { schools: [{ program (english | early_french_immersion | late_french_immersion), grades, name, address, website }], walk: { score 0-100, categories: [{ category, count, nearestM }] } | null, transit: [{ name, routes, weekdayTrips, distanceM }] }; schools are the catchment schools for the address (HRCE boundaries), never merely the nearest, and walk is our own measure, not a Walk Score. neighbourhood.showSchools / showWalkability / showTransit say whether the listing lets each be shown: when false, schools is [], walk is null, or transit is [] — hidden, not absent — and a site must not show them. Transit is its own switch: a site shows the stops whether or not it shows walk. property.location is null unless the listing has opted in, and then { latitude, longitude, precision: \"approximate\" } is the property rounded to three decimals, about 100 m — never the exact pin, which does not leave FullArc. Draw an area from it, not a doorstep: it is off by default and is a separate choice from hiding the street number. neighbourhood also carries internet: { asOf, providers: [{ name, technologies (fibre | cable | dsl | fixed_wireless | satellite) }] } | null — ISED's providers for the ~25 km² area, not a promise for the building — and collection: { day, zone (A | B, the recycling week), calendarArea } | null, from HRM. details.heatingCostMonthly / details.heatingCostBasis are the landlord's own figure from bills, never an estimate. waste is null or { kind (household | apartment | condo), curbside (false for buildings over six units, where no collection day applies), guide: { label, url } | null } — HRM's sorting guide for the building.","security":[{"bearerAuth":[]}],"parameters":[{"name":"slug","in":"query","schema":{"type":"string"},"description":"Exact slug. Unique within the tenant, not globally."},{"name":"status","in":"query","schema":{"type":"string","enum":["AVAILABLE","APPLICATION_PENDING","LEASED"]}},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"maximum":200}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"A page of listings + pagination"},"403":{"description":"Missing listings:read"}}}},"/listings/{slug}/viewing-slots":{"get":{"summary":"Open viewing times for a listing","description":"The times a renter may book a viewing of one advertised listing (AVAILABLE or APPLICATION_PENDING), from the account's weekly viewing hours minus blocked time, existing bookings, the minimum notice and the booking horizon. Scope: listings:read. This is the same engine a booking is re-checked against, so a time listed here is bookable unless someone takes it first. { enabled, timeZone, slotMinutes, slots: [{ startsAt, endsAt }] } with ISO UTC instants; enabled is false and slots empty when the account is not taking viewings online. A draft, leased, withdrawn or unknown listing is a 404.","security":[{"bearerAuth":[]}],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The open times"},"403":{"description":"Missing listings:read"},"404":{"description":"No advertised listing with that slug"}}}},"/viewings":{"post":{"summary":"Book a viewing","description":"Books a viewing of an advertised listing for a renter, from the site SERVER (the token is a secret). Scope: viewings:write. Body: { listingSlug, startsAt (one of /listings/{slug}/viewing-slots), contact: { name, email, phone? }, note?, source?, pageUrl?, sessionId?, visitor? }. The renter is recorded as a Lead (channel WEBSITE_FORM, the listing in its context) and emailed a confirmation with a link to see or cancel the viewing; booking grants no consent to be texted. One party per time: the second of two renters racing for a time gets 409 slot_taken. Refusals carry a stable code — invalid (400), listing_not_found (404), viewings_off, slot_unavailable, slot_taken (409). Success: 201 { viewingId, leadId, startsAt, endsAt, emailed }.","security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Booked"},"400":{"description":"invalid"},"403":{"description":"Missing viewings:write"},"404":{"description":"listing_not_found"},"409":{"description":"viewings_off, slot_unavailable or slot_taken"}}}},"/service-requests":{"post":{"summary":"Send a work order to a trades account","description":"Sends a work order to the account as a service call, from the sender's SERVER (the token is a secret). Scope: service_requests:write. Body: { reference? (your work order number), source? (who you are), site: { addressLine1, addressLine2?, city, province?, postalCode?, name? }, contact?: { name, email?, phone? }, problem, access? (how to get in), kind? (repair | maintenance | other), priority? (normal | urgent | emergency) }. The site is found by its address or added; the contact is matched by email or phone or added. A resend with the same reference returns the same call (200), never a second one. Refusals carry a stable code: invalid (400), trades_off (403, the account has no trades). Success: 201 { serviceCallId, number, isNew }.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Already received: the same call"},"201":{"description":"Received"},"400":{"description":"invalid"},"403":{"description":"Missing service_requests:write, or trades_off"}}}},"/applications/consent":{"get":{"summary":"Screening consent wording","description":"The written screening consent every applicant must be shown, in the account's name, and its version. Scope: listings:read or applications:write. { version, text, marketingText }. Show `text` beside an unticked checkbox in type of at least 10 point (Consumer Reporting Act (NS) s.11), and post `version` back as applicant.consentVersion — a submission naming an older version is refused with 409 consent_changed.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"{ version, text, marketingText }"},"403":{"description":"Missing scope, or Rentals is not enabled"}}}},"/applications":{"post":{"summary":"Submit a rental application","description":"Submits a rental application for an advertised listing (AVAILABLE or APPLICATION_PENDING), from the site SERVER — the token is a secret and the body is sensitive. Scope: applications:write. Body: { listingSlug, applicant: { name, email, phone, currentAddress, currentLength?, currentLandlord?, currentLandlordPhone?, reasonForLeaving?, previousLandlord? (name), previousLandlordPhone?, previousAddress?, employer?, position?, employmentLength?, employerContact? (who can confirm the job), employerPhone?, a second job: job2Employer?, job2Position?, job2Length?, job2Contact?, job2Phone? (job2Employer required if any is given), monthlyIncome (all jobs together), incomeVerification?, dateOfBirth? (YYYY-MM-DD), sin? (optional, never required), consent: true, consentVersion, marketing? }, household: { moveInDate?, occupants?, pets?, smoking?, vehicles?, notes? }, coApplicants?: [{ name, email }] (up to 5), source?, pageUrl?, sessionId?, visitor? }. Income, employment, rental history, date of birth and SIN are encrypted at rest. The applicant is recorded as a Lead (channel WEBSITE_FORM, the listing in its context), emailed a receipt, and each co-applicant is emailed a link to give their own details and consent. Refusals carry a stable code and, for a field, its name — invalid (400, with field), listing_not_found (404), consent_changed (409: fetch /applications/consent again), unavailable (503). Success: 201 { applicationId, leadId, invited, emailed }.","security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Received"},"400":{"description":"invalid"},"403":{"description":"Missing applications:write"},"404":{"description":"listing_not_found"},"409":{"description":"consent_changed"},"503":{"description":"unavailable"}}}},"/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: CALL_INTENT, meaning they tapped a tel: link, or SMS_INTENT, meaning they tapped an sms: 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 (or a text) — 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","SMS_INTENT"],"default":"CALL_INTENT","description":"CALL_INTENT for a tel: tap, SMS_INTENT for an sms: tap. A confirmed SMS_INTENT becomes an SMS lead, never a PHONE one."},"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)"}}}},"/leads/events":{"post":{"summary":"Report what a person did after their lead","description":"The site reports facts about a person FullArc already has a lead or contact for; FullArc decides what follows. Body: { email, event?, details?: { firstName?, lastName?, city?, province?, phone? } } — at least one of event/details. `event` is a short lower-case name (\"audit_run\"): the person acted, so their drips stop. `details` update their contact (never the lead). Nothing is created for an unknown email. Recording a phone implies no permission to text it. Scope: leads:write (the site Lead token).","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Reported — { success, data: { dripsStopped, contactUpdated } }"},"400":{"description":"invalid_json | invalid_email | invalid_event | nothing_to_report"},"403":{"description":"Insufficient scope (leads:write)"},"404":{"description":"lead_not_found — no lead or contact with that email; send the lead first"}}}},"/subscribers":{"post":{"summary":"Add a newsletter signup","description":"Puts a person on one of the token tenant's newsletter lists, with the CASL consent they gave on your form. Scope: subscribers:write (the site Lead token, created on or after 2026-10-14). Call it from your SERVER. `list` is the list NAME as your site knows it (\"Newsletter\"), matched case-insensitively; an unknown name creates the list. `consent.text` is required: the exact wording beside a separate, UNTICKED checkbox. It is kept with its SHA-256, `consent.version`, `consent.consentedAt` (ISO 8601, default now), `pageUrl`, the person's `visitor` block and `sessionId`. Creates no lead or contact and alerts nobody. Someone who unsubscribed (or complained, or is on the Do-Not-Contact list) is NEVER re-subscribed: the answer is `unsubscribed` and nothing is written, so tell them to contact you rather than promising mail. No double opt-in: the subscriber is active at once.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","list","consent"],"properties":{"email":{"type":"string","format":"email"},"name":{"type":"string","description":"Split on the first space; firstName/lastName win over it"},"firstName":{"type":"string"},"lastName":{"type":"string"},"list":{"type":"string","maxLength":100,"description":"The list name, never an id"},"consent":{"type":"object","required":["text"],"properties":{"text":{"type":"string","maxLength":4000,"description":"The exact checkbox wording shown"},"version":{"type":"string","maxLength":40},"consentedAt":{"type":"string","format":"date-time"}}},"source":{"type":"string","description":"Defaults to the host of pageUrl"},"pageUrl":{"type":"string","format":"uri"},"sessionId":{"type":"string"},"visitor":{"type":"object","description":"The person as your server saw them, as on POST /leads","properties":{"ip":{"type":"string"},"city":{"type":"string"},"region":{"type":"string"},"country":{"type":"string"}}}}}}}},"responses":{"200":{"description":"{ success, status: 'already_subscribed' | 'unsubscribed', list } — already on it (nothing changed), or this person said no before (nothing written)"},"201":{"description":"{ success, status: 'subscribed', list } — on the list, consent recorded"},"400":{"description":"code: invalid_json | invalid_email | invalid_list | invalid_consent | invalid_consented_at"},"403":{"description":"code: insufficient_scope (token lacks subscribers:write) | origin_not_allowed"},"500":{"description":"code: subscribe_failed"}}}},"/newsletters":{"post":{"summary":"Leave a newsletter draft for the owner to review and send","description":"Creates a DRAFT newsletter in the token tenant's account, in FullArc's email-builder blocks, addressed to subscriber lists by NAME (case-insensitive; an unknown name is refused, never created). The owner reviews it in /dashboard/newsletters and sends it; this endpoint NEVER schedules or sends anything. `name` is the idempotency key: a DRAFT with the same name is updated (content, subject, preheader, lists — replacing edits made in FullArc); a newsletter with that name past DRAFT is refused with 409 and left unchanged. Scope: newsletters:write (the site Lead token, created on or after the evening of 2026-10-14). Call it from your SERVER. Block types accepted: header, hero, heading, text, button, image, imagetext, productgrid, productcard, divider, spacer, section, columns, footer — `html` and every other type are refused. Every URL must be absolute https:// (a link may also be exactly {{unsubscribeLink}}); plain-text fields may not contain < or >; text.html, imagetext.html and footer.customHtml are sanitised to an allow-list; style values are CSS values only. At most 200 blocks, nested included; body at most 1 MB.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","subject","lists","blocks"],"properties":{"name":{"type":"string","maxLength":200,"description":"The draft's internal name, and its idempotency key"},"subject":{"type":"string","maxLength":200},"preheader":{"type":"string","maxLength":300},"lists":{"type":"array","minItems":1,"maxItems":20,"items":{"type":"string","maxLength":100},"description":"Subscriber list names, never ids"},"blocks":{"type":"array","minItems":1,"maxItems":200,"items":{"type":"object","required":["type"],"properties":{"type":{"type":"string"},"id":{"type":"string","description":"Optional; assigned when missing"},"content":{"type":"object"},"style":{"type":"object"}}}},"fromName":{"type":"string","maxLength":100},"replyTo":{"type":"string","format":"email"}}}}}},"responses":{"200":{"description":"{ success, status: 'updated', newsletterId, editUrl } — the draft of that name was refreshed"},"201":{"description":"{ success, status: 'created', newsletterId, editUrl }"},"400":{"description":"code: invalid_json | invalid_name | invalid_subject | invalid_preheader | invalid_lists | unknown_list (+ list) | invalid_from_name | invalid_reply_to | invalid_blocks | too_many_blocks | invalid_block (+ path, e.g. blocks[2].content.ctaLink)"},"403":{"description":"code: insufficient_scope (token lacks newsletters:write) | origin_not_allowed"},"409":{"description":"code: already_sent — a newsletter with this name is past DRAFT (newsletterId, newsletterStatus, editUrl); nothing changed"},"413":{"description":"code: body_too_large"},"500":{"description":"code: draft_failed"}}}},"/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?{}, logoUrl?, website?, facebookUrl?, instagramUrl?, linkedinUrl?, address?, postalCode?, title? }. The profile fields (website/facebookUrl/instagramUrl/linkedinUrl must be http/https, max 2048 chars; address max 300; postalCode max 20; title — the person's role, e.g. Owner — max 120) are trimmed and, on an existing contact, only FILL EMPTY columns: a value already on the contact is never overwritten. An invalid profile field is ignored rather than failing the request and is named in the response field `ignored: string[]`. `logoUrl` (http/https) is an image copied into FullArc storage after the contact is saved — PNG, JPEG, WebP or GIF up to 2 MB; SVG is rasterised to PNG; private hosts are refused. The stored URL is written to customFields.logoUrl (with logoSourceUrl, logoStoredAt) and to photoUrl only if that is empty. A logo failure never fails the request: the response field `logo` says { stored:true, url } or { stored:false, reason }. Re-sending the same logoUrl does not download it again. `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, logo? }"},"201":{"description":"Created — { success, contactId, isNew:true, logo? }"},"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}], brandLayout?:boolean, unsubscribe?:boolean, track?:boolean, leadId?:string }. `track: true` measures opens and clicks with FullArc’s own pixel and link redirect (links are rewritten; mailto:, tel: and the unsubscribe link are not). `leadId` ties the send to a lead in this account so it shows on that lead’s page; an id that is not this account’s is ignored and the send still goes (`leadLinked: false`). `brandLayout: true` wraps `html` (plain <p> paragraphs; `<p><a data-button href>` becomes a button) in the account's branded frame: logo, card, footer with the business name, address and contact. `unsubscribe: true` marks the message COMMERCIAL (it promotes something): an unsubscribe link for this account goes last in that footer and in List-Unsubscribe headers, and a recipient who unsubscribed is refused. It needs brandLayout. `from` must be on the tenant’s verified sending domain; omit it to use the stream default. Scope: emails:send, AND the account may use the Email API on that stream: its own verified sending domain for the stream, or access granted by FullArc. Every error carries a stable `code` — match on that, not on the prose.","security":[{"bearerAuth":[]}],"responses":{"201":{"description":"Sent — { success, messageId, logId, sentAsPlatform, leadLinked }"},"400":{"description":"Validation error — code: invalid_recipient | missing_subject | missing_html | invalid_json | invalid_attachment | unsubscribe_needs_brand_layout"},"401":{"description":"code: unauthenticated | invalid_token"},"403":{"description":"code: insufficient_scope (token lacks emails:send) | email_api_disabled (no verified sending domain for this stream and no FullArc grant)"},"422":{"description":"code: no_tenant | recipient_suppressed (permanent — stop sending to this address) | from_not_allowed (`from` is not on a verified sending domain of this account) | recipient_unsubscribed (a commercial message to someone who unsubscribed from this account)"},"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. Scope: webhooks:manage.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Subscriptions (secrets masked)"},"403":{"description":"Insufficient scope (webhooks:manage)"}}},"post":{"summary":"Create a webhook subscription. Scope: webhooks:manage.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookSubscription"}}}},"responses":{"201":{"description":"Created — secret returned once"},"403":{"description":"Insufficient scope (webhooks:manage)"}}},"patch":{"summary":"Update a subscription (?id=). Scope: webhooks:manage.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Updated"},"403":{"description":"Insufficient scope (webhooks:manage)"}}},"delete":{"summary":"Delete a subscription (?id=). Scope: webhooks:manage.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Deleted"},"403":{"description":"Insufficient scope (webhooks:manage)"}}}}}}