> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sideshift.app/llms.txt
> Use this file to discover all available pages before exploring further.

# API workflows

> Worked end-to-end recipes for the most common SideShift Platform API integrations.

Each recipe below states its goal and the scopes it needs, then shows a `curl` call and
the response shape straight from the [API reference](/platform).

They all assume you already hold an access token. See the
[OAuth quickstart](/quickstart) for registration, the PKCE authorization-code flow, and
the token exchange. Every request goes to the production base URL and carries the bearer
token:

```
Base URL:  https://app.sideshift.app/api/oauth/v1
Header:    Authorization: Bearer <access_token>
```

A few conventions used throughout (see
[Errors and rate limits](/platform/errors) for the full rules):

* **Cursor pagination.** List responses are `{ data, nextCursor, hasMore }`. Pass an
  opaque `cursor` (from a prior `nextCursor`) plus an optional `limit` (default 25,
  max 100). When `hasMore` is `false`, `nextCursor` is `null`.
* **Idempotency.** Every `POST`/`PATCH`/`PUT` accepts an `Idempotency-Key` header.
  Replaying the same key + body returns the original response; the same key with a
  **different** body is rejected with `409 idempotency_conflict`. Sensitive
  money-moving writes (`/payouts/execute`, `/payouts/quick-pay`) **require** it.
* **Errors.** Every error is `{ error: { code, message, requestId } }`. Lists and
  reads can return `401`/`402`/`403`/`429`; writes add `400`/`404`/`409`.
* The token is bound to one company tenant - there is no cross-tenant `?scope=`
  union, and a resource owned by another tenant is reported as `404`.

Realistic placeholder ids used below: company `Dsc8SfHtPjzNGDKzMqBP`, campaign
`prog_abc123`, contract `ctr_abc123`, creator `user_xyz`, post `post_abc123`,
invoice `inv_abc123`, conversation `conv_abc123`, webhook `whk_abc123`.

***

## 1. Create a complete campaign and update its payment structure

**Goal:** create a usable campaign in one call, update its CPM payment structure, then read it back.
**Scopes:** `campaigns:write` to create/update, `campaigns:read` to read.

### Create the campaign - `POST /campaigns`

The campaign must include complete payout/tracking terms. Tracking-only campaigns can omit payout
amounts and expected-post targets, but must set `analyticsOnly: true` and name at least one platform.
Omitted status defaults to `active`, so the campaign immediately appears in analytics filters; pass
`"status": "draft"` explicitly to keep it unpublished.

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/campaigns \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Spring TikTok Push",
    "paymentStructure": { "type": "fixed", "analyticsOnly": true },
    "requirements": { "platforms": ["tiktok"] }
  }'
