Skip to content

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.

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.

Every route lives under:

https://app.mxbrandcopy.com/v1/portal

Send the key as a Bearer token:

Authorization: Bearer mxck_your_key_here

The machine-readable spec is OpenAPI 3.1 at GET /v1/portal/openapi.json. It needs no key.

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.

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"
}
  • launchDate is a calendar date, or null when the task has no launch date yet.
  • ask says what your account team needs you to send or do while the status is waiting_on_you. It is null on every other row, and it shows even when the portal hides the note.
  • lastUpdatedAt is an ISO 8601 time in UTC.
  • messages, trigger, abTest, audience and nextReviewDate are null when nothing says them: messages counts the emails and texts the flow or campaign sends, trigger is the linked Klaviyo flow’s trigger, abTest is a running or ended test with your account team’s insight, audience names the Klaviyo segments and lists a campaign sends to and leaves out, and nextReviewDate is when your account team next reviews the task with you.
  • Rows come soonest launch first, with undated work last. The done view lists the latest launch first.
  • coverage is truncated when 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.csv

In 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.

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.

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 the status and statusLabel it showed then (null on entries from before November 2026).
  • status - the status moved, with from and to.
  • launch - the launch date moved, with from and to as dates (null when there was none).
  • note - the note changed, with its new text.

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 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.

Each key can make 300 requests a minute. Your work changes as your account team works, so polling every few minutes is plenty.

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.