{"openapi":"3.1.0","info":{"title":"Bloss Campaign API","version":"0.1.0","description":"Brand-facing API for tracking campaign spend. A *campaign* is a set of payout cycles you group together. Spend is tracked as **Base + CPM** (the same base pay + view-based bonus your creators earn). Spend and reach can be broken down by **tag** (`GET /campaign/tags`), by **day** (`GET /campaign/daily`), or per **post** (`GET /campaign/pairs`), and scoped to a tag set via the `tagIds` filter. Every campaign endpoint also takes `groupIds`, scoping it to a **creator group** — the same groups you filter the Video Vault by; `GET /groups` lists them and their members. The two filters compose as an AND, and ids within one filter are ANY. All endpoints return data only for the brand that owns the API key, and every one of them is read-only."},"servers":[{"url":"/api/v0"}],"security":[{"bearerAuth":[]},{"apiKeyHeader":[]}],"paths":{"/cycles":{"get":{"summary":"List payout cycles","description":"Returns the payout cycles for your brand. Use the returned `cycleId` values as `cycleIds` on the campaign endpoints.","responses":{"200":{"description":"The brand's cycles.","content":{"application/json":{"schema":{"type":"object","properties":{"cycles":{"type":"array","items":{"$ref":"#/components/schemas/Cycle"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/groups":{"get":{"summary":"List creator groups and their members","description":"Your brand's creator groups — the same groups you filter the Video Vault and the roster by — each with the ids of the creators in it. Use a returned `groupId` as `groupIds` on `GET /campaign/pairs`, or join `creatorIds` against the `creator.creatorId` on each pair.\n\nGroups are a property of the roster, not of a campaign, so this is not scoped to cycles. Membership is **current**, not historical: there is no record of when a creator joined or left a group, so re-running a report over an older cycle groups its pairs the way the roster looks today.\n\nArchived creators are included — their pairs stay in their cycles and keep appearing in the campaign endpoints. Empty groups come back with `creatorIds: []`. Groups are returned in your brand's own display order.","responses":{"200":{"description":"The brand's groups and their membership.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatorGroups"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/campaign/summary":{"get":{"summary":"Campaign spend summary","description":"The four core metrics for a campaign: total videos, total views, total spend (Base + CPM), and total remaining budget (prepayment − spend). When `prepayment` is provided, also reports budget usage and whether spend has reached the top-up threshold.\n\nBoth `tagIds` and `groupIds` scope **every** metric here, spend included — pay lives on the pair, which carries both a tag and a creator. This is the endpoint to use for a group's total spend, since `GET /campaign/daily` cannot break that total down by day.","parameters":[{"name":"cycleIds","in":"query","required":true,"description":"Comma-separated cycle ids to include in the campaign.","schema":{"type":"string"},"example":"cyc_abc,cyc_def"},{"name":"prepayment","in":"query","required":false,"description":"Total prepaid budget from the client (USD).","schema":{"type":"number","minimum":0},"example":50000},{"name":"thresholdPct","in":"query","required":false,"description":"Budget-usage percentage that flips `atThreshold` to true. Defaults to 70.","schema":{"type":"number","minimum":0,"default":70}},{"name":"tagIds","in":"query","required":false,"description":"Comma-separated tag ids. When present, every metric (videos, views, and spend) is scoped to pairs carrying at least one of these tags. Get tag ids from `GET /campaign/tags`.","schema":{"type":"string"},"example":"tag_abc,tag_def"},{"name":"groupIds","in":"query","required":false,"description":"Comma-separated creator-group ids. When present, every metric (videos, views, and spend) is scoped to pairs whose creator is in at least one of these groups. Get group ids from `GET /groups`; `__ungrouped__` selects creators in no group. An id that is not one of your brand's groups is a 400.","schema":{"type":"string"},"example":"grp_abc,grp_def"}],"responses":{"200":{"description":"Campaign summary.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignSummary"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/campaign/daily":{"get":{"summary":"Campaign daily breakdown","description":"Per-day videos posted, views generated, and spend (Base + CPM) across the campaign's cycles. Defaults to the full span the cycles cover, in your brand's timezone.\n\nSumming the days reproduces `GET /campaign/summary` for the same cycles: `videos` counts each video once on the day it was posted, `views` is each video's own day-over-day gain, and `spend` is the day-over-day increase in that day's cycle pay. `spend` is null on a day no requested cycle covers, and can be negative on a day a pair's pay was revised down.\n\nWhen `tagIds` is supplied, videos/views are scoped to that tag set and `spend` is null for every day — per-day spend cannot be attributed to a tag (pay is a cycle-level total on the pair). Use `GET /campaign/tags` for total spend by tag.\n\n`groupIds` scopes videos/views by the video's creator and also nulls `spend`, for a different reason: the daily spend series is differenced out of a pre-aggregated brand-wide table with no creator dimension. For a group's total spend, use `GET /campaign/summary` with the same filter.","parameters":[{"name":"cycleIds","in":"query","required":true,"description":"Comma-separated cycle ids to include in the campaign.","schema":{"type":"string"}},{"name":"from","in":"query","required":false,"description":"Clamp the start of the window (YYYY-MM-DD).","schema":{"type":"string","format":"date"}},{"name":"to","in":"query","required":false,"description":"Clamp the end of the window (YYYY-MM-DD).","schema":{"type":"string","format":"date"}},{"name":"tagIds","in":"query","required":false,"description":"Comma-separated tag ids. Scopes each day's videos/views to videos carrying at least one of these tags; `spend` becomes null. Get tag ids from `GET /campaign/tags`.","schema":{"type":"string"},"example":"tag_abc,tag_def"},{"name":"groupIds","in":"query","required":false,"description":"Comma-separated creator-group ids. Scopes each day's videos/views to videos whose creator is in at least one of these groups; `spend` becomes null. Get group ids from `GET /groups`; `__ungrouped__` selects creators in no group.","schema":{"type":"string"},"example":"grp_abc,grp_def"}],"responses":{"200":{"description":"Daily breakdown.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignDaily"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/campaign/tags":{"get":{"summary":"Campaign spend by tag","description":"Per-tag breakdown of a campaign's spend and reach. For each of your brand's tags, returns the videos, views, spend (Base + CPM), and CPM attributable to that tag across the campaign's cycles. Spend is attributed by *pair* tag (pairs carry the payout). A pair with multiple tags counts in each tag's bucket, so per-tag totals can exceed the campaign total. Every brand tag is returned, zero-filled when it has no activity. Use the returned `tagId` values as `tagIds` on `GET /campaign/summary` and `GET /campaign/daily`.\n\n`groupIds` narrows the population the breakdown describes — \"how did this squad's spend split across tags?\". The catalog itself is unaffected: every brand tag still comes back, zero-filled where that group has no activity.","parameters":[{"name":"cycleIds","in":"query","required":true,"description":"Comma-separated cycle ids to include in the campaign.","schema":{"type":"string"},"example":"cyc_abc,cyc_def"},{"name":"groupIds","in":"query","required":false,"description":"Comma-separated creator-group ids. Counts only the activity of creators in at least one of these groups. Get group ids from `GET /groups`; `__ungrouped__` selects creators in no group.","schema":{"type":"string"},"example":"grp_abc,grp_def"}],"responses":{"200":{"description":"Per-tag spend and reach.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignTagBreakdown"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/campaign/pairs":{"get":{"summary":"Campaign pairs (per-post rows)","description":"One row per video *pair* — a creator's post, or the 1–3 cross-posted videos that share a single payout — for the given cycles. This is the layer underneath `GET /campaign/summary` and `GET /campaign/tags`: per-pair `totalSpend` and `views` sum to the campaign figures those endpoints report for the same cycles. Each row carries the creator, the pair's tag ids, its money (`basePay` + `cpmBonus` = `totalSpend`), and a `videos` array with the platform, post URL, posted-at, and engagement of every video in the pair.\n\nEngagement comes from the latest snapshot inside the cycle window — the same rows the campaign totals count. A video with no in-cycle snapshot has `snapshotDate: null` and zeroed metrics, and is not counted by any campaign endpoint; `countedVideos` is how many of the pair's videos did count.\n\n`tagIds` filters which *pairs* come back (pair tags carry the money); each returned pair still reports all of its own videos.\n\n`groupIds` filters by the pair's **creator** instead — the creator groups from `GET /groups`. Filters compose as an AND (`groupIds` plus `tagIds` means both), while ids *within* one filter are ANY. Every row also carries `creator.groupIds`, so one unfiltered pass can be bucketed by group client-side.\n\nResults are ordered by `(updatedAt, pairId)` ascending and paged with the opaque `nextCursor`. Pass `updatedSince` with the largest `updatedAt` you have already stored to pull only what changed since — `updatedAt` moves whenever the pair is re-paired, re-assigned, or its payout is recalculated (which is what a view refresh triggers).","parameters":[{"name":"cycleIds","in":"query","required":true,"description":"Comma-separated cycle ids to include in the campaign.","schema":{"type":"string"},"example":"cyc_abc,cyc_def"},{"name":"tagIds","in":"query","required":false,"description":"Comma-separated tag ids. Returns only pairs carrying at least one of these tags. Get tag ids from `GET /campaign/tags`.","schema":{"type":"string"}},{"name":"groupIds","in":"query","required":false,"description":"Comma-separated creator-group ids. Returns only pairs whose creator is in at least one of these groups. Get group ids from `GET /groups`. Pass the literal `__ungrouped__` to include creators that are in no group at all; it may be combined with real group ids (the two populations are unioned). An id that is not one of your brand's groups is a 400, never a silently empty page.","schema":{"type":"string"},"example":"grp_abc,grp_def"},{"name":"creatorId","in":"query","required":false,"description":"Return only this creator's pairs.","schema":{"type":"string"}},{"name":"updatedSince","in":"query","required":false,"description":"ISO-8601 timestamp. Returns only pairs whose `updatedAt` is strictly greater — the incremental-sync cursor.","schema":{"type":"string","format":"date-time"},"example":"2026-07-01T00:00:00Z"},{"name":"limit","in":"query","required":false,"description":"Rows per page (1–500). Defaults to 100.","schema":{"type":"integer","minimum":1,"maximum":500,"default":100}},{"name":"cursor","in":"query","required":false,"description":"The `nextCursor` from the previous response. Omit for the first page.","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of pairs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignPairs"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"Provide your API key as `Authorization: Bearer <key>`."},"apiKeyHeader":{"type":"apiKey","in":"header","name":"x-api-key","description":"Alternatively, provide your API key as `x-api-key: <key>`."}},"responses":{"Unauthorized":{"description":"Missing, invalid, or revoked API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"BadRequest":{"description":"Invalid parameters — an unknown cycle id, an unknown creator-group id, or a malformed date, limit or cursor. An id that belongs to another brand reads as unknown.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string"}}},"Cycle":{"type":"object","properties":{"cycleId":{"type":"string"},"startDate":{"type":"string","format":"date-time"},"endDate":{"type":"string","format":"date-time"},"status":{"type":"string","example":"pending"}}},"CampaignSummary":{"type":"object","properties":{"metrics":{"type":"object","properties":{"totalVideos":{"type":"integer"},"totalViews":{"type":"integer"},"totalSpend":{"type":"number","description":"Base + CPM, USD."},"totalRemains":{"type":["number","null"],"description":"prepayment − totalSpend; null if no prepayment given."}}},"budget":{"type":["object","null"],"properties":{"prepayment":{"type":"number"},"budgetUsedPct":{"type":["number","null"]},"thresholdPct":{"type":"number"},"atThreshold":{"type":["boolean","null"]}}},"currency":{"type":"string","example":"USD"},"cycles":{"type":"array","items":{"type":"object","properties":{"cycleId":{"type":"string"},"startDate":{"type":"string","format":"date-time"},"endDate":{"type":"string","format":"date-time"},"status":{"type":"string"},"videos":{"type":"integer"},"views":{"type":"integer"},"spend":{"type":"number"}}}}}},"CampaignTagBreakdown":{"type":"object","properties":{"currency":{"type":"string","example":"USD"},"tags":{"type":"array","items":{"type":"object","properties":{"tagId":{"type":"string"},"name":{"type":"string"},"color":{"type":["string","null"],"description":"Palette token, or null for a neutral tag."},"videos":{"type":"integer"},"views":{"type":"integer"},"spend":{"type":"number","description":"Base + CPM summed over the tag's pairs, USD."},"cpm":{"type":"number","description":"spend / views × 1000; 0 when views is 0."}}}}}},"CampaignPairs":{"type":"object","properties":{"currency":{"type":"string","example":"USD"},"pairs":{"type":"array","items":{"$ref":"#/components/schemas/CampaignPair"}},"nextCursor":{"type":["string","null"],"description":"Pass as `cursor` for the next page; null on the last page."},"hasMore":{"type":"boolean"}}},"CampaignPair":{"type":"object","properties":{"pairId":{"type":"string"},"cycleId":{"type":"string"},"creator":{"type":"object","properties":{"creatorId":{"type":"string"},"name":{"type":["string","null"]},"handles":{"type":"object","description":"The creator's connected accounts. `facebook` is a page URL, not a bare handle. Null where the platform isn't connected.","properties":{"instagram":{"type":["string","null"]},"tiktok":{"type":["string","null"]},"youtube":{"type":["string","null"]},"facebook":{"type":["string","null"]}}},"groupIds":{"type":"array","items":{"type":"string"},"description":"Creator groups this creator belongs to, in your brand's display order; `[]` when ungrouped. Membership is current, not as-of the post — so it is identical on every pair of the same creator. Resolve to names with `GET /groups`."}}},"firstVideoAt":{"type":["string","null"],"format":"date-time","description":"Earliest post time in the pair — what assigns it to a cycle."},"videoCount":{"type":"integer"},"countedVideos":{"type":"integer","description":"Videos with an in-cycle snapshot — the pair's contribution to the campaign video count."},"views":{"type":"integer"},"likes":{"type":"integer"},"comments":{"type":"integer"},"shares":{"type":"integer"},"saves":{"type":"integer"},"basePay":{"type":"number","description":"Base pay for the pair, USD."},"cpmBonus":{"type":"number","description":"View-based bonus, USD."},"totalSpend":{"type":"number","description":"basePay + cpmBonus, USD."},"excludedFromPayout":{"type":"boolean","description":"The brand marked this pair unpaid. It is still included in the figures above, matching `GET /campaign/summary`."},"tagIds":{"type":"array","items":{"type":"string"}},"updatedAt":{"type":"string","format":"date-time"},"videos":{"type":"array","items":{"type":"object","properties":{"videoId":{"type":"string"},"platform":{"type":"string","example":"instagram"},"handle":{"type":["string","null"],"description":"The creator's handle on this video's platform."},"postUrl":{"type":["string","null"]},"postedAt":{"type":["string","null"],"format":"date-time"},"snapshotDate":{"type":["string","null"],"format":"date","description":"Date of the snapshot the metrics below come from; null when the video has no in-cycle snapshot (metrics are then 0)."},"views":{"type":"integer"},"likes":{"type":"integer"},"comments":{"type":"integer"},"shares":{"type":"integer"},"saves":{"type":"integer"}}}}}},"CreatorGroups":{"type":"object","properties":{"groups":{"type":"array","items":{"type":"object","properties":{"groupId":{"type":"string"},"name":{"type":"string"},"color":{"type":["string","null"],"description":"Palette token, or null for a neutral group."},"sortOrder":{"type":"integer","description":"Your brand's display order for the group. Groups are returned already sorted by it."},"memberCount":{"type":"integer","description":"Always equal to the length of `creatorIds`."},"creatorIds":{"type":"array","items":{"type":"string"},"description":"Creators in this group, matching `creator.creatorId` on `GET /campaign/pairs`. Includes archived creators."}}}},"ungroupedFilterId":{"type":"string","example":"__ungrouped__","description":"The sentinel to pass in `groupIds` for \"creators in no group at all\". Not a real group; it never appears in `groups`."}}},"CampaignDaily":{"type":"object","properties":{"from":{"type":"string","format":"date"},"to":{"type":"string","format":"date"},"days":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","format":"date"},"videos":{"type":"integer","description":"Videos posted on this day (brand-local)."},"views":{"type":"integer"},"spend":{"type":["number","null"],"description":"Base + CPM booked on this day. Null when a tagIds or groupIds filter is applied, or when no requested cycle covers the day."}}}}}}}}}