Create Campaign on the Campaigns page offers Build it with Copilot, and the prompt library has Create a campaign and Tune a campaign's payouts. The Copilot asks what is missing, shows the rules as sentences, and only writes after you approve — over these exact routes.
Campaigns via the API
Create and edit creator campaigns from your own tools or an agent, preview every payout rule as a sentence, and assign creators.
Campaigns can be created and edited through the API with the same vocabulary the campaign sheet uses: a cadence, a payout window, a video target, and the payouts — a fixed salary, a base payout per video, CPM tiers, flat bonuses, a performance cap and the eligibility window. You send that campaign spec; viral.app turns it into payout rules once, on the server, exactly as the sheet does. Whatever the sheet cannot express, the API cannot write either, so an agent can never set up a campaign your team could not have built by hand.
Every response describes the campaign back in plain sentences, one per rule-set, in the same wording the campaign sheet shows. A dry-run returns those sentences before anything is written, together with a hash that pins the write to what was reviewed. The API reference has every field and API and automation covers keys and access; this page explains how the pieces fit, and it is written so you can hand it to an agent as is.
Before you start
- Base URL and auth: every route below lives under
https://viral.app/api/v1and takes thex-api-keyheader. Bodies and responses are JSON. - Who can do what: creating, editing, ending and reactivating campaigns and assigning creators need a key of an owner, admin or member. Reads, the dry-run and both preflights also work for a viewer's key. Writes land in the workspace's activity log under the key's owner.
- Ids carry their type:
orgcamp_campaigns,orgcre_creators,orgbrief_briefs,orgjob_job listings. Dates are calendar days asYYYY-MM-DD. - Amounts are major units of the campaign currency:
10is 10.00,1.2is 1.20 per 1,000 views.
curl https://viral.app/api/v1/campaigns/orgcamp_RwmraAffhMt9 \
-H "x-api-key: $VIRAL_APP_API_KEY"
The write loop
Every change to a campaign follows the same four steps. Skipping the dry-run is possible, but then nothing pins the write to what a person reviewed.
- Read what exists.
GET /campaignsfor the current campaigns (theirspecis the starting point for an edit),GET /creatorsfor the creators you will assign. - Dry-run the spec with
POST /campaigns/validate. It returns the normalized spec, one sentence per rule-set, warnings, and thepreviewHash. Ifokisfalse, fix each entry inissuesat itspathand run it again. - Show the sentences to whoever approves. They are the same sentences the campaign sheet shows.
- Write with
POST /campaignsorPUT /campaigns/{id}, sending the same spec and thepreviewHash. Then assign creators through the preflight and assign routes.
Read a campaign
GET /api/v1/campaigns/{id} returns the campaign row plus:
GET /api/v1/campaigns lists campaigns with spec, headline and compensation (the min/max ranges the campaigns table shows) on every row. Without a statuses filter it returns the current campaigns only — upcoming, active and closing; pass statuses[0]=ended for ended ones. search matches names and descriptions, creatorIds narrows to campaigns a creator is assigned to, campaignIds looks specific campaigns up whatever their status (as GET /campaigns/{id} does), and pages carry 10 rows by default (page, perPage up to 100), so a workspace with many campaigns needs more than one call; GET /api/v1/campaigns/count takes search, statuses and creatorIds and returns the total. The raw rules array stays on list rows for existing integrations; new ones read spec.
The campaign spec
{
"name": "Creators Spain",
"description": "Spanish-speaking creators, TikTok first",
"currency": "EUR",
"schedule": {
"interval": "month",
"intervalCount": 1,
"anchor": "rolling",
"startsOn": "2026-10-01"
},
"videoTarget": 30,
"briefId": "orgbrief_AbCdEf123456",
"payouts": {
"fixedSalary": { "amount": 300 },
"basePayouts": [
{
"perVideo": 10,
"limit": { "videos": 30, "amount": 300 },
"minViews": 1000,
"platforms": ["tiktok", "instagram"]
}
],
"cpmPayouts": [
{
"scope": "per_video",
"tiers": [
{ "from": 0, "to": 100000, "rate": 1 },
{ "from": 100000, "rate": 1.5 }
]
}
],
"flatBonuses": [
{
"scope": "per_video",
"mode": "highest",
"tiers": [
{ "from": 100000, "to": 500000, "amount": 50 },
{ "from": 500000, "amount": 200 }
]
}
],
"performanceCap": { "amount": 1000 },
"eligibility": { "windowDays": 31, "publicationScope": "include_earlier" }
}
}
A few rules worth knowing before the reference:
- Amounts are major units of the campaign currency:
10means 10.00,1.2means 1.20 per 1,000 views. Amounts are validated against the currency's precision, so10.005is rejected for EUR, and each currency has a sanity ceiling; CPM rates stop at a thousandth of it, view counts at 1,000,000,000 and video targets at 10,000. A ladder holds at most 20 tiers, a campaign at most 10 base payouts, 10 CPM payouts and 10 flat bonuses, and the eligibility window runs from 1 to 180 days. - Tiers are half-open:
tois exclusive and the next tier'sfromequals it. Only the last tier may leavetoout. The sentences read them inclusively —"to": 100000becomes "Up to 99,999 views". Aggregated bonus tiers ("mode": "aggregated") carry notoat all. - The payout window is required.
schedule.anchorisrolling(each creator's cycle starts the day they join; you add and pay creators on their own dates) orfixed(everyone shares the same calendar cycle and payouts go out in one batch per cycle; a creator added mid-cycle gets that cycle as a first period cut to the join day, a fixed salary in it paid pro rata). It locks once the campaign is in use, so it has no default. - At least one payout type is required: a fixed salary, a base payout, a CPM payout or a flat bonus. A
performanceCapneeds at least one CPM payout or flat bonus to cap, and so does a non-defaulteligibility— the window and the publication scope only ever apply to those two, so with neither present awindowDaysother than 31 or acurrent_period_onlyscope is refused (issue atpayouts.eligibility) rather than accepted and silently reset. - Defaults the server fills:
currency(your workspace's default currency),schedule.startsOn(today for rolling campaigns; the start of the current week or month for fixed weekly or monthly ones),platforms(all),eligibility(31 days, videos published before the payout period included),intervalCount(1),scope(per_video), and bonusmode(highestfor a ladder of two or more tiers or a tier that carriesto,aggregatedfor a single open-ended tier). A start date — on create, or when an edit moves it — cannot be before the 1st of the previous month or more than two years ahead; an existing earlier start stays as it is. limiton a base payout: omit it and the base payout stops at the video target;nullpays every extra video; an object sets an explicit video cap and/or payout cap per cycle.- Entries may share platforms, and then they stack. Two base payouts, CPM payouts or flat bonuses covering the same platform both apply: a video on a shared platform earns each of them — a base payout of 10 and one of 5 on TikTok pay 15 per TikTok video, two CPM ladders each price the video's views — and every entry keeps its own cap, target and tiers. The dry-run says so in a
base_payouts_overlap,cpm_payouts_overlaporflat_bonuses_overlapwarning; nothing refuses the setup, so make sure it is the intended one. - Vocabulary follows the sheet:
schedule.intervalisday,weekormonthwithintervalCountfrom 1 to 12 (biweekly isweek× 2),scopeisper_videoorall_content, bonusmodeishighestoraggregated,publicationScopeisinclude_earlierorcurrent_period_only.
Conditions
Every base payout, CPM payout and flat bonus can carry conditions: which videos take part in that payout. On a per-video payout a video must match to be paid; on an all_content payout only matching videos' views add up. The platforms list always applies on top of them.
"cpmPayouts": [
{
"scope": "per_video",
"platforms": ["tiktok"],
"conditions": {
"match": "all",
"items": [
{ "field": "contentType", "operator": "is", "values": ["slideshow"] },
{ "field": "tags", "operator": "anyOf", "tagIds": ["orgtag_AbCdEf123456"] },
{ "field": "views", "operator": "atLeast", "value": 10000 }
]
},
"tiers": [{ "from": 0, "rate": 2 }]
}
]
matchisall(every condition) orany(one is enough); up to 10 conditions per payout.totalViewsjudges the creator, once per payout period: their total views over the payout's platforms, whatever the other conditions say.countFrom: "periodVideos"counts the views the videos published in the period have by its end;eligibleVideoscounts the views every video inside its eligibility window gained in the period. A period that misses pays nothing through that payout, and its views are never paid later.atLeastandbeloware exact complements, so two payouts differing only there never pay the same period (for example 6.67 per video at 300,000 total views or more, 3.33 below); oneatLeastand onebelowin one payout make a range. Works on every payout,all_contentincluded. Changing it after a paid period never warns: each period is judged on its own.- Views work per video. On a base payout,
countWhen: "reached"(the default) counts a video in the period its views reach anatLeastthreshold within the eligibility window, andbyPublicationPeriodEndcounts it only if it gets there by the end of the period it was published in — the same asminViews, so send one or the other. A basebelowneedscountWhen: "byPublicationPeriodEnd": the video counts if it is still under the count at the end of the period it was published in, so two base payouts differing only inatLeastandbelowthe same count pay each video once. On per-video CPM payouts and bonuses, a video that reaches anatLeastthreshold is paid for all its views inside the eligibility window, including earlier ones (never views from before the creator's assignment started); abelowcount pays a video only for periods it ends under the count, and nothing from the period it gets there. OneatLeastand onebelowin one payout make a range, with the samecountWhen. Views conditions onall_contentpayouts are refused. - An
atLeastreachedthreshold undermatch: "any"is accepted only when every other condition of the payout is acontentType. A tag, a hashtag or abelowcount can stop matching after a video was paid; reaching the threshold later would then look like a first match and pay its earlier views a second time.byPublicationPeriodEndhas no such limit. - Tags are read when a payout is calculated: tag a video before its period is paid. Tag ids outside your workspace are refused. Creators see tag names in their terms unless the tag is hidden from creators in its settings.
- A views change after a paid period is accepted with the
views_conditions_changed_after_payoutswarning, in the dry-run and in thePUTresponse: thresholds are judged per video without memory, so a raised one pays already-paid videos again, a lowered one never pays earlier views of videos that newly qualify, and a new payout with a views condition pays earlier views again. Switching a base payout frombyPublicationPeriodEndtoreachedat the same value is safe and does not warn, and so is changing abelowcount. - Send
conditionsback. An entry you send back without theconditionsit had loses them and then pays for every eligible video. The write succeeds and itsspecWarningscarrypayout_conditions_removedwith the number of payouts affected, so an integration that rewritesspec.payoutsshould keep every field it does not change.
Dry-run first
POST /api/v1/campaigns/validate validates a spec exactly as a write would — the brief link included — and returns the normalized spec, the summary sentences, the warnings, the issues (each with the spec path of the problem) and a previewHash — without writing anything. Branch on ok: the issues in a 200 are policy failures a write would refuse, while a body that fails the schema is a 400 BAD_REQUEST on the dry-run too. It takes four shapes:
curl -X POST https://viral.app/api/v1/campaigns/validate \
-H "x-api-key: $VIRAL_APP_API_KEY" \
-H "content-type: application/json" \
-d '{ "spec": { …the spec above… } }'
The spec above dry-runs to this (the spec echo is left out):
{
"ok": true,
"mode": "create",
"summary": {
"schedule": "Your campaign starts on Oct 1, 2026. Creators have individual payout windows that cycle every month.",
"fixedSalary": "Creators get paid €300.00 every month regardless of how many videos they publish.",
"basePayouts": [
"Creators get paid €10.00 per video. At the video cap of 30 videos, base payout is up to €300.00 every month. Base payout is limited to €300.00 in total. Only videos reaching 1,000 views count. Applies to TikTok and Instagram only."
],
"cpmPayouts": [
"Creators get performance payouts based on CPM rates applied per video: From 0 to 99,999 views, a rate of €1.00 / 1,000 views is paid. At least 100,000 views, a rate of €1.50 / 1,000 views is paid. Applies to all platforms."
],
"flatBonuses": [
"Creators get bonus payouts applied per video. Only the highest unlocked tier is paid. From 100,000 to 499,999 views, a bonus of €50.00 is paid. At least 500,000 views, a bonus of €200.00 is paid. Applies to all platforms. Bonuses are paid after the 31-day eligibility window closes."
],
"performanceCap": "Performance payouts stop at €1,000.00 every month; base payout and fixed salary are not affected.",
"eligibility": "Videos count toward performance payouts for 31 days after publication, including videos published before the payout period.",
"headline": "€10.00 / video + €300.00 / month + CPM €1.00–€1.50 + bonuses up to €200.00 · monthly, rolling"
},
"warnings": [
{
"code": "future_start",
"title": "Start date in future",
"message": "Heads up, creators can't be assigned to start working in a campaign before it's start date."
},
{
"code": "flat_bonus_waits_for_window",
"title": "Flat bonuses wait for the eligibility window",
"message": "When tier mode Highest Tier is selected, we only know the final tier once the eligibility window closes. Creators will wait 31 days before flat bonuses are included in payouts. Use a single tier, switch to Aggregated Tiers, or set Current Period Only to pay instantly."
}
],
"issues": [],
"previewHash": "4ea84d0ed21448e956587be2ffe782b5beae91a1f873fdf4b765f4879b601403"
}
Show the sentences to whoever approves, then pass the previewHash to the write. A write whose spec no longer matches the hash is refused with 409 CAMPAIGN_PREVIEW_MISMATCH, so nothing can change between review and write. The hash covers the spec only, not the brief link.
The warning codes are long_payout_cycle, daily_cadence, future_start, eligibility_window_exceeds_interval, flat_bonus_waits_for_window, base_payouts_overlap, cpm_payouts_overlap and flat_bonuses_overlap (entries of one family share a platform and stack), manual_payouts_only (no automated payout provider pays the currency), rules_apply_immediately (an edit dry-run of a campaign in use) and derived_from_job. Each carries the sheet's title and message.
Create a campaign
POST /api/v1/campaigns takes the spec plus an optional previewHash and returns the campaign in the GET shape. A name that already exists in the workspace is a 409 CAMPAIGN_NAME_CONFLICT; an invalid spec is a 400 whose data.issues carry the paths to fix (see the errors below). A linked brief is attached inside the same transaction and never notifies creators — a new campaign has none yet.
curl -X POST https://viral.app/api/v1/campaigns \
-H "x-api-key: $VIRAL_APP_API_KEY" \
-H "content-type: application/json" \
-d '{ …the spec above…, "previewHash": "4ea84d0ed21448e956587be2ffe782b5beae91a1f873fdf4b765f4879b601403" }'
Edit a campaign
PUT /api/v1/campaigns/{id} takes a partial spec. name, description, videoTarget, briefId and each schedule key replace only what you send; payouts replaces the whole block, so start from the campaign's current spec.payouts and send it complete, conditions included (see Conditions). The payout rules are re-derived from the merged spec whenever the payouts you send differ from the campaign's current ones, or videoTarget or currency change — which means a change to the video target alone moves the base payout's cap with it — while a rename, or the current payouts sent back unchanged, leaves the stored rules untouched. A currency change rescales the spec's amounts only — rules outside the spec's vocabulary keep theirs. One edge: a campaign whose rules use more than one eligibility window or publication scope (an internal setup) refuses a videoTarget or currency change on its own with an issue at payouts.eligibility, since re-deriving the rules would collapse them to one value; send payouts explicitly to rewrite them together with the change.
Rule changes apply to every unpaid payout at once, including the current period. Payouts you already issued keep the rules they were paid under, and every change is recorded as a revision. currency and the schedule keys are editable only while the campaign has no creator assignments and no payout history, cancelled payouts included; after that a changed value is a 409 CAMPAIGN_FIELD_LOCKED with data.fields, while echoing the current value still passes. The end date is never part of the spec — it belongs to the end and reactivate routes below.
briefId: null removes the brief; notifyCreatorsOfBriefChange: true tells the assigned creators about a changed brief link. A brief shared with everyone in the workspace can't be a campaign's brief (409 BRIEF_AUDIENCE_CONFLICT): its creators read it anyway. The briefs themselves are written through Briefs via the API.
Assign creators
A creator holds one running or scheduled assignment at a time, across all campaigns. A running assignment with a scheduled end does not block a new one that starts after it. Assignments start on a cycle boundary of a fixed campaign, or on the day the creator joins a rolling one, and they can never start before the campaign does.
- Find the creators.
GET /api/v1/creatorslists the workspace's creators with their ids and status. Archived creators cannot be assigned; see Creators for how they get there. - Run the preflight.
GET /api/v1/creators/campaigns/assign-optionstakescampaignIdand up to 50creatorIds(bracket notation:?campaignId=orgcamp_…&creatorIds[0]=orgcre_…&creatorIds[1]=orgcre_…) and returns what an assign would accept today:startOptions— for a fixed campaign, the cycle start dates behindstartBasisprev,currentandnext, plus today's date behindtoday, eachnullwhere the assign would refuse it (a start before the campaign began, or a cycle the campaign's end date cuts short);nullaltogether for a rolling campaign, which starts on a day you name.today, so a caller never has to derive an aligned date itself. "Today" is always the UTC calendar day — the day the campaign's cycles, thetodaybases and the running period are judged on, whatever the caller's time zone.- per creator,
blockedBy(running,scheduled,archivedornull) and theassignmentbehind it, so you can say which campaign a creator is on — or that they need unarchiving — before the assign fails. - per creator,
videosPaidOutsideCampaigns: the videos a video payout already paid while no campaign covered them, each with its last paid day (paidThrough). Such a payout does not take the video out of campaign pay, so a start on or before apaidThroughpays those days again.
- Assign.
POST /api/v1/creators/campaigns/assignwithcreatorIds,campaignIdand astartBasis. Fixed campaigns takeprev,currentornext(the cycle starts from the preflight),today, orcustomwith anystartDatefrom the campaign start on; rolling campaigns taketodayorcustomwith astartDate. A start inside a fixed campaign's cycle joins mid-period: that cycle becomes the assignment's first period, cut to the join day — due like any other period, a fixed salary in it paid pro rata, videos and views priced from the join day.currentinstead backdates the assignment to the cycle's start and pays the whole cycle. The response is{ "updated": <count> }.
// A fixed monthly campaign: everyone starts with the current cycle.
{ "creatorIds": ["orgcre_ABC123456789"], "campaignId": "orgcamp_RwmraAffhMt9", "startBasis": "current" }
// A rolling campaign: the creator joins on the day you name.
{ "creatorIds": ["orgcre_ABC123456789"], "campaignId": "orgcamp_RwmraAffhMt9", "startBasis": "custom", "startDate": "2026-10-06" }
Unassign with POST /api/v1/creators/campaigns/unassign: creatorIds (all in the same campaign) and an endBasis. prev, current and next end the assignment after a full billing period. today ends it at the end of today, and custom with an endDate on the day you name, any day. An end on a period's last day ends after that full period. An end inside a period makes that period the assignment's final one, cut to the end day and due the day after like any other — pay it, or cancel it if the creator was paid another way. A fixed salary in a cut period is paid pro rata, and partial payments already made toward that period move onto the cut period, so its payout still deducts them. Either way the call sets or replaces the scheduled end of the current assignment; an end on or before the assignment's start clears the assignment entirely. Payout periods already paid always block an earlier end (for an end inside a period, any paid or cancelled period reaching past the end day); cancelled ones block too unless you pass removeCanceledWindows: true, which deletes them together with the cleared assignment — consent you should collect from a person first. Once a cut final period is paid or cancelled, the end is final: moving it, or clearing the scheduled end in the dashboard, answers 409 FINAL_PERIOD_SETTLED.
Switch with POST /api/v1/creators/campaigns/switch: the same creatorIds, endBasis and endDate for the current assignment plus campaignId, startBasis and startDate for the target, applied in one transaction. The end works as in an unassign — a full period, today, or any custom day, with the same cut final period when it lands inside a period — the start as in an assign to the target, and the start must lie after the end: after an end inside a period, the target starts the day after at the earliest (startBasis: "custom" with that date; for a fixed target that is a mid-period join, its first period cut to that day).
Payouts follow the assignments: from the start date on, a creator's eligible videos count toward the campaign's payout queues, and an ended assignment stops accruing after its last period — the last full one, or the one cut to the end day. Payouts already issued are never touched. See Payouts for how the queues work.
End and reactivate a campaign
GET /api/v1/campaigns/{id}/end-contextis the preflight for ending: the campaign's validity span, the cycle-aligned end dates behindendBasisprev,currentandnext(cycleEnds), and every assignment with its validity span and its last settled payout window, so you can tell what an end date would do to it.POST /api/v1/campaigns/{id}/endsets the end date on a cycle boundary (endBasis, orcustomwithendDate). Assignments reaching past the end make the call fail unless you passendAssignments(each moves to its last full period before the end) orrevokeAssignments(rows that cannot keep one full period are deleted) — consent you should collect from a person first. A campaign must have run for at least one full cycle before it can end.POST /api/v1/campaigns/{id}/reactivateclears the end date of a campaign that has ended or is scheduled to end.
Errors at a glance
Notes for agents
If you are an agent working these routes on someone's behalf, these rules keep you inside what the team would have done by hand:
- Never invent a number or a date. An amount, a video target, a start date or the payout window the user did not state is a question, not a guess. The dry-run's
warningsand the campaign sheet's own ranges are for sanity checks, not for filling gaps. - Dry-run every write and show the
summarysentences before creating or editing. Pass thepreviewHashyou showed. Ifokisfalse, fix the exactpathin each issue and dry-run again; never write around an issue by dropping the field. - Edits replace the whole
payoutsblock. Read the campaign'sspec.payoutsfirst, change the one thing, send it complete. - Overlapping entries stack. Put two entries of one family on the same platform only when the user asked for exactly that; otherwise give each entry its own platforms. The dry-run's overlap warning is the check to show them.
- Consent flags need a person's explicit yes:
endAssignmentsandrevokeAssignmentson a campaign end,removeCanceledWindowson an unassign. Say what each would do to whom (the end-context and the assign preflight tell you) and wait for the answer. - Use the preflights instead of arithmetic. The assign preflight and the end-context return the cycle-aligned dates; pass their basis (
prev,current,next) rather than a computed date. endBasis: "today"is a cut, not a schedule. Say that the running period becomes a shortened final one due tomorrow, and that once it is paid the end can no longer move. Prefer a cycle-aligned basis unless the person wants the creator off the campaign today.- A
409is a fact, not a retry. A name conflict, a locked field, a blocked creator or a hash mismatch will not pass on a second identical call. Read the error'sdata, change the request or ask. - Lists are paged and default to current campaigns. Page through
GET /campaignsbefore concluding a name or a campaign does not exist, and passstatuseswhen the user means an ended one.
MCP server
Connect ChatGPT, Claude, Cursor, Codex, Gemini, Grok and other AI agents to your viral.app workspace with one URL and a browser sign-in.
Briefs API
Write creator briefs in markdown or TipTap JSON, attach sample videos from your tracked accounts or the viral video library, choose who reads them, and see who has, from your own tools or an agent.