Skip to main content
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. They all assume you already hold an access token. See the OAuth 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:
A few conventions used throughout (see Errors and rate limits 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.
201 Created returns the new id:

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).
200 OK:
Read it back with GET /campaigns/{id}/payment-structure:

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

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.

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. Skip it if you’d rather bring creators on directly via invites or contract offers (§3).

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

Get one application - GET /applications/{id}

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.
200 OK - on approve you get the auto-created contract id and its status:

Reject - same endpoint, action: "reject"


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.
201 Created:
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.

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.
201 Created:

List contracts - GET /contracts

Cursor-paginated, with optional status, programId, creatorId filters. Scope: contracts:read.
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.
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.
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.

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

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.

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

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.

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

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.

Pending payouts - GET /payouts/pending

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

Wallet + payout stats - GET /payouts/stats

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 before calling them.

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.
201 Created (the created invoice plus a top-level url):

Get one - GET /invoices/{id}

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

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

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.

List - GET /invoices

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

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.

Read a conversation’s messages - GET /conversations/{id}/messages

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

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.
201 Created:

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 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) → (optional) post it to the marketplace with targeting (below) → applications come in (§2).

Post the campaign - POST /jobs

201 Created returns the new job id - the campaign is now discoverable and accepting applications:
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. 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.