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 */
}Ask what to show
One request per app, cached at the edge.
/api/serve?app=YOUR_APP_IDReturns 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:
| Field | Type | Description |
|---|---|---|
id | string | Stable key. A creative UUID, or a house entry slug. |
name | string | App name, up to 40 characters. |
url | string | Where the card links. Always https. |
domain | string | Bare hostname. The logo and accent colour are resolved from it. |
tagline | string | Four or five words, under the name. |
body | string | One sentence, up to 160 characters. |
accent | string | Tailwind text colour pair for the name, resting then hover. A fallback only. |
monogram | string | One letter, for when no logo file resolves. |
appId | string? | The SparkPay app_id, present on house entries whose slug differs from it. |
paid | boolean? | True on the one paid card, when this hour is sold, approved and paying. |
discount | object? | 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:
| Field | Type | Description |
|---|---|---|
minCards | number | Fewest cards to show, held even on a phone. Currently 2. |
maxCards | number | Most to show, however wide the container gets. Currently 5. |
minCardWidth | number | Pixels a card needs before another column is worth adding. |
gap | number | Pixels 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.
Cache and CORS
Five minutes on the CDN, and open to every origin.
| Field | Type | Description |
|---|---|---|
Cache-Control | header | public, s-maxage=300, stale-while-revalidate=600 |
Access-Control-Allow-Origin | header | * 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.
Report what happened
Fire and forget. No auth, tiny body, 204 back.
/api/tallyCounts 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.
| Field | Type | Description |
|---|---|---|
apprequired | string | Your SparkPay app_id. Slug characters only, 40 max. |
creativeIdsrequired | string[] | The ids you are reporting on, up to 50. Each one is counted separately. |
creativeId | string? | A single id instead of the list. The older shape, and it keeps working. |
eventrequired | string | Exactly "impression" or "click". Anything else is a 400. |
source | string? | 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.
| Field | Type | Description |
|---|---|---|
204 | status | Counted, or quietly dropped. Nothing back. |
400 | status | Missing field, unknown event, an id that is not slug or UUID shaped, or more than 50 of them. |
429 | status | Over 300 requests a minute from one IP. |
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.
One hour, every dashboard
Twenty four hours in the day, one advertiser in each. $9 a month while you hold yours.