```

`201 Created` returns the new id:

```json theme={"system"}
{ "data": { "id": "prog_abc123" } }
```

### Set the payment structure - `PUT /campaigns/{id}/payment-structure`

Replace the structure. The body wraps a `paymentStructure` object (forwarded to the
campaign update service verbatim - `cpmRate >= 0` normalization and
`crosspostMaturityDays` validation are reused from the canonical service).

```bash theme={"system"}
curl -X PUT https://app.sideshift.app/api/oauth/v1/campaigns/prog_abc123/payment-structure \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "paymentStructure": { "type": "cpm", "cpmRate": 5, "paymentBasis": "views" } }'
```

`200 OK`:

```json theme={"system"}
{ "data": { "id": "prog_abc123" } }
```

Read it back with `GET /campaigns/{id}/payment-structure`:

```bash theme={"system"}
curl https://app.sideshift.app/api/oauth/v1/campaigns/prog_abc123/payment-structure \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={"system"}
{ "data": { "type": "cpm", "cpmRate": 5, "paymentBasis": "views", "crosspostMaturityDays": 7 } }
```

### Read the whole campaign - `GET /campaigns/{id}`

```bash theme={"system"}
curl https://app.sideshift.app/api/oauth/v1/campaigns/prog_abc123 \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={"system"}
{
  "data": {
    "id": "prog_abc123",
    "name": "Spring TikTok Push",
    "description": "Q2 creator push on TikTok",
    "emoji": "🚀",
    "campaignType": "ugc",
    "experienceMode": "standard",
    "paymentStructure": { "type": "cpm", "cpmRate": 5, "paymentBasis": "views" }
  }
}
```

<Note>
  `GET /campaigns` lists campaigns (paged, with optional `status` and `search` filters).
  `PATCH /campaigns/{id}` updates fields, and there are `POST /campaigns/{id}/archive`
  and `POST /campaigns/{id}/duplicate` lifecycle actions, all `campaigns:write`.
</Note>

### Optional next step - post the campaign to the marketplace

The `id` returned above is the campaign's `programId`. The final, opt-in step of
the create-campaign flow - mirroring the dashboard's **"Post & Get Applications"**
button - is to post the campaign to the Creator Marketplace so creators can discover
it and apply. Pass that `programId` to `POST /jobs` (with marketplace targeting and a
cover image); see [§8 Post an existing campaign to the marketplace](#8-post-an-existing-campaign-to-the-marketplace).
Skip it if you'd rather bring creators on directly via invites or contract offers
([§3](#3-invite-and-contract-a-creator)).

***

## 2. List and process applications

A campaign **application** is a creator's handle request against one of your
campaigns. **The platform exposes approve and reject only - there is no "shortlist"
action.**
**Scopes:** `applications:read` to list/get, `applications:write` to review.

### List applications - `GET /applications`

Cursor-paginated, sorted pending → approved → rejected.

```bash theme={"system"}
curl "https://app.sideshift.app/api/oauth/v1/applications?limit=25" \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={"system"}
{
  "data": [
    {
      "id": "prog_abc123_r1",
      "programId": "prog_abc123",
      "requestId": "r1",
      "handle": "@janecreator",
      "platform": "tiktok",
      "status": "pending",
      "creator": { "id": "user_xyz", "name": "Jane Creator" }
    }
  ],
  "nextCursor": "eyJpZCI6InByb2dfYWJjMTIzX3IxIn0=",
  "hasMore": false
}
```

<Note>
  **Application id is composite.** The id is `{programId}_{requestId}` (here
  `prog_abc123_r1`) - that exact string is what you pass to the get and status
  endpoints. Don't try to reconstruct it from parts.
</Note>

### Get one application - `GET /applications/{id}`

```bash theme={"system"}
curl https://app.sideshift.app/api/oauth/v1/applications/prog_abc123_r1 \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={"system"}
{
  "data": {
    "id": "prog_abc123_r1",
    "programId": "prog_abc123",
    "requestId": "r1",
    "handle": "@janecreator",
    "platform": "tiktok",
    "status": "pending",
    "creator": { "id": "user_xyz", "name": "Jane Creator" }
  }
}
```

### Approve - `POST /applications/{id}/status`

`action` is `approve` or `reject` only. Approving auto-accepts the program contract
and fires a Whop DM + signed PDF + notifications (sensitive), so use a fresh
`Idempotency-Key`.

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/applications/prog_abc123_r1/status \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "action": "approve" }'
```

`200 OK` - on approve you get the auto-created contract id and its status:

```json theme={"system"}
{
  "data": {
    "requestId": "r1",
    "creatorId": "user_x",
    "programId": "prog_abc123",
    "contractId": "ctr_1",
    "status": "approved",
    "contractStatus": "active"
  }
}
```

### Reject - same endpoint, `action: "reject"`

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/applications/prog_abc123_r2/status \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "action": "reject" }'
```

```json theme={"system"}
{ "data": { "requestId": "r2", "programId": "prog_abc123", "contractId": null, "status": "rejected" } }
```

***

## 3. Invite and contract a creator

Two ways to bring a creator onto a campaign: a shareable invite **link** (anyone
with it can join), or a direct **contract offer** to a specific creator.

### Create a campaign invite link - `POST /campaigns/{id}/invites`

This is the product's mechanism for inviting creators to a campaign. The body is
optional; you can cap uses and expiry. Returns a `token` + shareable `link`.
**Scope:** `creators:write`.

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/campaigns/prog_abc123/invites \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "expiresInDays": 14, "maxUses": 25, "label": "Spring outreach" }'
```

`201 Created`:

```json theme={"system"}
{ "data": { "token": "a1b2c3", "link": "https://app.sideshift.app/program-invite/a1b2c3" } }
```

