Developers
Your data, anywhere you need it
Persilio captures every lead together with the full story of how it found you — channel, referrer, UTM parameters, click IDs, and the visitor's page-by-page journey. The REST API and MCP server give you that data programmatically: wire it into your reporting, your warehouse, or your AI agents.
On this page
Overview
The Persilio API is a REST API over HTTPS with JSON responses. It exposes your leads — including the complete acquisition attribution and visitor journey behind each one — your lead and traffic analytics, and, for organizations with a Persilio-managed website, the website CMS: read and edit pages as content blocks and publish the site.
Base URL: https://api.persilio.com/api/v1
MCP server: https://api.persilio.com/mcp | Endpoint | Description |
|---|---|
| GET/me | Verify your key and identify the connected organization. |
| GET/leads | List leads newest-first with full acquisition attribution; filterable and paginated. |
| GET/leads/{id} | One lead in full: contact, activity history, and the tracked visitor journey. |
| GET/analytics/leads | Aggregate lead analytics for a time range. |
| GET/analytics/traffic | Aggregate website traffic analytics for a time range. |
| GET/cms/sites | List CMS-managed websites with each site’s block vocabulary. |
| GET/cms/pages | List a site’s pages; add ?slug= to fetch one page in full. |
| GET/cms/pages/{id} | One page’s full draft: blocks + the field schema they follow. |
| POST/cms/pages | Create a draft page, optionally from a template. |
| POST/cms/pages/{id} | Update a page’s title, SEO, or full block array. |
| POST/cms/pages/{id}/blocks | Add, edit, or remove a single block. |
| POST/cms/publish | Publish draft changes: commit to GitHub + redeploy the site. |
| GET/cms/publishes/{id} | Publish progress: committed → building → deployed / failed. |
Authentication
Every request is authenticated with an org-scoped API key. Create one in the dashboard under Settings → API access (owners and managers only). The key is shown once at creation — store it like a password. Keys can be revoked at any time from the same screen, and revocation takes effect immediately.
Send the key as a bearer token on every request:
Authorization: Bearer psk_live_4f8a2c… - A key only ever sees the organization it was created in.
- Keys are stored hashed — Persilio cannot show you a key again after creation.
-
A key acts as one person and carries scopes that
are the dashboard's own areas —
leads,contacts,messages,billing,analytics,reports,reviews,cms,seoand the rest of the sidebar. A key can never reach more than its holder can open in the dashboard, and it follows any change to that person's access on the Team page. A scoped key only reaches endpoints covered by its scopes, so a build-server key can render reviews without ever being able to read customer PII. A key with no scopes has everything its holder has. -
The leads and analytics surfaces are read-only. Writes live behind explicit scopes:
cmsedits website content,billingcreates and edits quotes, invoices, templates and e-signature links, andcontactsmanages the contact book.
Rate limits
Each key may make 120 requests per minute with bursts up to 30. The MCP
server draws from the same budget. Over the limit, requests return
429 with a Retry-After header (in
seconds) — wait that long and retry.
HTTP/2 429
Retry-After: 12
{ "error": { "message": "Rate limit exceeded.", "status": 429 } } Conventions & pagination
- Timestamps are Unix epoch milliseconds everywhere — request parameters and response fields alike.
- Optional fields with no value are omitted from responses, not returned as
null. Code defensively:lead.gclid ?? null. - IDs are opaque strings. Don't parse them; store and compare them as-is.
- Dates in analytics (
byDay) are UTC calendar days.
Pagination
GET /leads paginates with an opaque numeric cursor. Fetch the
first page normally; while the response's nextCursor is
non-null, request the next page with cursor=<nextCursor>.
When it comes back null, you have everything.
# page 1
curl -s "https://api.persilio.com/api/v1/leads?limit=100" -H "Authorization: Bearer $KEY"
# → { "leads": [ …100 leads… ], "nextCursor": 1750912345678 }
# page 2
curl -s "https://api.persilio.com/api/v1/leads?limit=100&cursor=1750912345678" -H "Authorization: Bearer $KEY"
# → { "leads": [ …remaining leads… ], "nextCursor": null } Endpoints
GET/me
Verifies your key and returns the organization it belongs to. Use it as a connectivity and auth check when setting up an integration.
Request
No parameters.
curl -s https://api.persilio.com/api/v1/me \
-H "Authorization: Bearer psk_live_…" Response
| Field | Type | Description |
|---|---|---|
| orgId | string | The organization's id. All data returned by this key belongs to this org. |
| org.name | string | Organization display name. |
| org.domain | string | The org's website domain as configured in Persilio. |
| org.websiteUrl | string? | Full website URL, when set. |
| org.businessPhone | string? | Primary business phone, when set. |
| key.name | string | The name this API key was given when created. |
{
"orgId": "kd701nhcc2qedbce39g2qjzp8x826em9",
"org": {
"name": "Example HVAC Co",
"domain": "example-hvac.com",
"websiteUrl": "https://www.example-hvac.com",
"businessPhone": "+15551234567"
},
"key": { "name": "Reporting script" }
} GET/leads
Lists leads newest-first. Every lead includes its full first-touch attribution — channel, referrer, landing page, UTM parameters, and click IDs. See the lead object for all fields.
Request
| Parameter | Type | Description |
|---|---|---|
| status | string | Filter by lead status. See Attribution values for the full list (new, contacted, estimated, scheduled, closed, lost, …). |
| channel | string | Filter by resolved acquisition channel: organic, ppc, social, referral, direct, or ai. |
| query | string | Free-text match against the lead name, email, or phone. |
| createdAfter | number | Only leads created after this Unix timestamp in milliseconds. |
| createdBefore | number | Only leads created before this Unix timestamp in milliseconds. |
| limit | number | Page size, 1–100. Default 25. |
| cursor | number | Pagination cursor — pass the nextCursor value from the previous page. |
# Most recent 50 leads
curl -s "https://api.persilio.com/api/v1/leads?limit=50" \
-H "Authorization: Bearer psk_live_…"
# New PPC leads from June 2026
curl -s "https://api.persilio.com/api/v1/leads?status=new&channel=ppc&createdAfter=1780272000000&createdBefore=1782864000000" \
-H "Authorization: Bearer psk_live_…"
# Search by name / email / phone
curl -s "https://api.persilio.com/api/v1/leads?query=jane" \
-H "Authorization: Bearer psk_live_…" Response
| Field | Type | Description |
|---|---|---|
| leads | lead[] | Array of lead objects — see The lead object below for every field. |
| nextCursor | number | null | Non-null when more results may exist. Pass it back as cursor to fetch the next page; stop when null. |
{
"leads": [
{
"id": "kd74x8m2p9qfz3hcvw6t5n1jrb89kd2e",
"createdAt": 1751212345678,
"cameInAt": 1751212345678,
"name": "Jane Example",
"email": "jane@example.com",
"phone": "5551234567",
"message": "Our AC went out yesterday — looking for a repair quote this week.",
"status": "contacted",
"source": "website_form",
"sourceLabel": "AC Repair",
"channel": "ppc",
"referrer": "https://www.google.com/",
"landingPage": "/services/ac-repair",
"sourceUrl": "https://www.example-hvac.com/services/ac-repair",
"utmSource": "google",
"utmMedium": "cpc",
"utmCampaign": "summer-ac-repair",
"utmTerm": "ac repair near me",
"utmContent": "ad-variant-b",
"gclid": "EAIaIQobChMI8p2c4vHq9gIVxxWtBh2QkQAxEAAYASAAEgL",
"sessionId": "b3f09c41-77aa-4c1e-9f2d-2f4e8d1c6a55",
"visitorId": "9a1c3f7e-d2b8-4e06-8f31-5c7a2e9d4b10",
"contactId": "s17k2m9w4rvq8n3xzp6c1f5hjd89tm3a",
"pipelineStage": "Contacted",
"notes": ["Called back — wants an estimate Thursday morning."],
"lastActivityAt": 1751215600000
}
],
"nextCursor": 1750912345678
} GET/leads/{id}
Returns one lead in full: every field from the lead object, plus the linked contact, the activity history (up to 50 entries, newest first), and — when the lead came through a tracked website — the journey object.
Request
Path parameter: the lead id from GET /leads. No query parameters.
curl -s https://api.persilio.com/api/v1/leads/kd74x8m2p9qfz3hcvw6t5n1jrb89kd2e \
-H "Authorization: Bearer psk_live_…" Response
The complete shape (optional fields with no value are omitted in real responses):
{
"id": "kd74x8m2p9qfz3hcvw6t5n1jrb89kd2e",
"createdAt": 1751212345678,
"cameInAt": 1751212345678,
"name": "Jane Example",
"email": "jane@example.com",
"phone": "5551234567",
"message": "Our AC went out yesterday — looking for a repair quote this week.",
"status": "contacted",
"source": "website_form",
"sourceLabel": "AC Repair",
"channel": "ppc",
"referrer": "https://www.google.com/",
"landingPage": "/services/ac-repair",
"sourceUrl": "https://www.example-hvac.com/services/ac-repair",
"utmSource": "google",
"utmMedium": "cpc",
"utmCampaign": "summer-ac-repair",
"utmTerm": "ac repair near me",
"utmContent": "ad-variant-b",
"gclid": "EAIaIQobChMI8p2c4vHq9gIVxxWtBh2QkQAxEAAYASAAEgL",
"sessionId": "b3f09c41-77aa-4c1e-9f2d-2f4e8d1c6a55",
"visitorId": "9a1c3f7e-d2b8-4e06-8f31-5c7a2e9d4b10",
"contactId": "s17k2m9w4rvq8n3xzp6c1f5hjd89tm3a",
"pipelineStage": "Contacted",
"notes": ["Called back — wants an estimate Thursday morning."],
"lastActivityAt": 1751215600000,
"contact": {
"id": "s17k2m9w4rvq8n3xzp6c1f5hjd89tm3a",
"name": "Jane Example",
"firstName": "Jane",
"lastName": "Example",
"email": "jane@example.com",
"phone": "5551234567",
"company": "Example Property Management"
},
"activities": [
{ "type": "status_change", "content": "Status changed from new to contacted", "timestamp": 1751215600000, "authorName": "Alex Rivera" },
{ "type": "note", "content": "Called back — wants an estimate Thursday morning.", "timestamp": 1751215500000, "authorName": "Alex Rivera" },
{ "type": "lead_created", "content": "New lead from website form", "timestamp": 1751212345678, "authorName": "System" }
],
"journey": {
"sessionId": "b3f09c41-77aa-4c1e-9f2d-2f4e8d1c6a55",
"visitorId": "9a1c3f7e-d2b8-4e06-8f31-5c7a2e9d4b10",
"startedAt": 1751212001000,
"lastSeenAt": 1751212390000,
"landingPage": "/services/ac-repair",
"landingUrl": "https://www.example-hvac.com/services/ac-repair?utm_source=google&utm_medium=cpc",
"referrer": "https://www.google.com/",
"channel": "ppc",
"sourceLabel": "Google Ads",
"utmSource": "google",
"utmMedium": "cpc",
"utmCampaign": "summer-ac-repair",
"utmTerm": "ac repair near me",
"utmContent": "ad-variant-b",
"gclid": "EAIaIQobChMI8p2c4vHq9gIVxxWtBh2QkQAxEAAYASAAEgL",
"device": { "type": "mobile", "browser": "Safari", "browserVersion": "18.5", "os": "iOS", "osVersion": "18.5" },
"geo": { "country": "US", "region": "TX", "city": "Austin" },
"isReturning": true,
"visitNumber": 3,
"pageviews": [
{ "path": "/services/ac-repair", "title": "AC Repair", "timestamp": 1751212001200, "durationMs": 42000 },
{ "path": "/reviews", "title": "Reviews", "timestamp": 1751212043200, "durationMs": 28000 },
{ "path": "/contact", "title": "Contact Us", "timestamp": 1751212071200, "durationMs": 61000 }
],
"events": [
{ "type": "scroll_depth", "path": "/services/ac-repair", "timestamp": 1751212030000, "scrollDepth": 75 },
{ "type": "tel_click", "path": "/services/ac-repair", "timestamp": 1751212040000, "elementText": "(555) 123-4567" },
{ "type": "form_start", "path": "/contact", "timestamp": 1751212080000, "formName": "contact", "fieldName": "name" },
{ "type": "form_submit", "path": "/contact", "timestamp": 1751212335000, "formName": "contact" }
],
"otherVisits": [
{ "startedAt": 1750901000000, "landingPage": "/", "channel": "organic", "referrer": "https://www.google.com/" },
{ "startedAt": 1751050000000, "landingPage": "/reviews", "channel": "direct" }
]
}
}
Returns 404 when the lead doesn't exist or belongs to a
different organization; 400 for a malformed id.
GET/analytics/leads
Aggregate lead acquisition for a time range: total plus breakdowns by resolved channel, source label, status, and day. Career-form job applications are excluded from the counts and reported separately.
Request
| Parameter | Type | Description |
|---|---|---|
| since | number | Range start, Unix timestamp in milliseconds. Default: 30 days ago. |
| until | number | Range end, Unix timestamp in milliseconds. Default: now. |
# Default range (last 30 days)
curl -s https://api.persilio.com/api/v1/analytics/leads \
-H "Authorization: Bearer psk_live_…"
# Explicit range — June 2026 (ms timestamps)
curl -s "https://api.persilio.com/api/v1/analytics/leads?since=1780272000000&until=1782864000000" \
-H "Authorization: Bearer psk_live_…"
# Tip: build ms timestamps from a date
date -d "2026-06-01" +%s000 # linux
python3 -c "import datetime; print(int(datetime.datetime(2026,6,1).timestamp()*1000))" Response
| Field | Type | Description |
|---|---|---|
| range | object | The since/until range actually used (Unix ms). |
| total | number | Sales leads in range (job applications excluded). |
| jobApplicationsExcluded | number | Career-form submissions in range, excluded from all counts. |
| byChannel | object | Lead counts keyed by resolved channel. Missing attribution groups under "(none)". |
| bySource | object | Lead counts keyed by source label. |
| byStatus | object | Lead counts keyed by current status. |
| byDay | object[] | Per-day counts: { date: "YYYY-MM-DD" (UTC), count }. |
| truncated | boolean | True when the range hit the per-request scan cap — narrow the window. |
{
"range": { "since": 1780272000000, "until": 1782864000000 },
"total": 42,
"jobApplicationsExcluded": 3,
"byChannel": { "organic": 18, "ppc": 12, "direct": 7, "referral": 3, "ai": 2 },
"bySource": { "AC Repair": 21, "Furnace Install": 11, "(none)": 10 },
"byStatus": { "new": 9, "contacted": 14, "scheduled": 8, "closed": 7, "lost": 4 },
"byDay": [
{ "date": "2026-06-01", "count": 2 },
{ "date": "2026-06-02", "count": 1 }
],
"truncated": false
} GET/analytics/traffic
Website traffic from Persilio's visitor tracking for a time range: session and pageview totals, conversion and returning-visitor counts, and breakdowns by channel, device, country, and day, plus top referrers, landing pages, and pages.
Request
| Parameter | Type | Description |
|---|---|---|
| since | number | Range start, Unix timestamp in milliseconds. Default: 30 days ago. |
| until | number | Range end, Unix timestamp in milliseconds. Default: now. |
curl -s "https://api.persilio.com/api/v1/analytics/traffic?since=1780272000000&until=1782864000000" \
-H "Authorization: Bearer psk_live_…" Response
| Field | Type | Description |
|---|---|---|
| range | object | The since/until range actually used (Unix ms). |
| sessions | number | Tracked sessions in range. |
| pageviews | number | Tracked pageviews in range. |
| returningSessions | number | Sessions from visitors with prior visits. |
| sessionsWithLead | number | Sessions that converted into a lead. |
| byChannel | object | Session counts keyed by resolved channel. |
| byDevice | object | Session counts keyed by device type. |
| byCountry | object[] | Top 10 countries: { key, count }. |
| byDay | object[] | Per-day session counts: { date: "YYYY-MM-DD" (UTC), count }. |
| topReferrers | object[] | Top 10 referrers: { key, count }. |
| topLandingPages | object[] | Top 10 landing pages: { key, count }. |
| topPages | object[] | Top 15 pages by views: { key, count }. |
| truncated | boolean | True when the range hit the per-request scan cap — narrow the window. |
{
"range": { "since": 1780272000000, "until": 1782864000000 },
"sessions": 4064,
"pageviews": 2905,
"returningSessions": 312,
"sessionsWithLead": 6,
"byChannel": { "direct": 3536, "organic": 441, "referral": 69, "ai": 11, "social": 7 },
"byDevice": { "mobile": 2983, "desktop": 1080, "tablet": 1 },
"byCountry": [
{ "key": "US", "count": 3901 },
{ "key": "CA", "count": 82 }
],
"byDay": [
{ "date": "2026-06-01", "count": 141 },
{ "date": "2026-06-02", "count": 129 }
],
"topReferrers": [
{ "key": "https://www.google.com/", "count": 431 },
{ "key": "https://chatgpt.com/", "count": 6 }
],
"topLandingPages": [
{ "key": "/", "count": 1512 },
{ "key": "/services/ac-repair", "count": 244 }
],
"topPages": [
{ "key": "/", "count": 794 },
{ "key": "/contact", "count": 248 }
],
"truncated": false
} Object reference
The lead object
Returned by GET /leads (in the leads array)
and GET /leads/{id} (top level). Fields marked with
? are omitted when they have no value.
| Field | Type | Description |
|---|---|---|
| id | string | Unique lead id. Use with GET /leads/{id}. |
| createdAt | number | When the lead record was created (Unix ms). |
| cameInAt | number | When the lead actually came in (Unix ms). Differs from createdAt for imported/backdated leads. |
| name | string | The lead's full name. |
| string? | Email address, when provided. | |
| phone | string? | Phone number, when provided. |
| message | string? | The message submitted with the form or captured from the call. |
| status | string | Current pipeline status. See Attribution values for all statuses. |
| leadKind | string? | Set to job_application for career-form submissions; absent for sales leads. |
| source | string | How the lead entered Persilio: website_form, manual, organic_call, or ppc_call. |
| sourceLabel | string? | Human-set label. For website_form leads this is usually the service the visitor selected. |
| channel | string? | Persilio's resolved first-touch acquisition channel. See Attribution values. |
| referrer | string? | The referring URL from the visitor session that produced the lead. |
| landingPage | string? | First page of the visit that produced the lead. |
| sourceUrl | string? | URL of the page the lead was submitted from. |
| utmSource | string? | utm_source from the first touch. |
| utmMedium | string? | utm_medium from the first touch. |
| utmCampaign | string? | utm_campaign from the first touch. |
| utmTerm | string? | utm_term from the first touch. |
| utmContent | string? | utm_content from the first touch. |
| gclid | string? | Google Ads click id, when the visit came from a Google ad. |
| fbclid | string? | Meta click id, when the visit came from a Facebook/Instagram ad. |
| msclkid | string? | Microsoft Ads click id. |
| sessionId | string? | Tracked browser session that produced the lead. Expanded in the journey object. |
| visitorId | string? | Persistent visitor id across that browser’s sessions. |
| contactId | string? | The contact (person) this lead belongs to. |
| pipelineStage | string? | Stage within the configured sales pipeline. |
| lostReason | string? | Why the lead was marked lost, when status is lost. |
| notes | string[]? | Free-form notes added by your team. |
| lastActivityAt | number? | Timestamp of the most recent activity on the lead (Unix ms). |
The journey object
Present on GET /leads/{id} when the lead came through a
website running Persilio tracking. It is the session that produced the lead — first-touch
attribution, the page-by-page path, interaction events, device and location, and the
visitor's other visits. Absent for manual leads and calls without a tracked session.
| Field | Type | Description |
|---|---|---|
| sessionId | string | The tracked session id. |
| visitorId | string | Persistent visitor id. |
| startedAt | number | Session start (Unix ms). |
| lastSeenAt | number | Last activity in the session (Unix ms). |
| landingPage | string | First page of the session. |
| landingUrl | string? | Full landing URL including query string. |
| referrer | string? | Document referrer on the first page. |
| channel | string? | Resolved channel for this session. |
| sourceLabel | string? | Human-readable source, e.g. "Google Ads". |
| utmSource … utmContent | string? | The five UTM parameters captured on the first page. |
| gclid / fbclid / msclkid | string? | Ad click ids captured on the first page. |
| device | object | type (mobile | tablet | desktop), browser, browserVersion, os, osVersion. |
| geo | object | country, region, city — resolved at the network edge. Fields are omitted when unknown. |
| isReturning | boolean? | True when this visitor had prior sessions on your site. |
| visitNumber | number? | Which visit this was for the visitor (1 = first). |
| pageviews | object[] | Chronological pages viewed: path, title, timestamp, durationMs (time on page; absent for the final page). |
| events | object[] | Chronological interaction events: type, path, timestamp, plus elementText, formName, fieldName, scrollDepth, or value depending on type. |
| otherVisits | object[] | Up to 10 other sessions by the same visitor: startedAt, landingPage, channel, referrer. |
Contact & activity objects
Also on GET /leads/{id}: contact is
the person this opportunity belongs to (leads are opportunities; contacts are people —
one contact can have many leads). activities is the newest-first
audit trail of what your team did with the lead.
contact
| Field | Type | Description |
|---|---|---|
| id | string | Contact id. |
| name | string | Full display name. |
| firstName / lastName | string? | Split name parts, when known. |
| string? | Email address. | |
| phone | string? | Phone number. |
| company | string? | Company, when known. |
activities[]
| Field | Type | Description |
|---|---|---|
| type | string | Activity kind, e.g. lead_created, status_change, note, email_sent, call. |
| content | string | Human-readable description of what happened. |
| timestamp | number | When it happened (Unix ms). |
| authorName | string? | Team member (or system) that performed the action. |
Attribution values
channel
Persilio's resolved acquisition channel, computed first-touch from the visitor's original session — not the last click before the form submit. Raw signals (referrer, UTMs, click IDs) are always included alongside it so you can apply your own model.
| organic | Unpaid search — Google, Bing, DuckDuckGo, etc. |
| ppc | Paid ads — a gclid/msclkid was present or utm_medium indicated cpc/paid. |
| social | Social networks — Facebook, Instagram, LinkedIn, TikTok, YouTube, etc. |
| referral | Another website linked to you. |
| direct | No referrer — typed URL, bookmark, or an app that strips referrers. |
| ai | AI assistants — ChatGPT, Perplexity, Claude, Copilot, Gemini. |
status
Lead statuses, in rough pipeline order:
newcontactedcontacted_calledcontacted_emailedcontacted_voicemailestimatedscheduledclosedlostdeclinedspam source
How the lead entered Persilio: website_form (submitted a tracked
form), organic_call / ppc_call (call
tracking), or manual (entered by your team).
Journey event types
| form_start | Visitor focused a form field (fieldName says which). |
| form_submit | Visitor submitted a form (formName). |
| tel_click | Tapped/clicked a phone number link (elementText). |
| click | Clicked a tracked element (elementId / elementText). |
| outbound_click | Clicked a link leaving your site (value = destination). |
| scroll_depth | Scrolled past a depth threshold (scrollDepth = percent). |
| rage_click | Rapid repeated clicks — a frustration signal. |
| copy | Copied text on the page. |
| video_play | Started a video. |
| error | A JavaScript error occurred on the page. |
Website CMS
Organizations with a Persilio-managed website can read and edit its pages through the
API — the same content the dashboard's website editor manages. Every CMS route requires
the cms scope (or an unscoped key). Edits always land in a
page's draft; nothing changes on the live site until a publish.
The block model
A page is an ordered list of blocks:
{ id, kind, props, hidden? }. The block kinds a
site supports — and the exact fields each kind's props accepts
— are defined per site and returned as blockDefs by
GET /cms/sites and on every page response. Writes are
validated against those definitions, and validation failures come back as readable
400 messages naming the block and field.
# A page's draft, abridged
{
"id": "…", "slug": "index", "path": "/", "title": "Home",
"blocks": [
{ "id": "1f3…", "kind": "hero",
"props": { "heading": "Injured? We can help.", "sub": "…" } },
{ "id": "9ab…", "kind": "faq",
"props": { "items": [ { "q": "…", "a": "…" } ] } }
],
"blockDefs": [ …the schema each kind accepts… ]
} Editing
For a single change, prefer the block-level operation — it can't accidentally drop content:
# Change one block's heading
curl -s -X POST "https://api.persilio.com/api/v1/cms/pages/{pageId}/blocks" \
-H "Authorization: Bearer psk_live_…" -H "Content-Type: application/json" \
-d '{ "action": "edit", "blockId": "1f3…",
"props": { "heading": "New headline" } }'
# action: "add" → { kind, props?, afterBlockId? }
# action: "remove" → { blockId } POST /cms/pages/{id} replaces the draft wholesale (title,
SEO, and/or the full blocks array — use it for reorders). If
the submitted array would drop existing block ids, the call is rejected unless
allowRemovals: true — so partial payloads can never silently
delete content.
Publishing
POST /cms/publish promotes every page with draft changes
(narrow with pageIds), commits the site's content to its
GitHub repository, and triggers a redeploy. It returns 202
with a publish id; poll GET /cms/publishes/{id} for
progress. Only one publish runs at a time per site — a second request returns
400 until the first resolves.
| Publish status | Meaning |
|---|---|
| queued | Accepted; drafts promoted, commit pending. |
| committing | Writing content.json to the site's GitHub repo. |
| committed | Commit landed (commitSha/commitUrl set); waiting for the build. |
| building | The site's deploy workflow is running. |
| deployed | Live. Terminal. |
| failed | Something broke — error says what. Terminal; fix and publish again. |
Every publish is attributed (dashboard user, API key name, or MCP) in the page's version history, and the git history of the site repository carries a full diff of every publish.
Errors
Errors return a JSON body with the shape
{ "error": { "message", "status" } }.
| Status | Meaning |
|---|---|
| 400 | Malformed request — e.g. an invalid lead id. |
| 401 | Missing, malformed, or revoked API key. |
| 404 | Resource not found (or belongs to a different organization). |
| 429 | Rate limit exceeded — honor the Retry-After header. |
Analytics responses additionally include a truncated flag when a
very large range hit the per-request scan cap — narrow the
since/until window if you see it set.
MCP server for AI agents
Persilio ships a hosted Model Context Protocol server, so AI agents and assistants can work with your Persilio data directly, with no glue code: read leads and analytics and, where a key allows it, edit your website and training courses. It speaks Streamable HTTP and accepts two forms of authentication: the same API keys as the REST API (as a bearer header), or the built-in OAuth flow for clients that can't send headers — which is how claude.ai custom connectors work.
https://api.persilio.com/mcp Available tools
Each tool wraps the corresponding REST endpoint, so the parameters and response shapes documented above apply directly.
| Tool | Description |
|---|---|
| get_org_info | Basic info about the connected organization. |
| get_setup_status | What the organization has set up (payments, courses, attendance, email, phone, Google) and what this key can reach — so an assistant knows what it can really do. |
| get_dashboard_summary | Leads this month, active jobs, invoiced amount, upcoming bookings. |
| list_leads | List/filter leads with full attribution; same parameters as GET /leads. |
| get_lead | Full detail for one lead including the visitor journey. |
| get_lead_analytics | Lead totals + breakdowns for a time range; same shape as GET /analytics/leads. |
| get_traffic_analytics | Sessions, pageviews, channels, top pages; same shape as GET /analytics/traffic. |
| cms_list_sites | The org’s CMS-managed websites + each site’s block vocabulary (cms scope). |
| cms_list_pages | Pages of the website with draft/published state. |
| cms_get_page | One page’s draft blocks plus the exact field schema each block accepts. |
| cms_create_page | Create a draft page, optionally from a template. |
| cms_edit_block | Change one block’s props by id — the reliable single-edit path. |
| cms_add_block | Add a block of a supported kind at a position. |
| cms_remove_block | Remove a block by id. |
| cms_update_page | Replace a page’s draft (title, SEO, full block array) with block-loss protection. |
| cms_publish_site | Promote drafts, commit to GitHub, redeploy the site. |
| cms_get_publish_status | Watch a publish: committed → building → deployed / failed. |
| courses_list | Training courses with module and lesson counts, published vs draft (courses scope). |
| courses_get | A course’s outline: modules, sub-modules and lessons with their status. |
| courses_search | Find lessons by what they say — every title and body, with snippets around each match. |
| courses_get_lesson | One lesson in full: ordered blocks (text, callout, image, video, quiz) plus a plain-text reading. |
| courses_replace_text | Find-and-replace inside a lesson. Matches the words a reader sees and rewrites only what changes, so formatting survives. |
| courses_update_block | Replace one block by id. |
| courses_insert_blocks | Add blocks at the start, end, an index, or after a block. |
| courses_remove_block | Remove one block by id. |
| courses_create_lesson | Create a lesson from blocks (a draft until published). |
| courses_update_lesson | Title, description, draft/published, signature requirement, thumbnail, attachments, or move to another module. |
| courses_delete_lesson | Delete a lesson (refused once it has signed acknowledgements). |
| courses_create / courses_update / courses_delete | Create a course, change its details or publish state, or delete it. |
| courses_create_module / courses_update_module / courses_delete_module | Manage modules and sub-modules (one level of nesting). |
| courses_reorder_modules / courses_reorder_lessons | Set the order of sibling modules, or of the lessons in a module. |
| courses_list_media | The Documents library, newest first: images, videos and files with the id a block references. |
| courses_request_upload | Get a link the person opens to drop files from their computer into the library — how a photo attached to a chat gets into a lesson. |
| courses_get_uploads | What arrived through an upload link, with the id each file is referenced by. |
| courses_import_media | Fetch a file from a public URL into the library (50 MB). |
| courses_progress | Who has taken a course: lessons completed, time spent, signed acknowledgements. |
| search_contacts | Search the contact book by name, email, company, or phone digits (contacts scope). |
| get_contact | One contact plus every quote/invoice linked to them. |
| create_contact | Create a contact; dedupes on email/phone. |
| update_contact | Patch contact fields, including lifecycle. |
| list_quotes | Quotes and invoices with totals, status, client, dashboard URL (billing scope). |
| get_quote | One quote in full: line items with term settings, written terms, e-signature URL, and the agreement text as the PDF renders it. |
| preview_agreement | Render the Service Agreement text for line items + provisions before saving. |
| create_quote | Create a draft quote or invoice (amounts in cents); quotes get an e-signature link. |
| update_quote | Edit an unpaid quote; editing an unsigned quote rotates its signing link. |
| set_quote_status | draft | created | sent | signed | canceled | voided. |
| get_quote_sign_link | The hosted e-signature URL (created on demand). The server never emails clients. |
| list_contract_term_templates | Named written-terms templates and the org default terms. |
| save_contract_term_template | Create or update a terms template (adminOnly locks it to sysadmins). |
| delete_contract_term_template | Delete a terms template. |
| set_default_contract_terms | Replace the org’s default written terms. |
| list_quote_templates | Saved line-item bundles from the quote editor. |
| save_quote_template | Save a new quote template. |
| delete_quote_template | Move a quote template to the trash. |
| seo_list_clients | Clients enrolled in the SEO program with this month’s progress (Savo Group staff keys, seo scope). |
| seo_get_month | A client’s Monthly Work board for a month: tasks, status, assignee, proof links. |
| seo_staff | Team members who can be assigned SEO tasks. |
| seo_create_task | Add a task (optionally already done) to a client’s month. |
| seo_update_task | Change status, notes, proof link, assignee, due date, priority. |
| seo_comment_on_task | Comment on a task. |
| seo_activity | Who changed what on the SEO program, and when. |
Connect from claude.ai or Claude Desktop (custom connector)
Claude's custom connectors authenticate with OAuth — the Persilio MCP server implements the full flow, so no configuration files are needed:
- In claude.ai (or Claude Desktop), open Settings → Connectors → Add custom connector.
- Name it Persilio and enter the server URL:
https://api.persilio.com/mcp - Leave the OAuth Client ID and Secret fields under Advanced settings empty — Claude registers itself automatically.
- Click Add, then Connect. A Persilio authorization page opens.
- Paste an API key from Dashboard → Settings → API access and click Authorize. You're redirected back to Claude with the connector active.
The connector receives a token tied to the API key you pasted — revoking that key in your dashboard disconnects the connector immediately. It can do exactly what that key allows and nothing more: a key is limited to the areas you tick when you create it (and to the areas its holder can open in the dashboard), and it only ever reaches your organization. Claude lists the tools in two groups — read-only, and tools that change things — so you can let it read freely and still be asked before every edit.
Edit your training courses from Claude
To let Claude maintain your courses, create a key with only the Courses area ticked and connect it as above. Then talk to it the way you would to an editor:
- "Find every lesson that mentions our 60-day probation period and change it to 90 days."
- "Rewrite the Reporting an Injury on the Job lesson at a fifth-grade reading level. Show me before you save."
- "Add a three-question quiz to the end of Customer Communication Standards."
- "Move Vehicle Inspection Checklist into the Safety module and set it back to draft."
- "Here's a photo of the correct PPE setup — put it at the top of the Job Site Safety lesson with a caption."
Edits are live as soon as Claude makes them, and new lessons stay drafts until you publish them. A photo dropped into the chat can't be read by a connector, so Claude hands you an upload link instead: open it, drop the file, tell Claude it's there, and it places the image in the lesson. The file is kept under Documents → Course Media.
When new tools are added to the connector: Claude keeps a copy of a
connector's tool list. If a tool described here is missing in your chat, open
Settings → Connectors, click Persilio, and choose Refresh tools
list. If that doesn't pick it up, add the connector again with
https://api.persilio.com/mcp?v=2 as the URL.
Connect from Claude Code
claude mcp add --transport http persilio https://api.persilio.com/mcp \
--header "Authorization: Bearer psk_live_…" Connect from Cursor / other MCP clients
Clients that support custom headers can send the API key directly. Clients that only support OAuth (like claude.ai above) work through the built-in flow automatically. The generic header-based configuration shape:
{
"mcpServers": {
"persilio": {
"url": "https://api.persilio.com/mcp",
"headers": {
"Authorization": "Bearer psk_live_…"
}
}
}
} Once connected, ask things like "how many organic leads did we get this month?", "show me the full journey behind our newest lead", or "which landing pages are converting?" — the agent pulls live data through the tools above.
Ready to build?
Create an API key in your dashboard and make your first request in under a minute.
Open dashboard settings