Client portal API
The client portal API gives your developer the same work and activity you see in the client portal, so you can build your own dashboard or sync it to another tool. It is read-only. This page is for developers.
Get a key
Section titled “Get a key”A client creates a key in the portal from API keys on the menu of their name at the top right, or asks their account team for one. The key starts with mxck_ and is shown once, so copy it when it appears. MX keeps only its last four characters.
A key reads one brand, for the person it belongs to, and sees exactly what that person sees in the portal. It stops working at once when it is revoked, when the person loses access to the portal, or when their account team turns Client API off or the organization turns off its API access.
Base URL and auth
Section titled “Base URL and auth”Every route lives under:
https://app.mxbrandcopy.com/v1/portalSend the key as a Bearer token:
Authorization: Bearer mxck_your_key_hereThe machine-readable spec is OpenAPI 3.1 at GET /v1/portal/openapi.json. It needs no key.
GET /brand
Section titled “GET /brand”The brand and what its portal shows.
curl -H "Authorization: Bearer mxck_your_key_here" \ https://app.mxbrandcopy.com/v1/portal/brand{ "data": { "name": "Acme Coffee", "defaultView": "everything", "columns": [ "type", "status", "launch", "lastUpdate", "note", "links", "messages", "trigger", "abTest", "audience", "nextReview" ], "sections": ["work", "activity", "files"] }}columns lists the columns the portal shows, in order. Name, status and launch date always show. A column it does not show comes back null on every row of /work. sections lists the portal’s tabs it shows: work (always), activity, results (the Analytics tab, under the key it first shipped with) and files.
GET /work
Section titled “GET /work”Every task the portal shows, open and done.
curl -H "Authorization: Bearer mxck_your_key_here" \ "https://app.mxbrandcopy.com/v1/portal/work?view=coming_up"{ "data": [ { "id": "k57ab2xq9m3r0v1s8t6w4y2z0a", "title": "Black Friday early access", "type": "campaign", "typeLabel": "Email campaign", "status": "needs_your_review", "statusLabel": "Needs your review", "launchDate": "2026-11-24", "lastUpdatedAt": "2026-11-03T15:04:05.000Z", "note": "Two subject lines to pick from.", "ask": null, "links": { "klaviyo": "https://www.klaviyo.com/campaign/...", "figma": null }, "messages": { "emails": 1, "sms": 0, "text": "1 Email" }, "trigger": null, "abTest": { "state": "running", "stateLabel": "Test running", "insight": "Half get an SMS as well as the email." }, "audience": { "included": ["Engaged 90 days"], "excluded": ["Bought last 30 days"] }, "nextReviewDate": "2026-12-01" } ], "coverage": "complete"}launchDateis a calendar date, ornullwhen the task has no launch date yet.asksays what your account team needs you to send or do while the status iswaiting_on_you. It isnullon every other row, and it shows even when the portal hides the note.lastUpdatedAtis an ISO 8601 time in UTC.messages,trigger,abTest,audienceandnextReviewDatearenullwhen nothing says them:messagescounts the emails and texts the flow or campaign sends,triggeris the linked Klaviyo flow’s trigger,abTestis a running or ended test with your account team’s insight,audiencenames the Klaviyo segments and lists a campaign sends to and leaves out, andnextReviewDateis when your account team next reviews the task with you.- Rows come soonest launch first, with undated work last. The
doneview lists the latest launch first. coverageistruncatedwhen the brand has more than 2,000 tasks and the answer holds the first 2,000.
Optional filters, which combine:
| Parameter | Values |
|---|---|
view |
needs_you, coming_up, in_progress, done, everything |
status |
not_started, in_progress, waiting_on_you, needs_your_review, making_changes, approved, previews_sent (Previews sent), done |
type |
campaign, text_campaign, sms, flow, flow_update, other (work outside campaigns and flows) |
Filtering by type when the portal does not show that column returns 400. Filtering by status, and every view, always works.
Add format=csv, or send Accept: text/csv, to get the same rows as a spreadsheet with the columns the portal’s Download CSV uses:
curl -H "Authorization: Bearer mxck_your_key_here" \ "https://app.mxbrandcopy.com/v1/portal/work?format=csv" -o work.csvIn the CSV, Launch date and Last updated are calendar dates (2026-11-24). Last updated is the day in your account team’s time zone. A CSV answer carries X-Coverage: complete or truncated.
Google Sheets
Section titled “Google Sheets”IMPORTDATA cannot send an Authorization header, so it cannot read this API. Use Apps Script instead: Extensions > Apps Script, store the key under Project Settings > Script properties as MX_KEY, and run:
function importMxWork() { const key = PropertiesService.getScriptProperties().getProperty('MX_KEY') const response = UrlFetchApp.fetch('https://app.mxbrandcopy.com/v1/portal/work?format=csv', { headers: { Authorization: 'Bearer ' + key }, }) const rows = Utilities.parseCsv(response.getContentText()) const sheet = SpreadsheetApp.getActiveSheet() sheet.clearContents() sheet.getRange(1, 1, rows.length, rows[0].length).setValues(rows)}Add a time-driven trigger to refresh it on a schedule.
GET /activity
Section titled “GET /activity”What changed, newest first, up to 50 entries a page.
curl -H "Authorization: Bearer mxck_your_key_here" \ https://app.mxbrandcopy.com/v1/portal/activity{ "data": [ { "id": "jd7f3k2m9q8r1s0t4v6w2x5y3z", "taskId": "k57ab2xq9m3r0v1s8t6w4y2z0a", "title": "Black Friday early access", "at": "2026-11-03T15:04:05.000Z", "change": { "kind": "status", "from": "in_progress", "fromLabel": "In progress", "to": "needs_your_review", "toLabel": "Needs your review" } } ], "nextCursor": "eyJ..."}change.kind is one of:
added- the task joined the portal, with thestatusandstatusLabelit showed then (nullon entries from before November 2026).status- the status moved, withfromandto.launch- the launch date moved, withfromandtoas dates (nullwhen there was none).note- the note changed, with its newtext.
taskId matches id in /work. To read the next page, pass nextCursor back as ?cursor=. It is null on the last page. A page can hold fewer than 50 entries before the end.
Errors
Section titled “Errors”Errors are JSON: { "error": { "code": "...", "message": "..." } }.
| Status | Code | When |
|---|---|---|
| 400 | validation_failed |
A filter or cursor is not valid. |
| 401 | unauthorized |
The API key is missing or no longer works. The answer is the same whatever the reason. |
| 404 | not_found |
No such route. |
| 405 | method_not_allowed |
Anything but GET. The API is read-only. |
| 429 | rate_limited |
Too many requests for this key. Wait Retry-After seconds, then try again. |
| 500 | internal_error |
Something went wrong on our side. Try again in a minute. If it keeps happening, send us the X-Request-Id. |
Every answer carries X-Request-Id. Include it when you report a problem.
Rate limit
Section titled “Rate limit”Each key can make 300 requests a minute. Your work changes as your account team works, so polling every few minutes is plenty.
Browsers
Section titled “Browsers”The API answers GET from any origin (CORS) and never uses cookies. Even so, call it from a server: a key in a web page is visible to anyone who opens it.