<Note>
  There is also a tenant-wide invite surface - `GET /invites` (list, `creators:read`),
  `POST /invites` (create with `programId` in the body, `creators:write`), and
  `DELETE /invites/{id}` (revoke by token, `creators:write`). See workflow note
  below. Team-member invites are **not** on this surface yet - a non-`campaign`
  invite type is rejected with `400`.
</Note>

### Offer a contract - `POST /contracts`

The UI-equivalent of inviting a specific creator to a campaign. `companyId` is always
the token's tenant (never client-supplied); `programId` must belong to it. Required:
`programId`, `contractorId`, `contractorName`. Fires the invite notification +
contract PDF (sensitive).
**Scope:** `contracts:write`.

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/contracts \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "programId": "prog_abc123", "contractorId": "user_xyz", "contractorName": "Jane Creator" }'
```

`201 Created`:

```json theme={"system"}
{ "data": { "id": "ctr_new123", "programId": "prog_abc123" } }
```

### List contracts - `GET /contracts`

Cursor-paginated, with optional `status`, `programId`, `creatorId` filters.
**Scope:** `contracts:read`.

```bash theme={"system"}
curl "https://app.sideshift.app/api/oauth/v1/contracts?programId=prog_abc123&limit=25" \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={"system"}
{
  "data": [
    { "id": "ctr_abc123", "companyId": "Dsc8SfHtPjzNGDKzMqBP", "contractorId": "user_xyz", "programId": "prog_abc123", "status": "active" },
    { "id": "ctr_def456", "companyId": "Dsc8SfHtPjzNGDKzMqBP", "contractorId": "user_qrs", "programId": "prog_abc123", "status": "pending" }
  ],
  "nextCursor": "eyJpZCI6ImN0cl9kZWY0NTYifQ==",
  "hasMore": false
}
```

`GET /contracts/{id}` returns a single contract in the same shape.

### Update one creator's contract - `PATCH /contracts/{id}`

Use the targeted update when one creator's base retainer or posting platforms
change. **Scope:** `contracts:write`.

Only send the values you intend to change. The API resolves the contract's
effective campaign + per-creator terms and preserves every unmentioned payment
field and requirement. `platforms` is the creator's exact replacement set, not
a union. The campaign must already use per-creator payment terms; a
campaign-level contract returns `409 conflict` with guidance to update the
campaign instead.

```bash theme={"system"}
curl -X PATCH https://app.sideshift.app/api/oauth/v1/contracts/ctr_abc123 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "baseRetainer": 1250,
    "platforms": ["tiktok", "instagram"]
  }'
```

A real edit uses the dashboard's contract-change flow: it validates the
resulting terms, appends history, regenerates the PDF, re-opens an active
contract for creator review, and sends the creator a review-link DM.

```json theme={"system"}
{
  "data": {
    "success": true,
    "changed": true,
    "contractId": "ctr_abc123",
    "updatedFields": ["baseRetainer", "platforms"],
    "previous": {
      "baseRetainer": 1000,
      "platforms": ["tiktok"]
    },
    "current": {
      "baseRetainer": 1250,
      "platforms": ["tiktok", "instagram"]
    },
    "pdfRegenerated": true,
    "pendingCreatorContractUpdate": true
  }
}
```

Repeating an already-current value is a no-op: `changed` is `false`, and the
API does not regenerate the PDF, append history, or notify the creator. Sandbox
grants cannot call this endpoint because a real edit sends an external DM.

### Cancel a contract - `POST /contracts/{id}/cancel`

**Scope:** `contracts:write`.

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/contracts/ctr_abc123/cancel \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)"
```

```json theme={"system"}
{ "data": { "id": "ctr_abc123", "status": "cancelled" } }
```

***

## 4. Add ghost handles and videos to a campaign

**Goal:** use the normal SideShift campaign flow to add tracked handles or videos,
one at a time or in bulk. **Scope:** `campaigns:write`.

The campaign id lives in the URL and the company tenant comes from the OAuth token.
The bulk endpoints accept structured JSON, so API clients do not need to construct
the CSV used by the dashboard uploader.

### Add one handle - `POST /campaigns/{id}/ghost-handles`

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/campaigns/prog_abc123/ghost-handles \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "platform": "tiktok",
    "handle": "@janecreator",
    "description": "Track this account"
  }'
