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 (with optional rate steps), a base payout cap, 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. compensation.basePayoutUnit says what the base payout range pays for: video, creative (base payouts that count each creative once, creatives: "once" or scope: "per_creative") or mixed; the headline names the same unit ("€10.00 / creative"). 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",
"timeZone": "Europe/Madrid"
},
"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),schedule.timeZone(UTC, on create only),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;{ "videos": 20 }stops at 20 videos and{ "videos": "videoTarget" }at the target.limit.amountis deprecated: on a campaign with one base payout it meansbaseCap(reads still return it there), with several it is refused, and next to a differentbaseCaptoo (the same value twice passes, so aGETsent back as aPUTdoes).videoTargetUnitsays whatvideoTargetcounts per cycle:videos(the default: every post on every platform),creatives(each video once, however many platforms it's posted to; only active creatives group copies, ungrouped videos count as their own) orper_platformwithvideoTargetPlatforms, a count per platform ({ "tiktok": 20, "instagram": 10 }): the platforms' sum replaces thevideoTargetyou send, and posts on unlisted platforms don't count. SendingvideoTargetPlatformsalone switches the unit toper_platform; next tovideosorcreativesit is refused (issue atvideoTargetPlatforms), andnullis the same as leaving it out, so thevideoTargetPlatforms: nullof a list row can be sent back. Pay still counts posts: a base payout that ends at the target (limitomitted or"videoTarget") or steps after it follows the sum of its own platforms' counts on a per-platform target (a payout on no listed platform is refused), and on a creatives target only on a single platform, withcreatives: "once"or withscope: "per_creative". Any other payout keeps the number it followed as its own — on an update after a unit change, and on a create — with avideo_target_followers_frozenwarning.stepson a base payout pay a different rate once a creator posts more videos in a period.perVideopays from the first video; each step{ "startsAfter": 7, "perVideo": 45 }pays every video after that many, up to the next step, so each video is paid at the rate of the step its place falls in.startsAftermay be"videoTarget"(after the target, the creator's own with custom rates). At most 4 steps (5 rates); starts rise and stay below where the payout ends; rates may rise or fall. A step at the same rate as the one before it returns therate_step_same_ratewarning: it pays each creator's own rate there (custom rates) until they get aperVideoof their own on the step. An update that sends a base payout with itsidbut withoutstepskeeps its stored steps;steps: []removes them — unlikeconditions, which a payout sent without loses.baseCapis the most all base payouts together pay per cycle, rate steps included. Carried videos are held to the cap of the period they were posted in, never the paying period's; what other base payouts pay in that period is not subtracted from it. Videos bought out with a video payout keep their places in their period (they count toward its rate steps and end) and are paid by their own video payout, outside the cap.- 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_video,per_creativeorall_content, bonusmodeishighestoraggregated,publicationScopeisinclude_earlierorcurrent_period_only. - Videos posted to several platforms (copies grouped as one creative in Analytics → Videos → Creatives, active creatives only) can count as one per payout, in one of two ways.
scope: "per_creative"counts the copies as one creative, their views added up: a CPM payout or bonus prices it like one video, and a base payout pays it one base payout, itsviewsconditions judging the copies' combined views (it counts in the first period at whose end they meet areachedatLeastcondition, otherwise only in the period its first copy was posted in).creativespays only each creative's winner (its copy with the most views): a base payout's"once"counts each creative once, in the first period any copy counts, on the copy with the most views by that period's end, each copy meeting itsviewsconditions on its own; a CPM payout's or bonus's"bestInWindow"or"bestInPeriod"pays only the copy with the most views over the eligibility window or in the period, and over the window a copy that takes the lead earns only the views beyond the best total so far. Each payout compares only the copies it would pay (its platforms, conditions, window). Refused (issue at the entry'screativesorscope): a payout covering a single platform,per_creativetogether withcreatives,current_period_onlyon CPM payouts and bonuses, a CPM payout's or bonus'sper_creativewithviewsconditions, a base payout'sper_creativewithviewsconditions undermatch: "any"or next to atotalVideoscondition that carries, andbestInPeriodon a per-video bonus or next to anatLeastviewscondition. An update that sends a payout back without the option it had is accepted with thecreative_payouts_removedwarning, and that payout then pays every copy.
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.- Tag names:
GET /campaignsrows andGET /campaigns/{id}carryconditionTagNames, the names of the tags the campaign's conditions use, keyed by tag id. 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.totalVideosjudges the creator's video count, once per payout period: every video they published in the period from their assignment start, on every platform, each platform post counting once (a cross-post to three platforms counts 3), whatever the payout's platforms and other conditions say; hidden videos (brief samples, and the Excluded tag except on videos tracked one by one on a manual selection account, which every payout pays) never count.value: "videoTarget"follows the campaign'svideoTarget, or the creator's own from their custom rates, and is never prorated; it counts invideoTargetUnit: undercreativeseach video once whatever platforms it's on, underper_platformevery listed platform must reach its own count (so a period isbelowit as soon as one listed platform is under its count) and other platforms don't count. The unit always comes from the campaign: atotalVideoscondition sent withcountorplatformsis refused. A period below anatLeastminimum pays nothing through that payout.carry(base payouts priced per video,atLeastonly, directly in amatch: "all"group or alone in its group, one per payout, and the payout's onlytotalViewsortotalVideoscondition) pays a short period's videos later, priced as if paid in the period they were posted in (its rate steps, its end and itsbaseCap, never the paying period's):previousPeriodin the next period if it reaches its own minimum,untilPaidin the first later period that does. Withoutcarrythey are lost. Carried videos are paid with a completed window (or Settle & end), never twice; payouts record them in their calculation'ssettlement.carriedVideos. Periods follow the campaign'sschedule.timeZone. A change after a paid period, the target one follows included, returns thetotal_videos_changed_after_payoutswarning: every unpaid period is judged at the new minimum.- 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. The top tier is paid once reached; lower tiers are paid after the 31-day eligibility window closes, if the campaign is still running then."
],
"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": "In Highest Tier mode a video's final tier is only known once its 31-day eligibility window closes, so those tiers are paid in the payout period the window closes in, and only if the campaign and the creator's assignment still run then. An open-ended top tier is paid as soon as a video reaches it. Switch to Aggregated Tiers to pay each tier in the period a video reaches it, or set Current Period Only to judge videos at the end of the period they are posted in."
}
],
"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, bonus_tiers_unpaid_after_end (once the campaign has an end date, Highest Tier bonus tiers that videos posted near the end can no longer earn), performance_cap_below_bonus (the performance cap is lower than a flat bonus, which then pays at most the cap and leaves nothing for CPM in that period), base_payouts_overlap, cpm_payouts_overlap and flat_bonuses_overlap (entries of one family share a platform and stack), rate_step_same_rate (a rate step at the rate before it, which follows each creator's own rate), manual_payouts_only (no automated payout provider pays the currency), rules_apply_immediately (an edit dry-run of a campaign in use), views_conditions_changed_after_payouts and total_videos_changed_after_payouts (a conditions edit after a paid period, see the conditions above), payout_conditions_removed and creative_payouts_removed (payouts sent back without their conditions or creative option), time_zone_changed (a schedule.timeZone change on a campaign with creators moves their unpaid periods to the new midnights), video_target_followers_frozen (base payouts that end at or step after the video target keep the number they followed as their own, because the target counts in a unit they can't follow, see videoTargetUnit above) 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, videoTargetUnit, videoTargetPlatforms, briefId and each schedule key replace only what you send (videoTargetPlatforms: null counts as not sent); payouts replaces the whole block, so start from the campaign's current spec.payouts and send it complete, conditions included (see Conditions). On a per_platform campaign videoTarget is the counts' sum: a videoTarget sent without videoTargetPlatforms is refused (issue at videoTarget) unless it equals that sum, so send the counts to change it, or another videoTargetUnit to count that number another way. The payout rules are re-derived from the merged spec whenever the payouts you send differ from the campaign's current ones, or the video target (its number, unit or platforms) or currency change — which means a change to the video target alone moves a base payout's end and every rate step after the target 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. schedule.timeZone locks later, once a campaign or partial payout, a video payout instead of the campaign's pay, a canceled period, or a payout across a back-to-back assignment depends on the campaign's periods; until then a change on a campaign with creators returns the time_zone_changed warning, because their unpaid periods move to the new midnights. 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 the campaign's calendar day in itsschedule.timeZone— 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.
Custom rates for one creator
A creator can be paid with their own values on a campaign: per-video and CPM rates, bonus amounts, the fixed salary, caps and the video target. Values only: the payouts themselves (platforms, tiers, conditions) stay the campaign's, so a creator who needs another structure needs their own campaign. Each value names a payout by the id it carries in the campaign's spec (every base payout and rate step, CPM tier, bonus tier, the fixed salary and each cap have one) and a field:
videoTarget sits next to the values, in the campaign's unit; on a per-platform target send videoTargetPlatforms instead ({ "tiktok": 20 }: the creator's own count for listed platforms, the others follow the campaign's). A base payout capped at the campaign target, and a rate step after it, follow the creator's own target. A target that would put a base payout's rate steps out of order is refused (issue at videoTarget). Amounts are major units, videoCap is a count. 0 switches a payout off for that creator; caps and the target stay above 0. A value equal to the campaign's is not kept: custom always means "differs from the campaign". In a merge (assign, switch), sending the campaign's value resets the creator's own one. One exception: a rate step the campaign pays at the rate before it follows the creator's rate there, so any perVideo sent for it is kept as the creator's own (delete it to follow again).
GET /api/v1/campaigns/{id}/custom-rateslists every creator with custom rates, their values and whether they are assigned, plusrows: every value a creator can have their own of, with the campaign's.GET /api/v1/campaigns/{id}/custom-rates/{creatorId}returns one creator's values, row by row.POST /api/v1/campaigns/{id}/custom-rates/validateis the dry-run:creatorIds(up to 50),mode(replacefor the write below,mergefor an assign or switch),videoTarget(orvideoTargetPlatforms) andvalues. Per creator it returns every value that changes (changes, before and after), the existing custom values a merge replaces (replaced),warnings, and for a single creator their unpaid periods priced before and after (openWindows), plus apreviewHash.PUT /api/v1/campaigns/{id}/custom-rates/{creatorId}replaces the creator's values with the set you send (an empty set resets them); pass thepreviewHashto pin it to the dry-run.DELETEon the same path resets them.POST /creators/campaigns/assignand/switchtakecustomRates(videoTargetorvideoTargetPlatforms, andvalues) andcustomRatesPreviewHash(dry-run withmode: "merge"). The values are merged per value into each creator's existing ones on that campaign.
// PUT /api/v1/campaigns/orgcamp_RwmraAffhMt9/custom-rates/orgcre_ABC123456789
{
"videoTarget": 8,
"values": [{ "id": "rule_A1b2C3d4E5f6", "field": "perVideo", "value": 12 }],
"previewHash": "…"
}
New values apply to every unpaid period of that creator right away; issued payouts keep what they were paid. GET /campaigns rows carry customRates (the creators with custom rates, and per part of the terms how many differ) or null, and every change fires the campaign.custom_rates_updated webhook.
Campaign edits keep creators' values as long as their payout stays. With an id, the campaign dry-run returns customRateImpact: resets (values on payouts the edit removes), overridden (values the edit changes that creators have their own of) and notices. An edit with resets is a 409 CUSTOM_RATES_RESET_REQUIRED unless you pass resetRemovedCustomRates: true; applyToCustomRates ([{ "id", "field" }] from overridden) resets the listed values so those creators follow the campaign's new one.
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.cycleEndWarningsholds, per date, the warnings the end call would return.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. The response'swarningsnever block:bonus_tiers_unpaid_after_endsays which videos can no longer earn Highest Tier bonus tiers, because a tier below an open-ended top tier pays only once a video's eligibility window closes and no payout period exists after the end. A campaign shorter than its window can't pay those tiers at all.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.
- One creator paid differently is custom rates, not a new campaign. Use a copy only when they need other payouts or conditions. Dry-run custom rates and show the
changes(before and after) before the write. - Consent flags need a person's explicit yes:
endAssignmentsandrevokeAssignmentson a campaign end,removeCanceledWindowson an unassign,resetRemovedCustomRateson an edit. 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.