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:
- Cursor pagination. List responses are
{ data, nextCursor, hasMore }. Pass an opaquecursor(from a priornextCursor) plus an optionallimit(default 25, max 100; messaging lists on SideShift’s own messaging clamp to 50). WhenhasMoreisfalse,nextCursorisnull. - Idempotency. Every
POST/PATCH/PUTaccepts anIdempotency-Keyheader. Replaying the same key + body returns the original response; the same key with a different body is rejected with409 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 return401/402/403/429; writes add400/404/409. - Each request has one effective tenant. It defaults to the connected company;
an agency-parent OAuth grant can select one consented direct child with
X-Act-As-Company. There is no cross-tenant union, and unknown query parameters such ascompanyIdorclientare not general tenant selectors.
Dsc8SfHtPjzNGDKzMqBP, campaign
prog_abc123, contract ctr_abc123, creator user_xyz, invoice inv_abc123.
Conversation and message ids on SideShift’s own messaging are bare UUIDs, such as
6f1d2c3b-8a4e-4b9c-9d0e-1f2a3b4c5d6e. Post ids are the platform’s own and are not
prefixed, so they appear as bare numbers such as 7661286873087642894.
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:
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
Theid 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.
Not every application carries a handle. Campaign-manager campaigns never
collect one. Paid-ads campaigns collect one only when the campaign needs ad
authorization for that platform - otherwise a paid-ads acceptance is
handle-free too.
A handle-free acceptance lands in the same queue as a campaign-join
request: isJoinRequest is true, handle is an empty string, and
platform is null. Branch on isJoinRequest before rendering handle or
platform copy. Approving a join request approves the membership itself; no
handle is added to the campaign.
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 sends the creator a direct message + signed PDF + notifications (sensitive), so use a fresh
Idempotency-Key.
Approval is also where your plan’s creator seat cap is enforced. If approving
would exceed the cap, the call returns 402 with code: subscription_required,
the application stays pending, and nothing is written:
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.
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
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.importwrites the validated batch. It requires the exact currentconfirmCampaignName.
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:
*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
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.
If the invoice allows BNPL rails (klarna, affirm, afterpay_clearpay,
sezzle, zip, card installments, and more), the hosted invoice page offers a
Pay now / Pay over time picker; BNPL is processed by Whop. The payer total
is unchanged, but the merchant’s net proceeds are reduced by a 16% merchant
fee when the payer pays over time. BNPL options appear only when the provider
is enabled, supports the invoice currency, and the invoice total is within the
provider’s supported amount range. Renewal invoices do not offer BNPL.
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.
Payment links
SetinvoiceType to payment_link in the create body to get a shareable
checkout link instead of an emailed invoice. Omit the customer fields - the
payer enters their name and email at checkout. Reusable links mint a child
invoice for each payment. Sending email on a payment link returns 409.
Void - POST /invoices/{id}/void
Voids an unpaid invoice (cancels the Whop plan + any in-flight Stripe wire or
BNPL PaymentIntent, best-effort). Idempotent - voiding an already-void invoice
returns its current state; voiding a paid invoice is 409 already_paid.
If a Stripe payment already completed, void fails with 409
payment_not_cancelable - refresh the invoice before voiding.
List - GET /invoices
Cursor-paginated; optional status (all/open/pending/partial_paid/paid/
void/overdue/refunded/partially_refunded) and customerEmail filters.
7. Messages
Read the company’s inbox and send messages as the company. Scopes:messages:read to list and read, messages:write to send and manage.
SideShift moved chat from a connected provider to its own messaging, one company at a
time. A company moves the first time a person opens the app; the API never starts the
move. Three states matter to an integration:
- Not started. The company keeps the previous behavior: provider-backed
conversations,
feed_…support-channel ids, and the older conversation ids. - Moving. Every messaging request answers
409 conflictwithmeta.reason: "messaging_migrating"andmeta.retryAfterSeconds: 60. Nothing is sent. The move takes minutes; retry after the delay. - Moved. Every conversation is a native room. Ids are bare UUIDs, rows carry
chatType: "native", messages carrysource: "native", and writes answerlane: "native". The recipes below show this state.
feed_… id or an older conversation id resolves
to the same room. A room that stayed on the provider answers 404 not_found with
meta.reason: "conversation_left_on_provider". A machine app (client credentials, no
acting person) acts as the company on every messaging route.
The product calls the three kinds “Support chat”, “Direct message” and “Group chat”. The
API keeps support channel as the wire name for a support chat.
Two budgets apply on top of the resource rate limit:
60 message sends and 10 conversation creations per minute, per credential and company.
A refused write answers 429 rate_limited with Retry-After and
meta.retryAfterSeconds. The full list of typed refusals is in
Messaging refusals.
The response while a company is moving:
Start at the inbox - GET /inbox
The merged inbox: support channels and DM conversations by last-message time, the same
list a person sees. Each item’s type names the follow-up read and send:
support_channel → /support-channels/{id}/messages, dm →
/conversations/{id}/messages. It is a snapshot: limit is at most 50, there is no
cursor, and hasMore is always false. Page deeper with the two lists below.
lastMessageBy is the sender’s display name, and unreadCount is the
company’s own count on every item.
Support channels - GET /support-channels and POST /support-channels/{id}/messages
A support channel is the main employer-creator conversation: applicant chats, campaign
invites, payout receipts and creator support all land here. On a company that has moved,
open is ignored (a native room is never resolved), resolvedAt is always null, and
limit is clamped to 50. nextCursor is an opaque string: pass it back as cursor.
GET /support-channels/{id}/messages; the items have the message
shape shown under conversations below. Send into the channel with an Idempotency-Key,
and reuse the same key if the call times out:
201 Created:
List conversations - GET /conversations
Every room the company belongs to, support rooms included on a company that has moved.
Cursor-paginated, limit clamped to 50. channelId on a native room is the former
provider channel id when the room was moved from one, else null.
Read a conversation’s messages - GET /conversations/{id}/messages
Newest first, both directions, limit clamped to 50. A conversation the company does
not belong to, or one that does not exist, is 404 not_found. A 502 upstream_error
means the read failed mid-page: retry with the same cursor.
senderUserId is the sender’s SideShift user id (the company id for the company’s own
messages). The items also carry sender and timestamp, older names for senderName
and createdAt.
Send a message - POST /messages
Sandbox-rejected - this sends a real message, so a sandbox grant returns
403 forbidden. Provide exactly one of conversationId (post into an existing room) or
creatorId, plus content. creatorId resolves the pair’s conversation - a native room
on a company that has moved - and creates it when none exists; a creator the company
cannot message answers 404 not_found with meta.reason. attachment_ids are provider
file ids: a native room refuses them with 400 invalid_request
(attachments_unsupported_on_native_conversation) and sends nothing - resend without
them.
201 Created:
message_id (react,
forward) accepts a native:-prefixed id too.
Manage a conversation
The other writes, and what a native room answers:POST /conversations spends the creation budget (10 per minute); the sends and the
forward spend the send budget (60 per minute).
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.