# Mage Engage API — Guide for AI Agents

You are an AI agent working in one Mage Engage account. Mage Engage plans, writes and publishes social media posts for brands. This guide tells you how to use its API safely.

The latest version of this guide is always at `https://mageengage.cloud/api/v1/guide`, and the machine-readable OpenAPI description at `https://mageengage.cloud/api/v1/openapi.yaml`. Neither needs a token.

## 1. Connecting

- **Base URL:** `https://mageengage.cloud/api/v1`
- **Authentication:** send your token on every request:
  `Authorization: Bearer <token>`
- **Format:** send JSON with `Content-Type: application/json`, except file uploads, which are `multipart/form-data`. Every response is JSON, errors included.
- **Rate limit:** 120 requests per minute per token. Over the limit you get `429`; wait for the number of seconds in the `Retry-After` header, then try again.
- **IDs:** brands, posts, variants and media have whole-number ids. AI runs have a text id (a UUID).

## 2. What you may do

Your rights are set by the account owner, per brand. **Always call `GET /me` first** and read each brand's `permissions`.

| Action | Who may do it |
|---|---|
| Read brands, posts and AI runs | Every agent |
| Create and edit ideas and drafts; submit them for review | Every agent |
| Upload images and video to a brand, and attach them to a post's platforms | Every agent (changing a scheduled post's media also needs `publish`) |
| Schedule, publish now, or edit a post that is already scheduled; publish or schedule one platform on its own | Only on brands where `permissions.publish` is `true` |
| Use AI to write posts | Only on brands where `permissions.ai` is `true` |

Rules that always apply:

- **You can never approve posts.** If a brand has `requires_approval: true`, a person must approve a post before it can be scheduled or published. Submit it for review and tell the user.
- **AI costs credits.** Each AI run spends the account's AI credits (`account.ai_credits` in `GET /me`). Reading, drafting, scheduling and publishing cost nothing.
- **Publishing is real.** `schedule` and `publish` send posts to real social media accounts. Only do it when the user asked for it.
- **If you are refused** with `403 not_permitted`, do not retry or look for another way. Tell the user which right is missing and on which brand; only the account owner can grant it.

## 3. Times

- Every time you **receive** is UTC, in ISO 8601: `2026-11-05T14:30:00+00:00`.
- A time you **send** can be either:
  - ISO 8601 with an offset or `Z` (an exact moment): `2026-11-05T09:30:00Z`, `2026-11-05T09:30:00-05:00`
  - or the brand's local wall-clock time, with no offset: `2026-11-05T09:30` (read in the brand's `timezone`)
- When a user says "9am", they normally mean 9am in the brand's time zone. Send it without an offset.

## 4. Destinations

A post goes to one or more **destinations**, given as keys in `channels` (posts) or `platforms` (AI). Get them from `GET /brands/{id}` → `destinations`:

```json
{ "key": "linkedin:6f1c…", "channel": "linkedin", "platform": "LinkedIn", "label": "CSP Geeks", "connected": true, "kind": "page" }
```

- Use the `key` exactly as given.
- A bare platform key (`facebook`, `instagram`, `linkedin`, `x`, `bluesky`) means every connected destination on that platform. If none is connected, the post is a draft for that platform only and cannot be published there.
- Platforms have their own rules (for example X: 280 characters; Bluesky: 300; Instagram: needs at least one image). A post that breaks them fails when it publishes.

## 5. Typical workflows

**Write a draft**
1. `GET /me` → pick the brand.
2. `GET /brands/{id}` → choose destination keys (and optionally a pillar, product, audience or campaign id).
3. `POST /brands/{id}/posts` with `title`, `body`, `channels`.
4. Optional: `PATCH /posts/{id}/variants/{variantId}` to give one platform its own text.
5. If the brand requires approval: `POST /posts/{id}/submit`, and tell the user it is waiting for review.

**Put images or video on a post**

Media is chosen **per platform**: each platform publishes only its own list, because platforms need differently sized media (for example square or 4:5 for Instagram, landscape for LinkedIn and X). Nothing is shared between platforms; add media to each platform yourself.
1. `POST /brands/{id}/media` with the file (multipart) → you get `media.id`. Or reuse one from `GET /brands/{id}/media`. Upload one file per size you need.
2. Send `media: { "instagram": [id], "linkedin": [id2] }` when you create the post, or `PATCH /posts/{id}` with `media` later. Each list is shown in the order given. The same file may be listed for several platforms.
3. A platform must be on the post (in `channels`) before it can have media.
4. Always send `alt_text` with an image: it describes the image for people who can't see it.

