Dashboard

API for apps

Two endpoints, no authentication, no SDK. One asks what to show, the other says what happened. If your app already renders a hardcoded array of cross-promotion cards, the only change is where the array comes from.

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"
  }
}

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.

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/track

Counts one impression or one click. The count lands 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.
creativeIdrequiredstringThe id from the ad you are reporting on.
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/track',
  JSON.stringify({
    app: 'tax-ducks',
    creativeId: 'b6f1c0a2-...',
    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.

FieldTypeDescription
204statusCounted, or quietly dropped. Nothing back.
400statusMissing field, unknown event, or an id that is not slug or UUID shaped.
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