Fyndol API & MCP
Fyndol is the all-in-one CRM and marketing platform for marketing agencies of any niche. Connect AI assistants and your own software to a Fyndol agency. Everything is limited to the agency (and sub-accounts) the person who connected can access.
https://app.fyndol.com/mcp — Streamable HTTP, OAuth 2.1 sign-in (Dynamic Client Registration + PKCE). Add it as a custom connector in your AI app; you sign in to Fyndol and pick what it may do.REST API:
https://app.fyndol.com/api/v1 · OpenAPI 3.1: /api/v1/openapi.jsonAuthentication
- OAuth 2.1 — metadata at
/.well-known/oauth-authorization-server. Apps acting for a person use the authorization code flow with PKCE (S256); access tokens last 1 hour, refresh tokens 30 days (rotated on use). Server-to-server apps use client credentials: an agency owner/admin creates them in Settings → Connect AI apps → Developers. - API keys — one sub-account each, made by an owner/admin in Settings → Webhooks & API → Developers.
curl https://app.fyndol.com/api/v1/contacts?limit=10 \ -H "Authorization: Bearer <access token or API key>"
Scopes
| Scope | What it allows |
|---|---|
contacts.read | Read contacts, tags and custom fields |
conversations.read | Read conversations (inbox summaries) |
opportunities.read | Read pipelines and opportunities |
workflows.read | Read workflows and their health |
calendars.read | Read calendars and appointments |
sites.read | Read funnels and websites |
reports.read | Read reports and KPIs |
usage.read | Read AI usage and estimated cost (agency owner/admin only) |
changes.propose | Propose changes (you approve them in Fyndol, unless a sub-account is set to “without asking”) |
changes.auto | Allow changes without approval (they can start your published workflows; the app still can’t send, delete, buy or see keys) (agency owner/admin only) |
Changes and approvals
A change (POST /api/v1/actions or an MCP write tool) becomes a pending change with an approve_url, and the person approves it in Fyndol — unless the connection has changes.auto (owner/admin only) or the sub-account's AI changes setting is “without asking”; then it runs right away, the reply says why (reason) and the person is notified (onboarding a client, draft invoices and contracts, stopping workflow enrollments and turning on a contact's consent to be called or texted always wait). The contact, tag and opportunity endpoints below follow the same rule for OAuth tokens: they write directly when the change may run right away (otherwise 403 approval_required — use POST /api/v1/actions), and they also need contacts.read (contacts) or opportunities.read (opportunities), because they answer with the record. API keys write directly. No endpoint or tool sends texts or emails, deletes records, buys anything or reads keys — but a change such as adding a tag, creating a contact or moving a deal starts the sub-account's published workflows, which may text, email or call contacts.
Consent and Do Not Disturb
An API key or a connected app can never turn ON a contact's consent to be called or texted (custom fields like ai_call_consent, sms_consent, tcpa_consent, sms_opt_in) or turn OFF Do Not Disturb. Only the contact can give consent (a Fyndol form, booking page or their own reply), or a person sets it on the contact in Fyndol. POST /contacts still saves the contact without those values and lists them in ignored_fields and warnings (a note on the contact says what was sent); PATCH /contacts/{id} answers 403 and changes nothing; an MCP or /actions change that does it waits for a person's approval. Saying No, sending a Yes the contact already gave and turning Do Not Disturb on always work. Other fields, like terms_consent or gdpr_consent, save normally.
Pagination, limits and errors
- Lists take
limit(1–100, default 25) andoffsetand return{ data, pagination: { total, limit, offset, has_more, next_offset } }. - 120 requests per minute per token or key. Every reply has
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset; over the limit you get429withRetry-After. - Errors:
{ "error": "plain message", "code": "snake_case" }with status 400 / 401 / 403 / 404 / 409 / 429.
Endpoints
| Endpoint | Parameters | |
|---|---|---|
| GET | /api/v1/sub-accountsList sub-accounts Sub-accounts (locations) this token can reach, with contact counts. | limit — Items per pageoffset — Items to skip |
| GET | /api/v1/conversationsList conversations Recent conversations (inbox) with the last message preview, newest first. Scope: conversations.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)unread_only — Only unread conversationslimit — Items per pageoffset — Items to skip |
| GET | /api/v1/opportunitiesList opportunities Opportunities (deals), most recently updated first. Scope: opportunities.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)pipeline_idstage_idstatus — open | won | lost | abandonedq — Name containslimit — Items per pageoffset — Items to skip |
| POST | /api/v1/opportunitiesCreate an opportunity Same rules as Create a contact, with opportunities.read instead of contacts.read for OAuth tokens; otherwise POST /actions (create_opportunity). With a "contact" object the contact is saved first (found by email / phone, or created): when that created or changed a contact, the reply has contact_id with contact_created or contact_updated — also in a 400 when the deal itself is refused (e.g. its stage needs a deal value), so you know the contact was saved. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)Body: pipeline_id, stage_id, contact_id, contact, name, value, status, assigned_to |
| GET | /api/v1/tagsList tags Tags with how many contacts have each. Scope: contacts.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)limit — Items per pageoffset — Items to skip |
| GET | /api/v1/custom-fieldsList custom fields Contact and opportunity custom fields. Scope: contacts.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)limit — Items per pageoffset — Items to skip |
| GET | /api/v1/workflowsList workflows Workflows (automations): status, trigger, step count, contacts still inside. Scope: workflows.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)limit — Items per pageoffset — Items to skip |
| GET | /api/v1/workflows/{id}Get a workflow One workflow with its trigger and steps. Scope: workflows.read. | id* |
| GET | /api/v1/workflow-healthWorkflow health Problems (errors/warnings) per workflow. Scope: workflows.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)limit — Items per pageoffset — Items to skip |
| GET | /api/v1/calendarsCalendars and upcoming appointments Calendars plus appointments in a range (default: next 14 days). Scope: calendars.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)from — ISO dateto — ISO date |
| GET | /api/v1/sitesFunnels and websites Funnels/websites with pages, views and form submissions, plus custom domains. Scope: sites.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account) |
| GET | /api/v1/reports/summaryKPI summary New leads by source, opportunities created/won/lost, appointments, messages, form submissions over the last N days. Scope: reports.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)days — Look-back window (default 7) |
| GET | /api/v1/reports/clientClient report The Client Report numbers (what an agency shows its client). Scope: reports.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)range — this_month | last_month | last_7_days | last_30_days | …fromto |
| GET | /api/v1/reports/ai-setterAI setter stats AI call numbers: calls, answered, booked, outcomes, call length, show rate, by lead source. Scope: reports.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)period — this_week | last_week | last_4include_test |
| GET | /api/v1/invoicesList invoices Invoices (newest first) with status, total, balance, due date and hosted link; plus outstanding / overdue totals. Scope: reports.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)status — draft | sent | viewed | partially_paid | paid | overdue | void | refundedcontact_idlimit — Items per pageoffset — Items to skip |
| GET | /api/v1/paymentsList payments Payments (transactions): amount, status, method, contact, invoice number; plus money collected in the last 30 days. Read-only. Scope: reports.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)status — succeeded | failed | refunded | partially_refundedlimit — Items per pageoffset — Items to skip |
| GET | /api/v1/contractsList contracts Contracts (newest first) with status, signers signed / total, total and dates; plus the contract templates. Read-only: contracts are only sent and signed inside Fyndol. Scope: contacts.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)status — draft | sent | viewed | partially_signed | signed | declined | expired | voidcontact_idlimit — Items per pageoffset — Items to skip |
| GET | /api/v1/usageAI usage (owner/admin) AI calls and estimated cost for the agency or one sub-account. OAuth tokens of an owner/admin only. Scope: usage.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)fromto |
| GET | /api/v1/contactsSearch contacts Contacts, newest first. Scope: contacts.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)q — Name, email or phone containsemailtaglimit — Items per pageoffset — Items to skip |
| POST | /api/v1/contactsCreate a contact API keys with contacts.write. OAuth tokens with changes.propose (or changes.auto) and contacts.read, when the change may run without approval (the connection may make changes without approval, or the sub-account's AI changes setting is “without asking”); otherwise 403 approval_required: use POST /actions (create_contact). Consent to be called or texted (custom ai_call_consent, sms_consent, tcpa_consent, sms_opt_in…) is never turned on here: the contact is still saved without those values, they are listed in ignored_fields and warnings, and a note on the contact says what was sent. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)Body: first_name, last_name, email, phone, company, source, tags, custom, upsert |
| GET | /api/v1/contacts/{id}Get a contact | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)id* |
| PATCH | /api/v1/contacts/{id}Update a contact Same rules as Create a contact, except that turning on consent to be called or texted (custom ai_call_consent, sms_consent, tcpa_consent…) or turning dnd off answers 403 and changes nothing. Sending No, a Yes the contact already gave, or dnd: true works. An empty body changes nothing. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)id*Body: first_name, last_name, email, phone, company, custom, dnd, assigned_to |
| POST | /api/v1/contacts/{id}/tagsAdd tags to a contact Same rules as Create a contact. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)id*Body: tags |
| GET | /api/v1/appointmentsList appointments Default range: last 7 days to next 60 days. Scope: calendars.read. | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account)fromtocalendar_idcontact_idstatus |
| GET | /api/v1/pipelinesList pipelines with stages | location_id — Sub-account id (not needed for API keys, tokens limited to one sub-account, or agencies with one sub-account) |
| POST | /api/v1/actionsPropose a change (OAuth) Creates a change. By default it waits for the user's approval in Fyndol (Settings → Connect AI apps → Approvals) and returns 202 with approve_url; if the connection may make changes without approval, or the sub-account's AI changes setting is “without asking”, it runs right away. Tools: stop_active_runs, create_pipeline, add_pipeline_stage, rename_pipeline_stage, create_opportunity, move_opportunity, set_opportunity_status, create_contact, update_contact, tag_contacts, untag_contacts, create_tag, add_contact_note, create_task, create_workflow, update_workflow, create_workflow_draft, create_report_share_link, onboard_client, create_draft_invoice, create_draft_contract. onboard_client, stop_active_runs, create_draft_invoice and create_draft_contract always wait for approval. No tool sends messages, deletes records, bills or reveals keys, but tag, contact and opportunity changes can start the sub-account's published workflows, which may send texts or emails. | — Body: tool, input |
| GET | /api/v1/actions/{id}Status of a proposed change | id* |
Sub-account ids come from GET /api/v1/sub-accounts. Tokens limited to one sub-account (and API keys) don't need location_id.
Inbound webhooks (leads from website forms, Zapier, Make)
Make an inbound URL in Settings → Webhooks & API → Inbound webhooks. POST JSON (or form fields) to it; no key or login is needed (the URL is the secret). Send at least one of email, phone, first_name (or name); last_name, company and tags (only used when the URL allows them) are optional, and any other field can be used in that URL's workflows as {{webhook.<field>}}.
- Success:
200with{"ok":true}, the same reply whether the contact is new or already existed. It never includes a contact id. - Errors:
400{ "error": "plain message" }when the data sent can't be used (no email, phone or name; an email or phone that isn't valid) ·404wrong URL ·429too many posts (waitRetry-Afterseconds).
curl -X POST '<your inbound webhook URL>' \
-H 'Content-Type: application/json' \
-d '{"first_name":"Jane","email":"jane@example.com","phone":"5105550100","message":"Need a quote"}'
# → {"ok":true}