```

Use optional `creatorId` to attach the ghost handle to an existing creator. Supported
handle platforms are `tiktok`, `instagram`, `youtube`, `snapchat`, `facebook`,
`twitter`, and `x`.

### Bulk add handles - `POST /campaigns/{id}/ghost-handles/bulk`

One request accepts 1–500 handles. Existing platform + handle pairs are skipped and
reported in the response.

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/campaigns/prog_abc123/ghost-handles/bulk \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "handles": [
      { "platform": "tiktok", "handle": "@janecreator", "name": "Jane Creator" },
      { "platform": "instagram", "handle": "brandclips" }
    ]
  }'
```

```json theme={"system"}
{
  "data": {
    "programId": "prog_abc123",
    "summary": { "total": 2, "created": 1, "skipped": 1, "errors": 0 },
    "results": [
      {
        "platform": "tiktok",
        "handle": "janecreator",
        "status": "created",
        "contractId": "ctr_abc123"
      },
      {
        "platform": "instagram",
        "handle": "brandclips",
        "status": "skipped",
        "reason": "Handle already exists in program"
      }
    ]
  }
}
```

### Add one video - `POST /campaigns/{id}/ghost-videos`

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/campaigns/prog_abc123/ghost-videos \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "platform": "youtube",
    "videoUrl": "https://www.youtube.com/watch?v=abc123",
    "creatorId": "creator_abc123"
  }'
```

`creatorId` and `description` are optional. Supported video platforms are `tiktok`,
`instagram`, `youtube`, and `snapchat`.

### Bulk add videos - `POST /campaigns/{id}/ghost-videos/bulk`

One request accepts 1–500 videos. `platform` is optional when SideShift can detect it
from the URL; existing URLs in the campaign are skipped.

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/campaigns/prog_abc123/ghost-videos/bulk \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "videos": [
      {
        "ghostName": "Jane Creator",
        "platform": "tiktok",
        "videoUrl": "https://www.tiktok.com/@jane/video/123"
      },
      {
        "ghostName": "Brand Clip",
        "videoUrl": "https://www.instagram.com/reel/abc123/"
      }
    ]
  }'
```

### Backfill observed daily history - `POST /campaigns/{id}/analytics-history`

Use this when migrating from another analytics provider. Send cumulative observations (never
estimated/interpolated values) captured before SideShift's first native snapshot. The endpoint
resolves each row within the token-bound company and campaign, derives daily deltas, re-bases the
first native snapshot, and verifies Firestore plus Postgres before an import succeeds.

The same endpoint has two modes:

* `validate` (default) runs identity resolution and the complete splice plan without writes.
* `import` writes the validated batch. It requires the exact current `confirmCampaignName`.

Every request requires an 8–200 character `Idempotency-Key`. Reuse a key only to retry the exact
same body. A batch supports 1–2,000 rows and at most 500 unique posts. Partition larger exports by
post; never split one post's history across requests. Each included post must carry its complete
imported history so an earlier newly supplied date can safely recalculate successor deltas.

Validate first:

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/campaigns/prog_abc123/analytics-history \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: hardlaunch-rezi-batch-01-validate" \
  -d '{
    "mode": "validate",
    "sourceId": "hardlaunch-rezi-2026-08-01",
    "unmatchedPostPolicy": "error",
    "rows": [
      {
        "postId": "7661286873087642894",
        "date": "2026-07-11",
        "cumulative": {
          "views": 374,
          "likes": 2,
          "comments": 0,
          "shares": 0,
          "bookmarks": 0
        }
      }
    ]
  }'
```

`postId` or `postUrl` is required; supplying both is allowed. Instagram shortcodes from provider
exports are resolved through the tracked URL. All five cumulative metrics are required because the
Firestore and Postgres history rows share that complete observation contract. Use `0` only when the
provider observed zero; do not substitute zero for an unavailable metric. Dates are UTC
`YYYY-MM-DD`. Cumulative declines are preserved as real negative deltas and returned as warnings.

When validation returns `ready: true`, submit the same rows in import mode with a fresh key:

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/campaigns/prog_abc123/analytics-history \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: hardlaunch-rezi-batch-01-import" \
  -d '{
    "mode": "import",
    "sourceId": "hardlaunch-rezi-2026-08-01",
    "confirmCampaignName": "Rezi Automations",
    "unmatchedPostPolicy": "error",
    "rows": [
      {
        "postId": "7661286873087642894",
        "date": "2026-07-11",
        "cumulative": { "views": 374, "likes": 2, "comments": 0, "shares": 0, "bookmarks": 0 }
      }
    ]
  }'
```

