API Reference

Modality REST API

The Modality API follows REST conventions. All first-party endpoints are versioned under /api/v1/ and return JSON. The REST surface is authenticated with your Auth.js session cookie, it is designed for the Modality web app and mobile app. Programmatic mk_live_ API keys authenticate a separate surface: the Model Context Protocol (MCP) server at POST /api/mcp.

Every request is scoped to the workspace of the authenticated caller. The workspace is derived from the session (or, for MCP, from the API key), never from a workspaceId you pass in. Any client-supplied workspaceId in a query string or body is ignored and overwritten. This is the tenant-isolation guarantee that prevents cross-workspace access (IDOR).

Authentication

There are two distinct auth boundaries. Choose the one that matches your caller, they are not interchangeable.

Two auth lanes side by side, Lane 1 'Session cookie → /api/v1/* (getAuthContext)' for the web/mobile app, and Lane 2 'Authorization: Bearer mk_live_… → /api/mcp (getMcpAuthContext)' for programmatic access. Make it explicit that a Bearer key does NOT work on /api/v1 and a session cookie does NOT work on /api/mcp.
Two auth lanes side by side, Lane 1 'Session cookie → /api/v1/* (getAuthContext)' for the web/mobile app, and Lane 2 'Authorization: Bearer mk_live_… → /api/mcp (getMcpAuthContext)' for programmatic access. Make it explicit that a Bearer key does NOT work on /api/v1 and a session cookie does NOT work on /api/mcp.