**Schedule a post** (needs `publish` on that brand)
1. Make sure the post is a draft or idea (or `approved`, if the brand requires approval).
2. `POST /posts/{id}/schedule` with `scheduled_at` in the future, or `next_slot: true` for the brand's next free posting slot.

**Publish or schedule one platform on its own** (needs `publish` on that brand)
1. `GET /posts/{id}` → find the platform's variant in `variants` (its `id` and `channel`).
2. `POST /posts/{id}/variants/{variantId}/publish` to send that platform now, or `POST /posts/{id}/variants/{variantId}/schedule` with `scheduled_at` to send it at its own time.
3. The other platforms are not sent. On a scheduled post they still go at the post's time; on a post that is not scheduled they wait, and the post shows `partially_published` once a platform has gone out.

**Let the AI write posts** (needs `ai` on that brand)
1. `POST /brands/{id}/ai/posts` → you get a `run` with `status: "queued"`.
2. Poll `GET /ai/runs/{runId}` every 5–10 seconds until `status` is `done` or `failed`. It usually takes under a minute.
3. On `done`, `posts` lists the Drafts the AI wrote. Review them with the user before scheduling.

## 6. Endpoints

### GET /me
Who you are, the account and every brand with your rights on it.

```json
{
  "agent": { "id": 12, "name": "Scheduler bot", "description": "Writes weekly drafts" },
  "account": { "name": "CSP Geeks", "ai_credits": 48210 },
  "brands": [
    { "id": 3, "name": "CSP Geeks", "timezone": "America/Chicago", "website_url": "https://cspgeeks.com",
      "requires_approval": false, "is_active": true, "permissions": { "publish": true, "ai": false } }
  ]
}
```

### GET /brands
Returns `{ "brands": [ … ] }`, the same brand objects as in `/me`.

### GET /brands/{brandId}
One brand, plus what a post can point at:

```json
{ "brand": { "id": 3, "name": "CSP Geeks", "…": "…",
  "destinations": [ { "key": "facebook:9a2e…", "channel": "facebook", "platform": "Facebook", "label": "CSP Geeks", "connected": true, "kind": "page" } ],
  "pillars":   [ { "id": 4, "name": "How-to" } ],
  "products":  [ { "id": 7, "name": "Managed WordPress" } ],
  "audiences": [ { "id": 2, "name": "Small agencies" } ],
  "campaigns": [ { "id": 5, "name": "Black Friday", "status": "active" } ] } }
```

### GET /brands/{brandId}/posts
The brand's posts: those with a time first, earliest first, then those without one, newest first.

Query parameters (all optional):

| Parameter | Meaning |
|---|---|
| `status` | Only posts with this status (see the list below) |
| `from` | Only posts planned at or after this time |
| `to` | Only posts planned before this time |
| `per_page` | Posts per page, 1–100 (default 50) |
| `page` | Which page |

Returns `{ "posts": [Post], "meta": { "page", "per_page", "total", "last_page" } }`. Posts in the list leave out each variant's text; use `GET /posts/{id}` for it.

### GET /posts/{postId}
Returns `{ "post": Post }` in full:

```json
{ "post": {
  "id": 81, "brand_id": 3, "title": "Uptime tips", "body": "Three things…",
  "content_goal": null, "cta": "Read more", "cta_url": "https://cspgeeks.com/uptime",
  "status": "draft", "origin": "manual",
  "scheduled_at": null, "published_at": null, "approved": false,
  "campaign_id": null, "product_service_id": null, "audience_id": null, "pillars": [4],
  "media": {
    "facebook": [ { "id": 31, "brand_id": 3, "type": "image", "title": "Uptime comic", "alt_text": "A four-panel comic…",
      "file_name": "uptime-comic.png", "mime": "image/png", "size": 482113, "width": 1200, "height": 800, "created_at": "…" } ],
    "linkedin": []
  },
  "author_id": 12, "created_at": "…", "updated_at": "…",
  "internal_notes": null, "review_note": null,
  "variants": [
    { "id": 140, "destination": "facebook:9a2e…", "channel": "facebook", "status": "pending",
      "scheduled_at": null, "scheduled_alone": false, "published_at": null, "content": "Three things…" },
    { "id": 141, "destination": "linkedin", "channel": "linkedin", "status": "pending",
      "scheduled_at": "2026-11-06T15:00:00+00:00", "scheduled_alone": true, "published_at": null, "content": "Three things…" }
  ]
} }
```

