# Shager REST API Quickstart: From a Fresh Key to a Tracked Post on 40 Accounts in Seven Calls

_2026-09-19 · A working tour of Shager's REST API in curl and TypeScript: auth, workspaces, channels, media, per-account drafts, scheduling, status checks and clicks._

Shager's REST API lives at `https://app.shager.ai/api/v1`, authenticates with `Authorization: Bearer shgr_live_...`, returns every result as `{ "data": ... }` and every error as `{ "error": { "code", "message" } }`. A complete distribution run is seven calls: check the key, pick a workspace, list its channels, upload media, create a draft with per-account captions, schedule it, then read status and clicks. The machine-readable spec is the [OpenAPI 3.1 document](https://app.shager.ai/api/v1/openapi.json).

Everything below runs as written once you set two environment variables. The same operations exist as MCP tools with the same names, so what you learn here carries over to Claude Code, Codex or Cursor.

## What do you need before the first call?

A key. Either a human creates one at https://app.shager.ai/dashboard/agents with the scopes you need, or your agent creates a sandbox through [self-signup](/en/blog/agent-self-signup-and-claim). Scopes work like this:

| Scope | Allows |
|---|---|
| `read` | `whoami`, listing workspaces, channels, groups and posts, reading a post, analytics |
| `write` | Creating workspaces, groups, media, draft posts, connect links, approval requests, cancelling |
| `publish` | Scheduling, including `create_post` with `schedule_at` |

```bash
export SHAGER_API_KEY="shgr_live_your_key_here"
export API="https://app.shager.ai/api/v1"
```

## Call 1: who am I?

```bash
curl -s "$API/me" -H "Authorization: Bearer $SHAGER_API_KEY"
```

```json
{ "data": { "organization_id": "...", "organization": "Northwind Agency", "sandbox": false, "scopes": ["read", "write"] } }
```

If `sandbox` is true, scheduling and connect links are blocked until a human claims the key. If `publish` is missing from `scopes`, you can build drafts but not schedule them. Knowing this up front saves a confusing 403 later.

## Call 2: pick or create a workspace

A workspace is usually one client or brand.

```bash
curl -s "$API/workspaces" -H "Authorization: Bearer $SHAGER_API_KEY"

curl -s -X POST "$API/workspaces" \
  -H "Authorization: Bearer $SHAGER_API_KEY" -H "Content-Type: application/json" \
  -d '{"name": "Summit Physio", "timezone": "Europe/London", "approval_required": true}'
```

Always set `timezone` (an IANA name) on creation. Schedule times are stored with their offset, and the workspace zone is how Shager shows them to humans. `approval_required: true` means posts must be approved by the client before they can be scheduled, which is a good default for new clients.

Export the id from the response so the following commands work:

```bash
export WS="the_workspace_id"
```

## Call 3: list channels and groups

```bash
curl -s "$API/workspaces/$WS/channels" -H "Authorization: Bearer $SHAGER_API_KEY"
curl -s "$API/workspaces/$WS/groups" -H "Authorization: Bearer $SHAGER_API_KEY"
```

Each channel comes back with `id`, `network`, `name`, `handle`, `status`, the `formats` it supports, and `connected_by_client`. Groups return `id`, `name` and `channel_ids`. Groups are how you address forty accounts without listing forty ids; pass `group_ids` to `create_post` and every active channel in the group is included.

If the workspace has no channels yet, create a connect link with `POST /workspaces/{workspace_id}/connect-links` and send it to the account owner. They connect Facebook, Instagram or YouTube themselves, with no Shager login.

## Call 4: upload media from a URL

```bash
curl -s -X POST "$API/workspaces/$WS/media" \
  -H "Authorization: Bearer $SHAGER_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "url": "https://cdn.summitphysio.example/video/knee-mobility.mp4",
    "file_name": "knee-mobility.mp4",
    "width": 1080, "height": 1920, "duration_ms": 42000
  }'
```

Shager downloads the file server side and returns `id`, `kind`, `mime_type` and `size_bytes`. For Reels, Shorts and Stories, pass `width`, `height` and `duration_ms` when you know them. That lets Shager check each network's format rules before a post is scheduled rather than failing at dispatch.

## Call 5: create a draft with per-account captions

This is the call that matters. Here it is in TypeScript with plain `fetch`, including a small helper you'll reuse:

```typescript
const API = "https://app.shager.ai/api/v1";

async function shager<T>(method: string, path: string, body?: unknown): Promise<T> {
  const res = await fetch(`${API}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.SHAGER_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json();
  if (!res.ok) {
    const err = new Error(`${res.status} ${json.error.code}: ${json.error.message}`);
    (err as any).status = res.status;
    throw err;
  }
  return json.data as T;
}

const WS = process.env.SHAGER_WORKSPACE_ID!;
const mediaId = process.env.SHAGER_MEDIA_ID!; // the id returned by call 4
const channels = await shager<Array<{ id: string; name: string; network: string; status: string }>>(
  "GET", `/workspaces/${WS}/channels`,
);