The response includes input/resolution counts, planned history and boundary writes, blockers,
warnings, and (after import) exact Firestore/Postgres verification counts. Details are capped at 100
entries with companion `*Truncated` flags; summary counts always describe the full batch.

Unmatched rows block by default. After investigating a known unmatched subset, set
`unmatchedPostPolicy: "skip"`; an import must also provide `expectedUnmatchedPostCount` equal to the
validated batch's exact `summary.unmatchedPosts`. This prevents a changed batch from silently
skipping more posts. Sandbox grants can validate, but cannot import.

### Read analytics for tracked content

```bash theme={"system"}
curl "https://app.sideshift.app/api/oauth/v1/analytics/accounts?program=prog_abc123&limit=50" \
  -H "Authorization: Bearer $TOKEN"
```

With `analytics:read`, the response groups tracked posts by creator handle + platform
and includes per-account post, view, and engagement totals plus a cross-account
summary. The other aggregate reads are `GET /analytics/overview`, `/analytics/kpis`,
`/analytics/time-series`, and `/analytics/videos`.

***

## 5. Payout read flows

Read your wallet ledger, pending amounts, and balances.
**Scope:** `payouts:read`.

### Payout history - `GET /payouts`

Wallet ledger entries (payouts + deposits), cursor-paginated, with an aggregate
`summary`. Optional filters: `type` (`debit`/`credit`), `creatorId`, `contractId`,
`status`, `fromDate`, `toDate`, `reason`.

```bash theme={"system"}
curl "https://app.sideshift.app/api/oauth/v1/payouts?type=debit&limit=25" \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={"system"}
{
  "data": [
    {
      "id": "led_abc123",
      "amount": 625,
      "type": "debit",
      "status": "completed",
      "description": "Payout for ctr_abc123",
      "contractId": "ctr_abc123",
      "creatorId": "user_xyz",
      "programId": "prog_abc123",
      "createdAt": "2026-06-17T18:00:00Z"
    }
  ],
  "summary": { "totalDebits": 625, "totalCredits": 5000, "net": 4375 },
  "nextCursor": "eyJpZCI6ImxlZF9kZWY0NTYifQ==",
  "hasMore": false
}
```

### Pending payouts - `GET /payouts/pending`

Calculated pending/overdue amounts for active/expired contracts. Optional
`programId`, `creatorId`, `minAmount`, `sortBy` (`amount`/`dueDate`/`creator`),
`sortOrder`.

```bash theme={"system"}
curl "https://app.sideshift.app/api/oauth/v1/payouts/pending?sortBy=amount&sortOrder=desc" \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={"system"}
{
  "data": [
    {
      "id": "ctr_abc123",
      "contractId": "ctr_abc123",
      "contractName": "Jane Creator — Spring TikTok Push",
      "amount": 625,
      "status": "due",
      "dueDate": "2026-06-20T00:00:00Z",
      "creatorId": "user_xyz",
      "creatorName": "Jane Creator",
      "programId": "prog_abc123"
    }
  ],
  "summary": { "totalPending": 625, "count": 1 },
  "nextCursor": null,
  "hasMore": false
}
```

### Wallet + payout stats - `GET /payouts/stats`

```bash theme={"system"}
curl https://app.sideshift.app/api/oauth/v1/payouts/stats \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={"system"}
{
  "data": {
    "availableBalance": 4375,
    "pendingBalance": 625,
    "totalOwed": 625,
    "lifetimeDeposits": 5000,
    "lifetimePaidOut": 625,
    "currency": "USD"
  }
}
```

<Note>
  **Money-moving writes exist but are out of scope for casual use.**
  `POST /payouts/execute` (run/dry-run contract payouts) and `POST /payouts/quick-pay`
  (pay anyone by email) require `payouts:write`. They are **sensitive**: the
  `Idempotency-Key` header is **required** (a `400` without it), and **sandbox /
  test-mode grants are rejected** with `403 forbidden`. Read the idempotency and
  sandbox rules in [Errors and rate limits](/platform/errors) before calling
  them.
