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 CPM payouts, 10 flat bonuses and one base payout per platform, 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; creators added mid-cycle snap to a slot, and payouts go out in one batch per cycle). 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.- Vocabulary follows the sheet:
schedule.intervalisday,weekormonthwithintervalCountfrom 1 to 12 (biweekly isweek× 2),scopeisper_videoorall_content, bonusmodeishighestoraggregated,publicationScopeisinclude_earlierorcurrent_period_only.
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, 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. 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.
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, 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.- 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.
- Assign.
POST /api/v1/creators/campaigns/assignwithcreatorIds,campaignIdand astartBasis. Fixed campaigns takeprev,currentornext(the dates from the preflight); rolling campaigns takecustomwith astartDate. 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), an endBasis of prev, current, next or custom with an endDate on a cycle boundary. It 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; 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.
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 follows the current campaign's cycles, the start the target's, and the start must lie after the end.
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 full period. 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. - 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. - 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.