API for apps

One script tag if you want the carousel we maintain, or two endpoints if you would rather draw it yourself. No authentication and no SDK either way.

Embed the unit

Two lines, and nothing to keep up to date.

Anywhere in your page

<script src="https://sparkads.dev/embed.js" defer></script>

<spark-ads-carousel app="YOUR_APP_ID" title="While you are here"></spark-ads-carousel>

app is your SparkPay app id — the key your numbers are filed under, and how the network knows not to hand you your own advert. title is the heading above the carousel and label names it for assistive tech; both are optional. Impressions and clicks are counted for you.

The element renders in a shadow root, so its styles cannot reach your page and yours cannot reach it. Custom properties are the exception, which makes them the whole theming surface:

Theming

spark-ads-carousel {
  --carousel-accent: #14b8a6;   /* the active dot and the arrows' hover tint */
  --carousel-surface: #0f172a;  /* the arrows' background */
}
This is the recommended path
The bundle is built from the same components this site draws its own cards with, so a change to the card, its colours or its density reaches you on our next deploy rather than your next release. Ten apps used to keep a copy of that card, and a discount ribbon added after those copies were taken rendered on none of them.

Ask what to show

One request per app, cached at the edge.

GET/api/serve?app=YOUR_APP_ID

Returns the cards this app should render right now: the paid card for the hour that is running, if it is sold and approved and paying, then the house entries. Your own app is never in its own list.

Response

{
  "ads": [
    {
      "id": "b6f1c0a2-...",
      "name": "Notemill",
      "url": "https://notemill.com",
      "domain": "notemill.com",
      "tagline": "Notes that work offline",
      "body": "A notebook that keeps working when the connection does not.",
      "accent": "text-gray-300 group-hover:text-gray-100",
      "monogram": "N",
      "paid": true
    },
    {
      "id": "sellular",
      "name": "Sellular",
      "url": "https://sellular.online",
      "domain": "sellular.online",
      "tagline": "Get the company found",
      "body": "Submit your product to 30+ directories with pre-filled profiles.",
      "accent": "text-lime-400 group-hover:text-lime-300",
      "monogram": "S",
      "discount": { "percent": 40, "code": "LAUNCH40", "expiresAt": null }
    }
  ],
  "slot": {
    "forSale": false,
    "pricingUrl": "https://sparkpay.dev/spark/sparkads",
    "from": "$9/month"
  },
  "layout": {
    "minCards": 2,
    "maxCards": 5,
    "minCardWidth": 200,
    "gap": 16
  }
}

Each entry in ads:

FieldTypeDescription
idstringStable key. A creative UUID, or a house entry slug.
namestringApp name, up to 40 characters.
urlstringWhere the card links. Always https.
domainstringBare hostname. The logo and accent colour are resolved from it.
taglinestringFour or five words, under the name.
bodystringOne sentence, up to 160 characters.
accentstringTailwind text colour pair for the name, resting then hover. A fallback only.
monogramstringOne letter, for when no logo file resolves.
appIdstring?The SparkPay app_id, present on house entries whose slug differs from it.
paidboolean?True on the one paid card, when this hour is sold, approved and paying.
discountobject?A live public coupon: percent, code, expiresAt. House entries only.

The slot object is what a consumer needs to decide whether to append its own "your app here" card: forSale is true whenever this hour has no paid ad in it, and pricingUrl is where to send whoever clicks.

The layout object is how many cards to show at once, described as a fit rather than as breakpoints:

FieldTypeDescription
minCardsnumberFewest cards to show, held even on a phone. Currently 2.
maxCardsnumberMost to show, however wide the container gets. Currently 5.
minCardWidthnumberPixels a card needs before another column is worth adding.
gapnumberPixels between cards. Part of the fit, so it is served with it.

Measure the carousel's own container, not the window — the unit is as wide as the column it sits in, not as wide as the screen — then show clamp(minCards, floor((width + gap) / (minCardWidth + gap)), maxCards) cards. The reference Carousel takes this object as a prop and does exactly that.

Pass it through rather than hardcoding it
These numbers are served so the whole network can be retuned from one deploy. An app that copies today's values into its own source stops getting that, and drifts the first time the card design changes.

Cache and CORS

Five minutes on the CDN, and open to every origin.

FieldTypeDescription
Cache-Controlheaderpublic, s-maxage=300, stale-while-revalidate=600
Access-Control-Allow-Originheader* on both endpoints

Every consumer app is another origin and the payload is public inventory, so the response is wide open on purpose. There is no credential to leak because there is no credential.

An ad can start late
A five minute cache means a new hour's card can take up to five minutes to appear. That is the honest price of not hitting the database on every pageview across the whole network, and it is why the hour is the unit rather than the minute.
Fetch it once per page, not per render
One call on mount is enough. The response is stable for the whole visit, and the cache means a second call inside the window returns the same bytes anyway.

Report what happened

Fire and forget. No auth, tiny body, 204 back.

POST/api/tally

Counts an impression or a click against every creative in the body. Counts land in an hourly aggregate row keyed by hour, app, creative and source, so this is an increment rather than an event log.

FieldTypeDescription
apprequiredstringYour SparkPay app_id. Slug characters only, 40 max.
creativeIdsrequiredstring[]The ids you are reporting on, up to 50. Each one is counted separately.
creativeIdstring?A single id instead of the list. The older shape, and it keeps working.
eventrequiredstringExactly "impression" or "click". Anything else is a 400.
sourcestring?Either "sellular" or "direct". Anything else becomes "network".

From a consumer carousel

navigator.sendBeacon(
  'https://sparkads.dev/api/tally',
  JSON.stringify({
    app: 'tax-ducks',
    creativeIds: ['b6f1c0a2-...', 'sellular'],
    event: 'impression',
    source: 'direct'
  })
);

The body is parsed as text, not by content type, so a sendBeacon with its text/plain default stays a simple request and skips the preflight. A normal fetch with JSON headers works the same.

One request per pageview, not per card
A carousel draws every card it was served at once, so report them in one body. Ten beacons for one view spend ten times the rate limit on it, and on any page running an ad blocker they are ten blocked requests in the console instead of one.
FieldTypeDescription
204statusCounted, or quietly dropped. Nothing back.
400statusMissing field, unknown event, an id that is not slug or UUID shaped, or more than 50 of them.
429statusOver 300 requests a minute from one IP.
A dropped count is not an error
If the write fails the endpoint still answers 204. A public counter that returns a 500 into a beacon nobody reads has cost you the impression and told you nothing, so it does not.

Discounts on the cards

Resolved once on the network, never hardcoded by a consumer.

A house entry can carry a discount, which the serve route resolves from SparkPay and caches for fifteen minutes. Consumers never look coupons up themselves: that would be ten apps asking the same question, and a percentage baked into a fallback array outlives the sale it advertises.

Unlisted coupons never appear here
The route reads SparkPay's public coupon list, which returns only the codes marked listed. A private code, such as one issued for a seeding programme, is not in that response and therefore cannot reach a card. Tier-scoped codes are dropped too, because "40% off" on a card has to be true of whatever the reader ends up buying.

One hour, every dashboard

Twenty four hours in the day, one advertiser in each. $9 a month while you hold yours.

See which hours are free