`media` has one list per platform on the post: exactly what that platform publishes, in order. A variant's `scheduled_at` is that platform's own time; with `scheduled_alone: true` it goes then even if the post itself is not scheduled.

**Post `status` values:**
- Before publishing: `idea`, `draft`, `needs_review`, `approved`, `scheduled`
- Publishing and after: `publishing`, `published`, `partially_published`, `failed`, `cancelled`, `abandoned`

**Variant `status` values:** `pending`, `publishing`, `published`, `failed`, `cancelled`, `abandoned`.

### POST /brands/{brandId}/posts
Create a post. It is saved as a Draft unless you send `status: "idea"`. Returns `201 { "post": Post }`.

| Field | Required | Notes |
|---|---|---|
| `title` | yes | Up to 255 characters; for people, not published as-is |
| `channels` | yes | Destination keys, at least one |
| `body` | | The post text; every variant starts with it |
| `status` | | `draft` (default) or `idea` |
| `scheduled_at` | | A *planned* time. It does not schedule the post |
| `cta`, `cta_url` | | Call to action and its link |
| `content_goal`, `internal_notes` | | For people; not published |
| `pillars` | | Pillar ids |
| `campaign_id`, `product_service_id`, `audience_id` | | Ids from `GET /brands/{id}` |
| `media` | | Media per platform: `{ "instagram": [ids], "linkedin": [ids] }`. Up to 10 of the brand's image or video ids per platform, in the order they appear. Each platform must be in `channels`, and each id already in the brand's library (`POST /brands/{id}/media`). `media_ids` (one list for every platform) is no longer accepted |

```json
{ "title": "Uptime tips", "body": "Three things that keep a site up…", "channels": ["facebook:9a2e…", "linkedin"],
  "media": { "facebook": [31], "linkedin": [32] } }
```

### PATCH /posts/{postId}
Edit a post. Send only the fields you change; the fields are the same as for create.

- Sending `channels` replaces the destinations. A platform that has already published is never removed. A platform you remove loses its media.
- Sending `media` replaces the media of each platform you name, in the order given; `[]` removes that platform's media. Platforms you don't name keep theirs. Leave `media` out to keep all media as it is.
- `status` may only switch between `idea` and `draft`.
- Editing an `approved` post on a brand that requires approval sends it back to review.
- A `scheduled` post needs `publish` on the brand.
- Posts that are publishing or finished cannot be edited (`409`).

### GET /brands/{brandId}/media
The brand's images and video, newest first. Query parameters (all optional): `q` (words in the title or tags), `type` (`image` or `video`), `per_page` (1–100, default 50), `page`.

Returns `{ "media": [Media], "meta": { "page", "per_page", "total", "last_page" } }`. A Media item is shown in the post example above.

### POST /brands/{brandId}/media
Upload an image or video into the brand's private media library. Send `multipart/form-data`:

| Field | Required | Notes |
|---|---|---|
| `file` | yes | An image (JPG, PNG, GIF or WebP, up to 10 MB) or a video (MP4, MOV or WebM, up to 25 MB) |
| `title` | | Defaults to the file name |
| `alt_text` | | What the image shows, up to 500 characters. Send it for every image |
| `description` | | Notes for people, up to 5,000 characters |

```
curl -X POST https://mageengage.cloud/api/v1/brands/3/media \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \
  -F "file=@uptime-comic.png" -F "alt_text=A four-panel comic about keeping a site up"
```

Returns `201 { "media": Media }`. Uploads count against the plan's storage; when it is full you get `422`. The file itself is never linked in the API: it stays private to the brand, and Mage Engage sends it to the platforms when the post publishes.

### PATCH /posts/{postId}/variants/{variantId}
Give one destination its own text: `{ "content": "…" }`. This only works while that variant is `pending` or `failed`.

### POST /posts/{postId}/submit
Send an idea or draft for review (`needs_review`). The brand's reviewers are notified.

### POST /posts/{postId}/schedule
**Needs `publish`.**

| Field | Notes |
|---|---|
| `scheduled_at` | A future time. Required unless `next_slot` is sent |
| `next_slot` | `true` puts the post in the brand's next free posting slot |
| `confirm_links` | `true` schedules even if a link in the post does not load |

Each posting slot holds a limited number of posts. If the time is taken, you get `422` with the next free slot in the message. Returns `{ "post": Post }` with `status: "scheduled"`.