</Note>

***

## 6. Invoice lifecycle

Create, inspect, (re)send, and void an invoice.
**Scopes:** `invoices:write` to create/send/void, `invoices:read` to list/get.

### Create + send - `POST /invoices`

Creates and emails an invoice. **Sandbox-rejected** (fires real Stripe/Whop/Resend
side effects) - a sandbox grant returns `403 forbidden`. Amounts are in cents.

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/invoices \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "customerEmail": "client@example.com", "lineItems": [{ "description": "Consulting", "amountCents": 50000 }] }'
```

`201 Created` (the created invoice plus a top-level `url`):

```json theme={"system"}
{
  "data": {
    "id": "inv_abc123",
    "invoiceNumber": "INV-1001",
    "status": "open",
    "customerEmail": "client@example.com",
    "currency": "usd",
    "totalCents": 50000,
    "hostedInvoiceUrl": "https://app.sideshift.app/invoice/inv_abc123",
    "pdfUrl": "https://app.sideshift.app/invoice/inv_abc123.pdf",
    "createdAt": "2026-06-17T18:00:00Z"
  },
  "url": "https://app.sideshift.app/invoice/inv_abc123"
}
```

### Get one - `GET /invoices/{id}`

```bash theme={"system"}
curl https://app.sideshift.app/api/oauth/v1/invoices/inv_abc123 \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={"system"}
{
  "data": {
    "id": "inv_abc123",
    "invoiceNumber": "INV-1001",
    "status": "open",
    "customerEmail": "client@example.com",
    "currency": "usd",
    "totalCents": 50000,
    "hostedInvoiceUrl": "https://app.sideshift.app/invoice/inv_abc123",
    "pdfUrl": "https://app.sideshift.app/invoice/inv_abc123.pdf",
    "createdAt": "2026-06-17T18:00:00Z"
  }
}
```

### (Re)send the email - `POST /invoices/{id}/send`

Sends a real email via Resend (sandbox-rejected). Returns the refreshed invoice.

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/invoices/inv_abc123/send \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)"
```

```json theme={"system"}
{ "data": { "id": "inv_abc123", "invoiceNumber": "INV-1001", "status": "open", "currency": "usd", "totalCents": 50000 } }
```

### Void - `POST /invoices/{id}/void`

Voids an unpaid invoice (cancels the Whop plan + any in-flight Stripe wire,
best-effort). Idempotent - voiding an already-void invoice returns its current state;
voiding a **paid** invoice is `409 conflict`.

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/invoices/inv_abc123/void \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)"
```

```json theme={"system"}
{ "data": { "id": "inv_abc123", "invoiceNumber": "INV-1001", "status": "void", "currency": "usd", "totalCents": 50000 } }
```

### List - `GET /invoices`

Cursor-paginated; optional `status` (`all`/`open`/`pending`/`partial_paid`/`paid`/
`void`/`overdue`/`refunded`/`partially_refunded`) and `customerEmail` filters.

```bash theme={"system"}
curl "https://app.sideshift.app/api/oauth/v1/invoices?status=open&limit=25" \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={"system"}
{
  "data": [
    {
      "id": "inv_abc123",
      "invoiceNumber": "INV-1001",
      "status": "open",
      "customerEmail": "client@example.com",
      "currency": "usd",
      "totalCents": 50000,
      "hostedInvoiceUrl": "https://app.sideshift.app/invoice/inv_abc123",
      "pdfUrl": "https://app.sideshift.app/invoice/inv_abc123.pdf",
      "createdAt": "2026-06-17T18:00:00Z"
    }
  ],
  "nextCursor": "eyJpZCI6Imludl9hYmMxMjMifQ==",
  "hasMore": false
}
```

***

## 7. Messages

Read DM conversations and send messages as the company.
**Scopes:** `messages:read` to list, `messages:write` to send.

### List conversations - `GET /conversations`

Cursor-paginated. Read-only.

```bash theme={"system"}
curl "https://app.sideshift.app/api/oauth/v1/conversations?limit=25" \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={"system"}
{
  "data": [
    {
      "id": "conv_abc123",
      "lastMessage": "Thanks, sending the brief now!",
      "lastMessageBy": "user_xyz",
      "members": ["Dsc8SfHtPjzNGDKzMqBP", "user_xyz"],
      "groupName": null,
      "isProgramGroupChat": false,
      "timestamp": "2026-06-17T18:05:00Z",
      "isBookmarked": false
    }
  ],
  "nextCursor": "eyJpZCI6ImNvbnZfYWJjMTIzIn0=",
  "hasMore": false
}
```

### Read a conversation's messages - `GET /conversations/{id}/messages`

Cursor-paginated. A conversation the tenant does not belong to is reported as `404`.

```bash theme={"system"}
curl "https://app.sideshift.app/api/oauth/v1/conversations/conv_abc123/messages?limit=25" \
  -H "Authorization: Bearer $TOKEN"