1. Session cookies, the REST surface (/api/v1/*)

Modality uses Auth.js (NextAuth v5) with a JWT session strategy. When you sign in, the browser (or the mobile app) receives a session cookie that is sent automatically with every request. Server code resolves it with getAuthContext(), which returns the caller's workspaceId, userId, and workspace role. There is currently no Bearer-token authentication on the /api/v1 surface, sending an Authorization header there does nothing, and an unauthenticated request returns 401 { "error": "Not authenticated" }.

Do not try to call /api/v1/… from a server-to-server script with a Authorization: Bearer header, those routes never read it, so the call is treated as unauthenticated and returns 401. For programmatic access, use the MCP server (below).

2. API keys, the MCP surface (/api/mcp)

For machine callers (agents, scripts, integrations) Modality issues workspace API keys in the format mk_live_<random>. A key authenticates only the MCP server at POST /api/mcp, where getMcpAuthContext() resolves the key to its owning workspace. The key is hashed (SHA-256) at rest; the plaintext is shown to you exactly once at creation.

Calling the MCP server
curl -X POST https://modalitystudio.com/api/mcp \
  -H "Authorization: Bearer mk_live_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }'

The MCP server speaks JSON-RPC 2.0 (Streamable-HTTP). Use tools/list to discover tools and tools/call to invoke one. See the MCP / AI access section for details.

How to create an API key:
  1. Open Settings → API (requires the Business plan, the tab is gated behind the api_access feature).
  2. Only workspace Owners/Admins can issue keys (workspace:manage permission).
  3. Give the key a name and select its scopes (least-privilege, see below). Keys are scope-limited, not full-access.
  4. Copy the mk_live_… secret immediately, it is displayed once and cannot be retrieved again.
Settings → API keys tab (src/app/(dashboard)/settings/api-keys-tab.tsx) showing the scope checkboxes (read vs write vs sensitive send/refund), the Business-plan upgrade gate for non-Business workspaces, and the one-time reveal of a freshly created mk_live_ secret.
Settings → API keys tab (src/app/(dashboard)/settings/api-keys-tab.tsx) showing the scope checkboxes (read vs write vs sensitive send/refund), the Business-plan upgrade gate for non-Business workspaces, and the one-time reveal of a freshly created mk_live_ secret.

Authorization & Permissions

Being authenticated is not enough, every mutating route also checks a role permission via requirePermission(role, '<resource>:<action>'). Permissions follow a resource:action shape, e.g. contacts:read, contacts:write, events:read, events:write, invoices:write, integrations:write, analytics:read. Some routes (e.g. creating a custom object type) require Owner/Admin via requireAdmin(role).

  • 401 { "error": "Not authenticated" }, no valid session.
  • 403 { "error": "Forbidden: requires contacts:write permission" }, session is valid but the role lacks the permission.
  • 403 with a plan-limit payload, the workspace hit a plan cap (see Errors). Enforced by enforcePlanLimit().

Response Format

Envelope Shapes
// Single item
{ "data": { "id": "...", ... } }

// List (with a total count)
{ "data": [ ... ], "total": 42 }

// Create, returns 201 with the created resource
{ "data": { "id": "...", ... } }
Envelope caveats: most lists return { data, total }, but not all are uniform. GET /api/v1/objects returns { data } with no total, and GET /api/v1/events sets total to the number of items returned (the page length), not a separate full count. Treat total as authoritative only where a resource is paginated.

Errors

Errors use standard HTTP status codes and one of the following JSON bodies:

Error Bodies
// 400, Zod validation failure (details is the list of issues)
{ "error": "Invalid input", "details": [ { "path": ["email"], "message": "Required" } ] }

// 401, not signed in
{ "error": "Not authenticated" }

// 403, role lacks the permission
{ "error": "Forbidden: requires events:write permission" }

// 403, plan cap reached (shape varies by limit)
{ "error": "You've reached your plan's contact limit", "limit": 500, "current": 500, "upgradeUrl": "..." }

// 404, not found / not owned by this workspace
{ "error": "Event not found" }

// 409, conflict (e.g. deleting a ticket type that has sold tickets)
{ "error": "This ticket type has sold tickets, so it can't be deleted..." }

// 429, rate limited
{ "error": "Too many requests" }

// 500, unexpected server error
{ "error": "Failed to fetch contacts" }
Validation errors always carry details (the raw Zod issues array) alongside error. Some routes use parsed.error.flatten() instead of the issues array, so details may be either a flat list or a { fieldErrors, formErrors } object, read it, don't assume its exact shape.

Contacts (People)

Manage CRM contacts with tags, lists, custom statuses, custom fields, and activity timelines. Contacts are the same records surfaced as "People" in the app. Permissions: contacts:read / contacts:write.

GET/api/v1/contactsSession Auth

List contacts in the workspace. All filters are optional; the workspace is taken from the session (never a query param).

Query Parameters
searchstringMatch by name or email
tagIdstring (repeatable)Include contacts with this tag; repeat for multiple
excludeTagIdstring (repeatable)Exclude contacts with this tag
listIdstring (repeatable)Include contacts in this list
excludeListIdstring (repeatable)Exclude contacts in this list
statusstringFilter by lifecycle status
excludeStatusstringExclude a lifecycle status
customStatusstringFilter by a workspace-defined custom status
sortBystringSort field (default: createdAt)
sortDirectionasc|descSort direction (default: desc)
pagenumberPage number (default: 1)
pageSizenumberItems per page (default: 50)
Response
{
  "data": [
    {
      "id": "clx...",
      "email": "user@example.com",
      "firstName": "Jane",
      "lastName": "Doe",
      "phone": null,
      "source": "form",
      "status": "ACTIVE",
      "tags": [{ "id": "...", "name": "VIP", "slug": "vip" }],
      "createdAt": "2026-01-15T10:30:00Z"
    }
  ],
  "total": 1842
}
POST/api/v1/contactsSession Auth

Create a contact, or upsert by email. If a contact with the same email already exists, the new fields are merged onto the living record instead of erroring. Returns 201.

Request Body (JSON)
emailstringrequiredContact email address (identity key)
firstNamestringFirst name
lastNamestringLast name
phonestringPhone number (normalized to E.164 on write)
sourcestringLead source identifier
tagIdsstring[]Tag IDs to attach
Response
{ "data": { "id": "clx...", "email": "user@example.com", ... } }
GET/api/v1/contacts/:idSession Auth

Get a single contact with tags, lists, custom statuses, and custom field values.

PATCH/api/v1/contacts/:idSession Auth

Update a contact's fields, tags, lists, and status.

DELETE/api/v1/contacts/:idSession Auth

Delete a contact from the workspace.

GET/api/v1/contacts/:id/timelineSession Auth

Get the contact's activity timeline (form submissions, orders, campaign engagement, notes).

POST/api/v1/contacts/importSession Auth

Bulk upsert contacts. Each row is created or merged by email. Subject to the workspace's plan contact limit.

Request Body (JSON)
contactsobject[]requiredArray of mapped contact objects
Response
{
  "results": [ { "email": "a@x.com", "status": "created" }, ... ],
  "summary": { "created": 150, "updated": 23, "errors": 2 }
}
The import response is { results, summary }, the per-row outcomes plus a summary of { created, updated, errors }. There is no data wrapper and no imported field. Tags are not applied by this endpoint; attach tags per-contact or via a follow-up call.

Related CRM helpers:

GET /api/v1/contacts/fieldsList the custom-field schema available on contacts
POST /api/v1/contacts/import/suggest-mappingAI-suggested column → field mapping for a CSV header row

Lists, Segments, Tags & Fields

Segmentation lives in Modality (ESPs are only delivery destinations). Tags are simple labels; lists are static memberships; segments are saved rule sets that resolve to a live audience.

GET/POST /api/v1/tagsList / create tags
GET/PATCH/DELETE /api/v1/tags/:idRead, rename, or delete a tag
GET/POST /api/v1/listsList / create static lists
GET/PATCH/DELETE /api/v1/lists/:idManage a list
GET/POST /api/v1/crm-listsRicher CRM lists (with saved views + entries)
GET/POST /api/v1/crm-lists/:id/entriesAdd or list entries in a CRM list
GET/POST /api/v1/crm-lists/:id/viewsSaved views for a CRM list
GET/POST /api/v1/segmentsList / create rule-based segments
GET/PATCH/DELETE /api/v1/segments/:idManage a segment
GET /api/v1/segments/countResolve a segment rule to a live audience count
GET/POST /api/v1/custom-fieldsDefine custom contact fields
GET/PATCH/DELETE /api/v1/custom-fields/:idManage a custom field
GET/POST /api/v1/contact-statusesWorkspace-defined lifecycle statuses
GET/POST /api/v1/merge-tagsMerge tags usable in campaigns/templates
GET/POST /api/v1/email-templatesReusable email templates
GET/POST /api/v1/tasksCRM tasks; /tasks/:id to manage
GET/POST /api/v1/notesNotes; /notes/:id to manage

Events

Create and manage events, ticket types, RSVPs, orders, promo codes, and door operations. Permissions: events:read / events:write.

GET/api/v1/eventsSession Auth

List events in the workspace.

Response
{
  "data": [
    {
      "id": "clx...",
      "title": "Summer Festival",
      "slug": "summer-festival",
      "status": "PUBLISHED",
      "visibility": "PUBLIC",
      "startsAt": "2026-07-15T18:00:00Z",
      "endsAt": "2026-07-15T23:00:00Z",
      "venue": "Outdoor Arena",
      "ticketTypes": [
        { "id": "...", "name": "GA", "price": 4500, "capacity": 2000 }
      ]
    }
  ],
  "total": 5  // count of items returned, not a paginated total
}
POST/api/v1/eventsSession Auth

Create an event. A slug is derived from the title if you don't supply one. Subject to the plan event limit.

Request Body (JSON)
titlestringrequiredEvent title
slugstringURL slug (auto-derived from title if omitted)
descriptionstringEvent description
startsAtISO 8601Start date/time
endsAtISO 8601End date/time
timezonestringIANA timezone
venuestringVenue name
addressstringVenue address
coverImagestringCover image URL
statusenumDRAFT | PUBLISHED | COMPLETED | CANCELLED
visibilityenumPUBLIC | SEARCHABLE | UNLISTED | PRIVATE
capacitynumberOverall capacity
requireApprovalbooleanGate RSVP/checkout behind host approval
lat / lngnumberCoordinates (used for mobile Discovery)
Response
{ "data": { "id": "clx...", "slug": "summer-festival", ... } }
GET/api/v1/events/:idSession Auth

Get a single event with its ticket types and settings.

PATCH/api/v1/events/:idSession Auth

Update event fields (partial). Same field set as create, minus workspaceId.

DELETE/api/v1/events/:idSession Auth

Delete an event.

Ticket types

POST/api/v1/events/:id/ticket-typesSession Auth

Add a ticket type. Prices are integers in cents. Returns 404 if the event isn't in your workspace.

Request Body (JSON)
namestringrequiredTier name (e.g. GA, VIP)
pricenumberrequiredPrice in cents (4500 = $45.00)
capacitynumberMax tickets for this tier
salesStartISO 8601When this tier goes on sale
salesEndISO 8601When sales close for this tier
descriptionstring (≤200)Short blurb shown under the tier name
occurrenceIdsstring[]Showings this tier sells at (empty = all showings)
requiredbooleanRequired base tier, cart keeps its qty ≥ total add-ons
PATCH/api/v1/events/:id/ticket-typesSession Auth

Update a ticket type (pass ticketTypeId in the body). Adds a status field: ACTIVE | PAUSED | SOLD_OUT | CLOSED.

Request Body (JSON)
ticketTypeIdstringrequiredThe tier to update
statusenumACTIVE | PAUSED | SOLD_OUT | CLOSED
variousAny create field (name, price, capacity, salesStart/End, etc.)
DELETE/api/v1/events/:id/ticket-typesSession Auth

Delete a ticket type (ticketTypeId in body). Returns 409 if it has sold tickets, set status to CLOSED instead to remove it from sale without breaking order history.

POST/api/v1/events/:id/ticket-types/reorderSession Auth

Reorder the tiers as they appear on the public page (drag-reorder).

Checkout

POST/api/v1/events/:id/checkoutPublic

Create a Stripe checkout for a ticket purchase. Public, no session required; rate-limited by client IP. If the event has requireApproval enabled, checkout returns 403 until the RSVP is approved.

Request Body (JSON)
emailstringrequiredBuyer email
firstNamestringBuyer first name
lastNamestringBuyer last name
itemsarrayrequired[ { ticketTypeId, quantity } ]
promoCodestringOptional promo code
Response
{
  "data": {
    "checkoutUrl": "https://checkout.stripe.com/...",   // hosted redirect
    "clientSecret": "cs_...",                             // when using Embedded Checkout
    "orderId": "clx..."
  }
}
Ticket payments run through Stripe Connect direct charges on the organizer's connected account. Depending on the surface, Modality uses Stripe's hosted Checkout (a redirect URL) or Embedded Checkout (an in-modal flow via a client secret, used by the event embed widget). Order fulfillment happens asynchronously when the Connect webhook fires (see Webhooks), and a reconciler cron backstops any missed events.

RSVPs, orders & door operations

A large surface of event sub-resources ships today. The canonical "attending" predicate for an RSVP is approved: true and declinedAt: null.

GET/POST /api/v1/events/:id/rsvpsList RSVPs / create one; :id/rsvps/:rsvpId to manage
POST /api/v1/events/:id/rsvpPublic RSVP submission (approval-gated when enabled)
GET /api/v1/rsvps/pendingRSVPs awaiting host approval across events
GET/POST /api/v1/events/:id/ordersOrders for the event
POST /api/v1/events/:id/orders/:orderId/refundRefund an order (Connect)
GET/POST /api/v1/events/:id/promo-codesManage promo codes
POST /api/v1/events/:id/promo-codes/validatePublic: validate a promo code
GET/POST /api/v1/events/:id/waitlistWaitlist; :id/waitlist/manage to admit
GET /api/v1/events/:id/occurrencesRecurring-showing occurrences
GET/POST /api/v1/events/:id/seatsSeating map; /seats/initialize to build it
POST /api/v1/events/:id/duplicateDuplicate an event
POST /api/v1/events/:id/inviteSend invitations
GET /api/v1/events/:id/qrEvent QR code
GET /api/v1/events/:id/checkin/manifestDoor check-in manifest
POST /api/v1/events/:id/send-emailEmail attendees
POST /api/v1/events/:id/sms-blastSMS blast to opted-in attendees
GET /api/v1/events/by-slug?slug=…Resolve an event by slug

Point of sale (door)

GET /api/v1/events/:id/pos/configPOS configuration for the event
POST /api/v1/events/:id/pos/payment-intentCreate a PaymentIntent (Tap to Pay / reader)
POST /api/v1/events/:id/pos/card-payment-intentManual card key-in PaymentIntent
POST /api/v1/events/:id/pos/saleFinalize a door sale into an order
POST /api/v1/events/:id/pos/receiptIssue a receipt

Tickets & Orders

Read issued tickets and orders, look up a ticket for check-in, and generate wallet passes.

GET /api/v1/ticketsList issued tickets
GET /api/v1/tickets/lookupLook up a ticket (e.g. by code) for check-in
GET /api/v1/tickets/:id/walletApple Wallet pass (.pkpass)
GET /api/v1/tickets/:id/google-walletGoogle Wallet pass link
GET /api/v1/ordersList orders across events
GET/POST /api/v1/reservationsSpace/time reservations
POST /api/v1/checkin/redeemRedeem a ticket (idempotent on clientScanId)
POST /api/v1/checkin/rsvpCheck in an approved RSVP guest
POST /api/v1/checkin/undoUndo a check-in
GET /api/v1/checkin/statsLive door counts
Door staff can operate without a login via Staff Links, /api/v1/staff/:token/* (manifest, redeem, rsvp-decision, stats, undo). These are token-scoped to one event and its check-in tools, so you can hand a scanner to a volunteer without adding them to the workspace.

Forms & Submissions

Build dynamic forms (multi-page, conditional logic, theming, payment) and collect submissions. Submitting a form upserts a Person and stores the submission.

GET/api/v1/formsSession Auth

List forms in the workspace.

POST/api/v1/formsSession Auth

Create a form.

Request Body (JSON)
namestringrequiredForm name
fieldsFormField[]Field definitions
settingsobjectTheme, payment, notifications, etc.
statusstringDRAFT or PUBLISHED
GET/api/v1/forms/:idSession Auth

Get a single form.

PATCH/api/v1/forms/:idSession Auth

Update a form.

DELETE/api/v1/forms/:idSession Auth

Delete a form.

POST/api/v1/submissions/:slugPublic

Submit a form. Public. Upserts a Person by email and stores the submission; may return a payment URL for priced forms.

Request Body (JSON)
(dynamic)objectrequiredField values keyed by field ID
Response
{
  "data": {
    "id": "clx...",
    "formId": "clx...",
    "contactId": "clx...",
    "paymentUrl": "https://..."   // priced forms only
  }
}

Form sub-resources:

GET /api/v1/forms/:id/submissionsList a form’s submissions
GET/POST /api/v1/forms/:id/variantsProduct-set / scheduler variants
GET/PATCH/DELETE /api/v1/forms/:id/variants/:variantIdManage a variant
POST /api/v1/forms/:id/variants/:variantId/duplicateDuplicate a variant
GET /api/v1/forms/:id/orders-reportOrders/payments report for a priced form
GET /api/v1/forms/resolveResolve a public form (by slug/variant)
POST /api/v1/forms/verifyEmail-confirmation verification
GET /api/v1/submissions/detail/:idFull submission detail
POST /api/v1/submissions/:slug/uploadFile upload field handler
POST /api/v1/submissions/send-payment-link/:idSend a payment link for a submission

Campaigns

Create and send email campaigns. Delivery goes through an ESP connector, or (where enabled) native sending. Permissions: campaigns:read / campaigns:write.

GET/api/v1/campaignsSession Auth

List campaigns.

POST/api/v1/campaignsSession Auth

Create a campaign.

Request Body (JSON)
namestringrequiredCampaign name
subjectstringrequiredSubject line
htmlContentstringHTML body
textContentstringPlain-text fallback
audienceTypestringALL, TAG, LIST, or SEGMENT
audienceValuestringTag slug / list ID / segment ID
connectorIdstringESP connector for delivery
GET/api/v1/campaigns/:idSession Auth

Get a campaign.

PATCH/api/v1/campaigns/:idSession Auth

Update a campaign.

DELETE/api/v1/campaigns/:idSession Auth

Delete a campaign.

POST/api/v1/campaigns/:id/sendSession Auth

Send now or schedule (pass scheduledFor to defer).

Request Body (JSON)
scheduledForISO 8601Schedule time (omit for immediate send)

Campaign tooling:

GET /api/v1/campaigns/:id/statsDelivery/open/click analytics
POST /api/v1/campaigns/:id/testSend a test email
POST /api/v1/campaigns/:id/preflightPre-send checks (links, merge tags, audience)
POST /api/v1/campaigns/:id/duplicateDuplicate a campaign
POST /api/v1/campaigns/renderRender HTML (per-section parity for the editor)
POST /api/v1/campaigns/generateAI-generate campaign content
POST /api/v1/campaigns/decomposeDecompose HTML into editable sections
GET /api/v1/campaigns/:id/template-contentFetch template content for the editor

Sequences

Multi-step drip sequences with enrollments. Permissions reuse the automations set: automations:read / automations:write. A cron worker advances enrollments.

GET/POST /api/v1/sequencesList / create sequences
GET/PATCH/DELETE /api/v1/sequences/:idManage a sequence
GET/POST /api/v1/sequences/:id/stepsList / add steps
GET/PATCH/DELETE /api/v1/sequences/:id/steps/:stepIdManage a step
POST /api/v1/sequences/:id/enrollEnroll a contact
GET /api/v1/sequences/:id/enrollmentsList enrollments
GET/PATCH/DELETE /api/v1/sequences/:id/enrollments/:enrollmentIdManage an enrollment

Automations

Trigger → action workflow rules. Permissions: automations:read / automations:write.

GET/api/v1/automationsSession Auth

List automations.

POST/api/v1/automationsSession Auth

Create an automation.

Request Body (JSON)
namestringrequiredAutomation name
triggerobjectrequired{ type, config }, trigger definition
actionsobject[]requiredArray of { type, config } steps
statusstringACTIVE or PAUSED (default: ACTIVE)
GET/api/v1/automations/:idSession Auth

Get an automation.

PATCH/api/v1/automations/:idSession Auth

Update an automation.

DELETE/api/v1/automations/:idSession Auth

Delete an automation.

GET/api/v1/automations/:id/logsSession Auth

Execution log for an automation.

Custom Objects

Define custom data structures (Deals, Sponsors, …) with typed attributes and records. Permissions: objects:read for reads; creating an object type requires Owner/Admin.

GET/api/v1/objectsSession Auth

List object types. NOTE: returns { data } with no total.

Response
{
  "data": [
    {
      "id": "clx...",
      "name": "Deals",
      "slug": "deals",
      "singularName": "Deal",
      "icon": "Briefcase",
      "attributes": [
        { "name": "Stage", "type": "select", "options": [...] },
        { "name": "Value", "type": "currency" }
      ]
    }
  ]
}
POST/api/v1/objectsSession Auth

Create an object type (Owner/Admin only).

Request Body (JSON)
namestringrequiredPlural name (e.g. Deals)
slugstringrequiredURL-safe identifier
singularNamestringSingular form (e.g. Deal)
iconstringLucide icon name
GET/api/v1/objects/:slugSession Auth

Get one object type.

PATCH/api/v1/objects/:slugSession Auth

Update an object type.

DELETE/api/v1/objects/:slugSession Auth

Delete an object type.

GET/api/v1/objects/:slug/recordsSession Auth

List records with sorting, filtering, and full-text search.

Query Parameters
pagenumberPage number
pageSizenumberItems per page
sortBystringAttribute to sort by
sortDirasc|descSort direction
searchstringFull-text search across attributes
POST/api/v1/objects/:slug/recordsSession Auth

Create a record.

Request Body (JSON)
titlestringrequiredRecord title
attributesobjectKey-value pairs for custom attributes

Object sub-resources:

GET/POST /api/v1/objects/:slug/attributesManage the object’s attribute schema
PATCH/DELETE /api/v1/objects/:slug/attributes/:idEdit / remove an attribute
GET/PATCH/DELETE /api/v1/objects/:slug/records/:idManage a single record
GET /api/v1/objects/:slug/records/:id/timelineRecord activity timeline
POST /api/v1/objects/:slug/importBulk-import records
POST /api/v1/objects/linkLink records across object types

Invoices

Native invoicing with numbering, sending, payments, and stats. Permissions: invoices:read / invoices:write. Lists return { data, total }.

GET/POST /api/v1/invoicesList / create invoices
GET/PATCH /api/v1/invoices/:idRead / update an invoice
POST /api/v1/invoices/:id/sendEmail the invoice + pay-link to the customer
POST /api/v1/invoices/:id/voidVoid an invoice
POST /api/v1/invoices/:id/cancelCancel an invoice
GET/POST /api/v1/invoices/:id/paymentsRecord / list payments
GET /api/v1/invoices/next-numberNext sequential invoice number
GET/PATCH /api/v1/invoices/settingsInvoice defaults (numbering, terms, branding)
GET /api/v1/invoices/statsOutstanding / paid / overdue totals
POST /api/v1/payments/invoice/:token/checkoutPublic: customer pays an invoice

Integrations (ESP connectors)

Connect ESPs (Brevo, Mailchimp, Klaviyo) as delivery destinations. Connectors share a common interface and support OAuth. Permissions: integrations:read / integrations:write.

GET/POST /api/v1/integrationsList connected integrations / connect one
GET/PATCH/DELETE /api/v1/integrations/:providerManage a connector
POST /api/v1/integrations/:provider/syncTrigger a sync
GET /api/v1/integrations/:provider/oauthBegin the OAuth flow
GET /api/v1/integrations/:provider/oauth/callbackOAuth redirect handler
GET /api/v1/integrations/:provider/listsRemote lists/audiences
GET /api/v1/integrations/:provider/campaignsRemote campaigns
GET /api/v1/integrations/:provider/automationsRemote automations
GET /api/v1/integrations/:provider/templatesRemote templates
GET /api/v1/integrations/:provider/merge-tagsRemote merge tags

Analytics

Workspace stats, activity feed, and aggregates. Permission: analytics:read. Responses are { data }.

GET /api/v1/analytics/statsHeadline KPIs (contacts, events, revenue, …)
GET /api/v1/analytics/activityRecent activity feed
GET /api/v1/analytics/aggregateAggregate metrics over a range
GET /api/v1/analytics/growthGrowth over time
GET /api/v1/analytics/performanceCampaign/event performance
GET /api/v1/analytics/sourcesLead-source breakdown

Domains

Custom sending domains for branded email. Gated behind the custom_domains feature (Business plan).

GET/POST /api/v1/domainsList / add a custom domain (returns DNS records to set)
GET/PATCH/DELETE /api/v1/domains/:idCheck verification / update / remove

API Keys Management

Issue and revoke the mk_live_ keys used by the MCP server. Business-gated (api_access) and requires workspace:manage. These endpoints are called by the Settings → API UI; the plaintext secret is returned once on create.

GET/api/v1/api-keysSession Auth

List this workspace's keys (metadata only, never the secret).

Response
{ "data": [ { "id": "...", "name": "Zapier", "scopes": ["crm:read"], "lastUsedAt": "...", "expiresAt": null } ] }
POST/api/v1/api-keysSession Auth

Issue a key. Invalid scopes are dropped; if none are valid the request 400s. Returns 201 with the one-time secret.

Request Body (JSON)
namestring (1–80)requiredHuman label for the key
scopesstring[] (1–20)requiredScopes from the allowlist (see MCP)
expiresAtISO 8601 | nullOptional expiry
Response
{ "data": { "id": "...", "name": "Zapier", "scopes": ["crm:read"], "secret": "mk_live_..." } }
DELETE/api/v1/api-keys/:idSession Auth

Revoke a key immediately (sets revokedAt; the key stops authenticating on the next request).

MCP / AI Access

POST /api/mcp is a Model Context Protocol server (JSON-RPC 2.0, Streamable-HTTP), the purpose of the mk_live_ keys. It lets AI agents and scripts operate on your workspace through a curated set of tools, with the workspace bound to the key (never to request input) and every call scope-checked.

JSON-RPC methods
  • initialize, handshake (protocol version, server info).
  • tools/list, discover the available tools.
  • tools/call, invoke a tool with { name, arguments }.
  • ping, liveness.

Scopes a key can carry (grant the least you need):

ScopeGrants
crm:read / crm:writeContacts, records, tags, lists, notes, tasks
events:read / events:writeEvents, ticket types, orders, RSVPs, promo codes
commerce:read / commerce:writeRead / create-edit invoices
commerce:sendSensitive, email an invoice pay-link
commerce:refundSensitive, refund an order (moves money)
campaigns:read / campaigns:writeCampaigns, automations, sequences, social posts
campaigns:sendSensitive, send a campaign to the audience
esign:readRead document templates & envelopes
esign:sendSensitive, send a document for signature
analytics:readRead analytics & stats
A tool call that needs a scope the key doesn't carry is returned as a tool result with isError: true (message missing_scope:<scope>) so the calling model can see and react to it, the JSON-RPC request itself still succeeds. Per-key rate limiting applies. An invalid/revoked/expired key fails the transport with 401.

Webhooks

Modality delivers outbound webhooks to your endpoints when things happen in your workspace, and it also receives inbound webhooks from Stripe, Resend, and Twilio.

Outbound: subscribe to workspace events

Register endpoints and Modality will POST a signed JSON payload to them. This is a shipped feature, manage endpoints via the API or in Settings.

GET/api/v1/webhooks/endpointsSession Auth

List your webhook endpoints (with delivery counts).

Response
{ "data": [ { "id": "...", "url": "https://...", "events": ["order.completed"], "status": "ACTIVE" } ] }
POST/api/v1/webhooks/endpointsSession Auth

Create an endpoint. A signing secret (whsec_...) is generated and stored for you.

Request Body (JSON)
urlstring (URL)requiredYour HTTPS receiver
eventsstring[]requiredOne or more event types (see below)
descriptionstringOptional label
GET/api/v1/webhooks/endpoints/:idSession Auth

Get an endpoint plus its recent deliveries.

PATCH/api/v1/webhooks/endpoints/:idSession Auth

Update url, events, description, or status (ACTIVE | PAUSED | DISABLED). Re-setting to ACTIVE resets the failure counter.

DELETE/api/v1/webhooks/endpoints/:idSession Auth

Delete an endpoint.

Subscribable event types:

  • contact.created
  • contact.updated
  • contact.deleted
  • contact.status_changed
  • form.submitted
  • event.created
  • event.updated
  • event.deleted
  • order.completed
  • campaign.sent
  • automation.triggered
  • record.created
  • record.updated
Delivery payload & verification
POST https://your-endpoint.example.com
X-Webhook-Event: order.completed
X-Webhook-Timestamp: 2026-09-02T10:30:00.000Z
X-Webhook-Signature: <hex hmac-sha256>

{
  "event": "order.completed",
  "timestamp": "2026-09-02T10:30:00.000Z",
  "workspaceId": "clx...",
  "data": { "orderId": "clx...", "eventId": "clx...", "total": 9000, ... }
}

Verify authenticity by computing HMAC-SHA256(secret, timestamp + "." + rawBody) with your endpoint's whsec_ secret and comparing it to X-Webhook-Signature. Deliveries retry up to 3 times with exponential backoff; an endpoint that fails 10 times in a row is auto-disabled. Return a 2xx to acknowledge.

Screenshot + payload: the Settings → Webhooks management UI showing an endpoint with its event subscriptions and ACTIVE/PAUSED/DISABLED status, alongside an expanded recent delivery for order.completed (the JSON body plus the X-Webhook-Signature / X-Webhook-Timestamp / X-Webhook-Event headers).

Demo GIF / screenshot to be added

Inbound: Stripe

Point your Stripe webhook (platform and Connect) at /api/v1/webhooks/stripe. The following events are handled:

  • checkout.session.completed, fulfills ticket orders, issues tickets, sends confirmation.
  • checkout.session.expired, releases held inventory for an abandoned checkout.
  • customer.subscription.updated, applies plan changes.
  • customer.subscription.deleted, handles cancellation/downgrade.
  • invoice.paid, records a successful subscription payment.
  • invoice.payment_failed, flags a billing problem.

Inbound: Resend, Twilio & email

POST /api/v1/webhooks/resendEmail delivery/open/click/bounce events from Resend
POST /api/v1/webhooks/twilioSMS delivery-status callbacks from Twilio
POST /api/v1/webhooks/inbound-emailInbound email (e.g. support+<token>@ replies)

Rate Limits

Rate limiting uses sliding-window presets keyed per workspace (ws:<id>), or per client IP on public endpoints like checkout. There are no per-plan tiers. The presets:

PresetLimitApplied to
default60 / 60sStandard API reads/writes
strict10 / 60sSensitive routes
ai20 / 60sAI-backed endpoints

A limited request returns 429 with body { "error": "Too many requests" } and these headers:

  • Retry-After, seconds until you can retry.
  • X-RateLimit-Limit, the window's ceiling.
  • X-RateLimit-Remaining, 0 on a limited response.
  • X-RateLimit-Reset, epoch ms when the window resets.
Rate limiting is backed by Upstash Redis and is only active when UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are configured. If they are absent, limiting is disabled and all requests pass (a warning is logged in production). Don't rely on rate limiting as a correctness guarantee.

Screenshot: a real 429 response to a repeated request, show the JSON body with error 'Too many requests' together with the Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset response headers (e.g. from a browser devtools Network panel or a curl -i).

Demo GIF / screenshot to be added