Designing a campaign type
A campaign type is the one document that defines what a sponsor can buy from your app — price, delivery rules, ad slots, the creative you need from them, any custom fields. Everything an advertiser sees — the booking form, the quote, a rejection message — is generated from it. Worth sketching before you register.
What it actually is
A campaign type is flat, not nested into sections: name, description (optional), a pricing block, a delivery block, one or more slots, and optional assets, fields, and validationRules. There is no separate “basics” object — name and description sit at the top level alongside everything else.
Start with what’s sellable, not the price
Figure these out in order: where can an ad appear (slots), what do you need uploaded for it (assets), what other information rides along (fields) — then price it.
A slot is a named place an ad can appear. Most slots point at exactly one asset via assetKey; several slots can share one asset (DrivingTest’s three highway billboards below all point at the same texture). Leave assetKey off altogether for a text-only slot — a placement you compose yourself from the sponsor’s name, brand colour, one-line pitch and button label, with no file involved. It sells, serves and bills like any other slot, and the sponsor is never asked to upload anything for it. Use that rather than declaring an asset you know you won’t render: the booking form asks for every asset you declare, so a placeholder gets collected from a paying sponsor and then never drawn. An optional role (intro, ambient, mark, outro, interstitial) is a platform-wide vocabulary for cross-app booking — skip it if you don’t need it, a slot sells and serves fine without one. A slot can also point at a preview surface — a screenshot of the real place with the ad’s footprint pinned onto it, so a sponsor sees their own creative composited in before they pay. That’s set up separately, once you’re registered, at /app/surfaces.
An asset declares the shape you want — aspect ratio, target width or duration, file formats, a size ceiling — never “whatever the sponsor happens to upload.” The ingest pipeline conforms their single high-resolution master to exactly that shape. It only rejects a file when no safe transform exists: the source is smaller than the minSourceWidth you set (upscaling looks bad), or a contain crop would need to stretch the aspect more than 3× to fit (a logo would come out unrecognisable). It never rejects a file merely for being “the wrong size.”
A field is anything besides a file: boolean, select, text, link, number, date, or color. A color field can declare contrastFloors — a WCAG-style legibility check run at submission, before moderation, before payment, so an illegible colour is rejected with a reason instead of silently booked and never delivered right.
Picking a pricing model
Every campaign type uses exactly one. All money is minor units (cents) under the hood; advertisers only ever see dollars.
| If… | Use | Because |
|---|---|---|
| a guaranteed, date-bound placement, sold regardless of traffic | flat / cpd | Same idea; cpd prices it per day, for variable-length flights. |
| the standard usage-based ad unit | cpm | Billed per confirmed impression, shown to sponsors as “$4 CPM.” |
| a click is a real, instrumented action in your app | cpc | Skip it if nothing actually happens on click — it’s not a proxy for attention. |
| one price per genuine session the ad was actually shown in | cps | Immune to multi-slot inflation by construction — five slots filled in one session still bill once. |
| selling a guaranteed share of decisions, several sponsors running at once | sov | A delivery guarantee (“50% of matching requests for two weeks”), not a consumption unit — and it can’t pair with an exclusive slot (below). |
Shared vs. exclusive
A slot’s exclusivity defaults to shared — several campaigns can win it over time. Make a slot exclusive_per_slot only when that’s a real product fact (“this card back can only show one logo at a time”), not a pricing tactic to charge more — the seeded “Exclusive Route Takeover” fixture (packages/db/src/seed.ts) is a real example: flat pricing, $500 per 7–30 day flight, one slot, all to one sponsor. maxConsecutiveRenewals (default 2) and bookingHorizonDays (default 90) are the guardrails that stop “exclusive sponsorship” from becoming “permanent lockout” — the same sponsor can’t just keep renewing forever, and can’t book more than 90 days out.
The schema enforces two combinations you can’t ship, not just advises against:
- a slot can’t be both
exclusive_per_slotand carry adefaultShareOfVoicePct— it can’t mean “you alone” and “you get 50%” at once. sovpricing requiresshareddelivery — a guaranteed share only means something when there’s a whole to share.
Two more delivery knobs worth knowing early: dedupeWindowSec (default 30) collapses confirmations for the same campaign and session inside that window into one billable impression — it’s about the same moment. frequencyCap (e.g. { count: 4, perHours: 24 }) is about repetition across time instead — how often the same viewer can see it at all.
Four worked examples
3D game-world billboard
Three billboards along a driving-test route, sharing one 16:9 image asset (video optional). cpm at $4.00, $100 minimum budget, geo multipliers for US/CA traffic and a volume discount past $5,000 spend. shared delivery, dedupeWindowSec: 30, frequencyCap: { count: 4, perHours: 24 } so the same driver doesn’t see it more than 4 times a day.
Mobile app banner + interstitial
Two distinct slots, priced differently because they cost the sponsor different amounts of attention. A persistent bottom banner: cpm, shared, role: ambient, a gentle frequency cap — it’s always there, easy to ignore. A between-screens interstitial: also cpm but at a materially higher rate, and a tighter frequencyCap since it’s more intrusive and needs to earn its interruption. Both use cropStrategy: "smart" on the image asset so a phone-shaped crop doesn’t cut through a logo.
Newsletter / content sponsorship
flat or cpd, not cpm — there’s no reliable per-open confirm signal for an email, so a metered model would be pricing something you can’t actually count. One slot, role: mark or outro, a small 3:1 logo asset with cropStrategy: "contain" so it letterboxes instead of cropping through a wordmark, plus a link field and a short text field (capped with maxLength) for a one-line pitch. This is also the honest case for exclusive_per_slot: “presented by one sponsor per issue” is a real product fact for a newsletter, not a pricing trick.
Podcast / audio pre-roll
An audio asset (maxDurationSec, loudness normalized to a declared loudnessLufs so no sponsor blows out someone’s headphones). flat or cpd per episode is the honest default — a play count isn’t a confirmed listen. cps is the harder-but-more-honest option if your player can genuinely instrument “playback continued past the spot.” Two slots via role: intro (pre-roll) and role: interstitial (mid-roll) sharing one asset, so one sponsor can buy both without uploading twice.
Rules the schema enforces for you
Not advice — the platform stops you from shipping these:
- no two fields, assets, or slots may share a key.
- every slot’s
assetKey, where it has one, must reference an asset you actually declared — a slot with noassetKeyis a text-only placement, which is allowed. - an image asset with
powerOfTwoon must derive power-of-two dimensions at its declared aspect and width, or the combination is rejected up front — before a single upload hits it. - the two exclusivity/
sovconflicts above.
Where this becomes real
Start a campaign type once you’ve registered — the guided editor walks these same decisions and validates against this exact schema at every step. Or skip straight to “Whole document as JSON” in the sidebar, paste a complete document in one shot, and click “Apply to all sections” then “Create campaign type.” Either way validates against the identical rules, so nothing you build can drift from what’s described here.