```

```json theme={"system"}
{
  "data": [
    { "id": "msg_abc123", "sender": "Dsc8SfHtPjzNGDKzMqBP", "content": "Hi! Following up on the campaign.", "timestamp": "2026-06-17T18:00:00Z" },
    { "id": "msg_def456", "sender": "user_xyz", "content": "Thanks, sending the brief now!", "timestamp": "2026-06-17T18:05:00Z" }
  ],
  "nextCursor": "eyJpZCI6Im1zZ19kZWY0NTYifQ==",
  "hasMore": false
}
```

### Send a message - `POST /messages`

**Sandbox-rejected** - this fires a **real** Whop DM (best-effort) and persists to
Firestore, so a sandbox grant returns `403 forbidden`. Provide exactly one of
`conversationId` (post into an existing thread) or `creatorId` (start/continue a
creator DM), plus `content`.

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/messages \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "creatorId": "user_creator123", "content": "Hi! Following up on the campaign." }'
```

`201 Created`:

```json theme={"system"}
{
  "data": {
    "messageId": "msg_new789",
    "conversationId": "conv_abc123",
    "whopMessageId": "whopmsg_123",
    "timestamp": "2026-06-18T12:00:00Z"
  }
}
```

***

## 8. Post an existing campaign to the marketplace

**Goal:** post a campaign to the marketplace so creators can discover it and apply -
the same result as the dashboard's **"Post & Get Applications"** button. This is the
opt-in final step of the create-campaign flow, whether the campaign is one you just
created in [§1](#1-create-a-campaign-and-set-its-payment-structure) or an existing one.
**Scope:** `jobs:write`.

A "campaign posted to the marketplace" is a job linked to the campaign. Create a
job with `programId` set to the campaign id: the job appears in the public feed and
its applications flow back to the campaign. (Creating a job spends 1 posting credit
and requires a cover image - pass a hosted `imageUrl` or an inline `imageDataUrl`.)

End to end, the flow is: **create a campaign** ([§1](#1-create-a-campaign-and-set-its-payment-structure))
→ **(optional) post it to the marketplace with targeting** (below) → **applications
come in** ([§2](#2-list-and-process-applications)).

### Post the campaign - `POST /jobs`

```bash theme={"system"}
curl -X POST https://app.sideshift.app/api/oauth/v1/jobs \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "title": "Spring TikTok Push — creators wanted",
    "description": "Post short-form TikToks for our spring push.",
    "jobType": "Content Creator",
    "creatorType": "ugc_ads",
    "paymentType": "performance",
    "cpmRate": "5",
    "imageUrl": "https://cdn.example.com/spring-push.png",
    "platformFocus": ["TikTok"],
    "ageRequirement": "18+",
    "contentType": "UGC",
    "accountType": "personal_account",
    "programId": "prog_abc123"
  }'
```

`201 Created` returns the new job id - the campaign is now discoverable and
accepting applications:

```json theme={"system"}
{ "data": { "jobId": "job_abc123", "programId": "prog_abc123", "imageUrl": "https://cdn.example.com/spring-push.png" } }
```

<Note>
  Use `programIds` instead of `programId` to link the job to several campaigns at
  once. Applications land on the linked campaign(s) - process them with the
  [applications flow](#2-list-and-process-applications). To post to multiple
  campaigns or pull the linked job later, `GET /jobs` lists the tenant's jobs.

  The MCP equivalent is the `create_job` tool with `programId` set - same single
  mechanism, same "Post & Get Applications" result.
</Note>