### POST /posts/{postId}/publish
**Needs `publish`.** Sends the post now to every `pending` or `failed` destination.

- Send `{ "confirm_links": true }` to go ahead when a link does not load.
- Returns `202 { "post": Post, "dispatched": 2 }`. Publishing happens in the background, so check the post's variants a minute later.

### POST /posts/{postId}/variants/{variantId}/publish
**Needs `publish`.** Sends this one platform now (a `failed` one is retried). The post's other platforms are not sent.

- Send `{ "confirm_links": true }` to go ahead when a link does not load.
- Returns `202 { "post": Post }`. Check the variant a minute later.
- `409` when the platform has already gone out, or the brand requires approval and the post is not approved.

### POST /posts/{postId}/variants/{variantId}/schedule
**Needs `publish`.** Schedules this one `pending` platform for its own time, whether or not the post is scheduled.

| Field | Notes |
|---|---|
| `scheduled_at` | A future time |
| `confirm_links` | `true` schedules even if a link does not load |

Returns `{ "post": Post }`; the variant shows `scheduled_alone: true`. Call it again to change the time. A post that is not scheduled uses one of the month's scheduled posts from the plan.

### POST /posts/{postId}/variants/{variantId}/unschedule
**Needs `publish`.** Takes a platform scheduled on its own off the schedule and clears its own time. Returns `{ "post": Post }`.

### POST /brands/{brandId}/ai/posts
**Needs `ai`; spends AI credits.** The AI writes posts in the brand's voice. They land as Drafts. Returns `202 { "run": Run }`.

| Field | Required | Notes |
|---|---|---|
| `mode` | yes | `single`, `product`, `announcement`, `reuse` or `holiday` |
| `platforms` | yes | Destination keys, at least one; the AI writes one text per platform |
| `prompt` | for `single`, `announcement` | What to write about, up to 2,000 characters |
| `count` | | Number of posts, 1–5 (default 1) |
| `product_id` | for `product` | The product or service to promote |
| `reuse_type` | for `reuse` | What to rework: `post`, `source`, `product`, `campaign`, `article` or `text` |
| `reuse_id` / `reuse_text` | for `reuse` | The record's id, or (for `text`) the text itself |
| `holiday` | for `holiday` | A holiday key `YYYY-MM-DD\|…` from the app. The API cannot list holidays yet, so prefer `single` |
| `audience_id`, `pillar_id`, `campaign_id` | | Steer the writing |
| `scheduled_at` | | A planned time for the Drafts |
| `notes` | | Extra instructions, up to 2,000 characters |

```json
{ "mode": "single", "prompt": "Why weekly backups matter for small agencies", "platforms": ["linkedin", "facebook"], "count": 2 }
```

### GET /ai/runs/{runId}
A run you started:

```json
{ "run": { "id": "5d0c…", "brand_id": 3, "status": "done", "error": null, "warnings": [],
  "posts": [ { "id": 92, "status": "draft", "…": "…" } ], "created_at": "…", "updated_at": "…" } }
```

- `status` moves `queued` → `writing` → `done` or `failed`.
- On `failed`, `error` says why. Low AI credits is a common cause; tell the user.
- Credits are not charged for a run that produced nothing.

## 7. Errors

Every error has a `message` you can show the user.

| Status | Meaning | What to do |
|---|---|---|
| `401` | Missing, wrong or revoked token, or the agent was removed | Stop and tell the user. Do not retry |
| `403` `not_permitted` | You lack `publish` or `ai` on that brand (`permission` says which) | Tell the user the account owner must grant it |
| `404` | The record does not exist in this account | Check the id |
| `409` `not_allowed_now` | The post's status does not allow it (for example, approval is required) | Read `message`; usually a person must act |
| `409` `account_suspended` | The account is suspended | Stop and tell the user |
| `422` | Invalid input or a plan limit reached | Fix the fields in `errors`; for a plan limit, tell the user |
| `429` | Too many requests | Wait `Retry-After` seconds |

Example of a `403`:

```json
{ "error": "not_permitted", "permission": "publish", "message": "This agent may not schedule or publish posts for CSP Geeks. The account owner can grant it." }
```

Example of a `422`:

```json
{ "message": "The title field is required.", "errors": { "title": ["The title field is required."] } }
```

## 8. Not available through the API yet

You cannot do these through the API; a person does them in the app:

- deleting posts, or cancelling a schedule
- fetching an image from a web address (upload the file instead)
- the Campaign Builder, Mage Planner, articles and AI images
- approving posts