const post = await shager<{ id: string; status: string }>("POST", `/workspaces/${WS}/posts`, {
  label: "Knee mobility, week 42",
  caption: "Three minutes a day for stiffer knees. Full routine in the video.",
  channel_ids: channels.map((c) => c.id),
  media_ids: [mediaId],
  format: "REEL",
  youtube_title: "3-minute knee mobility routine",
  youtube_privacy: "public",
  per_channel: channels.map((c) => ({
    channel_id: c.id,
    caption: `${c.name}: three minutes a day for stiffer knees. Our physios show the routine.`,
  })),
  cta_url: "https://summitphysio.example/book",
  cta_text: "Book an assessment",
  campaign: "knee-mobility-oct",
  first_comment: "Book an assessment: https://summitphysio.example/book",
  stagger_seconds: [45, 120],
});

console.log(post.id, post.status); // DRAFT
```

What each field earns you:

1. `per_channel` gives every account its own caption. In practice an agent writes these; the template above is a placeholder for that step, and the reasoning is in [avoiding spam blocks at scale](/en/blog/avoid-spam-blocks-when-posting-at-scale).
2. `format` is applied where the network supports it, and `SHORT` versus `REEL` is resolved per network otherwise. `youtube_title` and `youtube_privacy` only affect YouTube channels.
3. `cta_url` produces a separate short link and UTM set per account, with `utm_content` identifying the account. That's what [per-account lead attribution](/en/blog/utm-per-account-lead-attribution) is built on.
4. `stagger_seconds` sets a random pause between accounts on the same network. The default is `[30, 90]`.

Without `schedule_at`, the result is a draft. Keep its id for the next calls with `export POST_ID="the_post_id"`.

## Call 6: approval or schedule

If the workspace requires approval, send it to the client. The response includes an `approval_url` the reviewer opens without logging in:

```bash
curl -s -X POST "$API/workspaces/$WS/posts/$POST_ID/approval" \
  -H "Authorization: Bearer $SHAGER_API_KEY" -H "Content-Type: application/json" \
  -d '{"reviewer_name": "Dana Ellis", "reviewer_email": "dana@summitphysio.example", "send_email": true}'
```

Once approved (or if approval isn't required), schedule it. This needs the `publish` scope, and `schedule_at` must be ISO 8601 with an offset:

```bash
curl -s -X POST "$API/workspaces/$WS/posts/$POST_ID/schedule" \
  -H "Authorization: Bearer $SHAGER_API_KEY" -H "Content-Type: application/json" \
  -d '{"schedule_at": "2026-10-20T08:30:00+01:00"}'
```

Shager validates every destination against its network's rules at this point. If any account would reject the post, you get a 422 `rejected` with the reason, and nothing is queued.

## Call 7: check status and clicks

```bash
curl -s "$API/workspaces/$WS/posts/$POST_ID" -H "Authorization: Bearer $SHAGER_API_KEY"
curl -s "$API/workspaces/$WS/posts?status=SCHEDULED&limit=50" -H "Authorization: Bearer $SHAGER_API_KEY"
curl -s "$API/workspaces/$WS/analytics?from=2026-10-01T00:00:00Z&to=2026-10-31T23:59:59Z" \
  -H "Authorization: Bearer $SHAGER_API_KEY"
```

`get_post` returns one row per destination with `state`, `dispatch_at`, `published_at`, the live `url` and any `error`. Analytics returns `total_clicks`, `by_account` and `by_post`, counting clicks on Shager's tracked links with bot traffic excluded. To cancel a scheduled post that hasn't started publishing, `POST /workspaces/{workspace_id}/posts/{post_id}/cancel`.

## Which errors should your code handle?

| Status and code | Meaning | What to do |
|---|---|---|
| 401 `unauthorized` | Header missing or malformed | Send `Authorization: Bearer shgr_...` |
| 403 `forbidden` | Key lacks the needed scope | Draft instead, or ask a human for `publish` |
| 403 `sandbox_unclaimed` | Unclaimed self-signup key | Surface the claim link to a human |
| 404 `not_found` | Wrong workspace, post or channel id | Re-list; ids are per workspace |
| 409 `not_scheduled` | Cancelling a post that isn't scheduled | Read the post status first |
| 422 `rejected` | A network rule failed | Show the message; it names the problem |
| 429 `rate_limited` | Over 120 requests per minute per key | Back off and retry |

The rate limit rarely bites a well-designed client. One `create_post` covers hundreds of accounts, so you shouldn't need a call per account.

## FAQ

### Is there an SDK?

Not a dedicated one. The OpenAPI 3.1 document works with standard generators if you want typed clients, and the API is small enough that a helper like the one above is usually all you need. A Shager CLI is coming.

### Are REST and MCP really the same?

Yes. Each MCP tool maps to one REST operation with the same name, inputs, scopes, rate limit and audit logging. Use REST from scripts and services, and MCP from agent clients. The full tool list is on the [agents page](/en/agents).

### How do I post different text to Facebook and YouTube in the same post?

Use `per_channel` with each channel's id and caption, and `youtube_title` for the YouTube title. One post object, many destinations, each with its own caption, format and first comment if you need it.

## Next step

When this sequence runs cleanly by hand, wrap it into something an agent can repeat. [Building a Claude skill for Shager](/en/blog/build-a-claude-skill-for-shager) shows how to package these calls and the rules around them into a reusable skill.

---
Source: https://shager.ai/en/blog/shager-rest-api-quickstart
