Decision API — Integration Guide
Audience: an AI coding agent (or engineer) wiring the Tacswap marketplace UI to ad placements.
You are integrating the Tacswap marketplace with direct-sold ad placements. This document is the complete, self-contained contract — you do not need to read the Decision API source to integrate against it.
"Direct-sold" means ad selection is reservation + priority + share-of-voice (SOV) + frequency cap. There is no auction, no RTB, no bidding, no geo targeting, and no dayparting. You request an ad for a named placement; the API picks a booked ad (if any) and returns the creative plus signed tracking beacons that you must fire.
Quickstart
# 1. Ask the Decision API to fill a placement (server-to-server, authenticated)
curl -s -X POST "https://decision.tacswap.com/api/decision" \
-H "Authorization: Bearer $DECISION_SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"placement":"category:optics","context":{"userId":"user-123"}}'
// 2. Response (filled). Render the creative, then fire the beacons.
{
"filled": true,
"creative": { "format": "image", "width": 970, "height": 250, "assetKey": "demo/vortex-hero.png", "html": null },
"beacons": {
"impression": "https://decision.tacswap.com/b/imp?t=<signed>",
"click": "https://decision.tacswap.com/b/click?t=<signed>"
}
// ...ids omitted for brevity
}
Then in the marketplace UI:
- Render the creative (
assetKey-> CDN image, orhtmlfor rich/custom HTML). - Fire the impression beacon when the ad becomes visible (load
beacons.impressionas a 1x1 pixel). - Make the ad clickable using
beacons.clickas the link target. It records the click and 302-redirects the user to the advertiser's landing page.
If filled is false, render nothing and collapse the slot.
Base URL & environments
| Environment | Base URL |
|---|---|
| Production | https://decision.tacswap.com |
| Local dev | http://localhost:3001 |
Always use the absolute
beacons.impression/beacons.clickURLs exactly as returned in each response. They are fully-qualified, signed, and single-use. Never reconstruct, cache across requests, or mutate them.
Authentication
POST /api/decision requires the shared service token. Provide it via either
header:
Authorization: Bearer <DECISION_SERVICE_TOKEN>
# or
x-service-token: <DECISION_SERVICE_TOKEN>
Server-to-server only. The
DECISION_SERVICE_TOKENis a secret and must never ship to the browser or any client-side bundle. The marketplace backend calls the Decision API and passes the resulting creative + beacon URLs down to the client. The beacon URLs themselves are safe to expose to the browser (they are signed and carry no secret).
The beacon endpoints (/b/imp, /b/click) and /api/health do not require
the service token — they are authenticated by the HMAC signature embedded in the
t query parameter.
Endpoints
POST /api/decision — request an ad for a placement
Headers: Content-Type: application/json + service token (see Authentication).
Request body:
{
"placement": "category:optics", // REQUIRED — the placement key
"channel": "marketplace", // optional — "marketplace" (default) | "email"
"context": { // optional — used only to derive a frequency-cap identity
"userId": "stable-user-id", // optional — your stable user id (recommended)
"ip": "1.2.3.4", // optional — falls back to the x-forwarded-for header
"userAgent": "Mozilla/5.0 ..." // optional — falls back to the User-Agent header
}
}
| Field | Type | Required | Notes |
|---|---|---|---|
placement | string | yes | The placement key (see Placements). |
channel | "marketplace" | "email" | no (default marketplace) | Tags the resulting beacons only. Does not change which ad is selected. |
context.userId | string | no | Stable per-user id. Pass it so per-user frequency caps work. |
context.ip | string | no | Falls back to x-forwarded-for. |
context.userAgent | string | no | Falls back to the User-Agent request header. |
contextis used only to compute a non-reversibleuserHash(SHA-256 ofuserId|ip|userAgent) for frequency capping. No raw PII is stored.
Response — filled (HTTP 200):
{
"filled": true,
"bookingId": "uuid", // attribution ids — log them if useful, otherwise ignore
"placementId": "uuid",
"creativeId": "uuid",
"creative": {
"format": "image", // "image" | "video" | "rich" | "custom_html"
"width": 970, // pixels (may be null)
"height": 250, // pixels (may be null)
"html": null, // inline markup for "rich"/"custom_html"; null otherwise
"assetKey": "demo/vortex-hero.png" // object-storage key for image/video; null for pure HTML
},
"beacons": {
"impression": "https://decision.tacswap.com/b/imp?t=<signed-token>",
"click": "https://decision.tacswap.com/b/click?t=<signed-token>"
}
}
Response — unfilled (HTTP 200):
{ "filled": false }
A 200 { "filled": false } is normal and expected — it means there is no live,
in-window, under-cap booking with an approved creative for that placement right
now. Treat it as "show no ad", not as an error.
Error responses:
| Status | Body | Meaning |
|---|---|---|
401 | { "error": "unauthorized" } | Missing/invalid service token. |
400 | { "error": "invalid_request", "detail": "..." } | Body failed validation (e.g. missing placement). |
GET /b/imp?t=<token> — impression beacon
Records an impression and returns a 1x1 transparent GIF (HTTP 200). Always returns the pixel, even for an invalid/expired token, so the page never shows a broken image. Fire this when the ad is rendered/visible.
GET /b/click?t=<token> — click beacon
Records a click and 302-redirects to the creative's click-through URL
(resolved server-side — there is no open redirect). Use this URL as the ad's link
target. If the token is invalid/expired, it falls back to https://tacswap.com.
GET /api/health — liveness
Returns { "status": "ok", "service": "decision", "ts": <epoch-ms> }. No auth.
Useful for readiness checks.
Placements (the "slots" you fill)
A placement is a named inventory unit. You select one by passing its key
string in the request. There is no separate "zone" or "ad unit" concept — the
placement key is the only identifier you need.
- Key convention:
{scope}:{identifier}, e.g.category:optics. - Kinds:
category_sponsorship,email_slot,section_takeover,high_fidelity. (Informational — you select by key, not by kind.)
Seeded/example keys:
| Key | Kind | Description |
|---|---|---|
category:optics | category_sponsorship | Sponsorship of the optics category. |
email:weekly-deals | email_slot | Slot in the weekly deals newsletter. |
takeover:homepage | section_takeover | Homepage takeover unit. |
The real set of placement keys lives in the database and is managed in the portal. Ask ad-ops for the current list rather than hardcoding beyond what you need. An unknown or inactive placement key simply returns
{ "filled": false }.Note: a placement's
capacityis its share-of-voice capacity, not a count of physical on-page slots. One decision request fills one ad.
Scheduling & eligibility — what governs whether you get an ad
You don't schedule anything from the client; scheduling is enforced server-side at selection time. Understanding the rules tells you why a placement is filled or empty, and what you must pass for caps to work. An ad is eligible only if all of the following hold:
- Placement is active. The placement key exists and is
active. - Booking is live. Only bookings with status
liveare served. Other statuses (reserved,paused,completed,cancelled) are skipped. - In flight window. The booking's
[startDate, endDate]range includes today (UTC). Outside that window it is not served. - Creative is approved. Only creatives with status
approvedare served. - Under frequency cap. If the booking has
frequencyCapPerDay, the user must not have already hit that many impressions for that booking today (UTC). This is counted peruserHash, so you must pass a stablecontext.userId(and/orip/userAgent) for capping to apply. If you send nocontext, frequency caps cannot be enforced for that request.
Among eligible bookings, selection is:
- Priority tiers: the highest
priorityvalue wins; lower-priority bookings are excluded from the draw entirely. - Share-of-voice rotation: within the top priority tier, one booking is chosen
by weighted random using each booking's
shareOfVoice(1–100). This means repeated requests for the same placement legitimately rotate between competing sponsors.
Not supported (do not build around these)
The Decision API does not implement:
- Dayparting (hour-of-day / day-of-week targeting)
- Delivery pacing / impression smoothing
- Geo or audience targeting
- Channel-based inventory filtering —
channelonly tags the beacons; it does not restrict which ad is selected.
If you need any of these, that is an ad-ops/booking concern, not something the client request can control.
Rendering & beacon obligations (required of the caller)
For every filled response you MUST:
- Render the creative according to
creative.format:image/video: load the asset referenced bycreative.assetKeyfrom object storage / CDN, sized tocreative.widthxcreative.height.rich/custom_html: injectcreative.html(theassetKeymay be null).
- Fire the impression beacon when the unit renders / becomes visible — e.g.
<img src="{beacons.impression}">or afetch(beacons.impression). - Wrap the ad in the click beacon — use
beacons.clickas thehref/ link target so clicks are tracked and the user is redirected.
Beacon properties:
- Signed: HMAC-SHA256; tampering invalidates them.
- Single-use: deduped via an embedded nonce, so a refresh/replay is ignored.
- Expiring: valid for 7 days from issue.
clickThroughUrlis intentionally not returned in the decision response. The destination is resolved server-side when the click beacon is hit, which prevents open-redirect abuse. Always link tobeacons.click, never to a raw advertiser URL.
End-to-end integration recipe
1. Backend: a thin decision proxy (keeps the token server-side)
// marketplace backend — never expose DECISION_SERVICE_TOKEN to the browser
type Decision =
| { filled: false }
| {
filled: true;
bookingId: string;
placementId: string;
creativeId: string;
creative: {
format: "image" | "video" | "rich" | "custom_html";
width: number | null;
height: number | null;
html: string | null;
assetKey: string | null;
} | null;
beacons: { impression: string; click: string };
};
export async function getAd(
placement: string,
userId?: string,
): Promise<Decision> {
const res = await fetch(`${process.env.DECISION_API_URL}/api/decision`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.DECISION_SERVICE_TOKEN}`,
},
body: JSON.stringify({
placement,
channel: "marketplace",
context: userId ? { userId } : undefined,
}),
// ad fill should never block the page; fail soft on timeout/error
});
if (!res.ok) return { filled: false };
return (await res.json()) as Decision;
}
2. Client: render the creative + fire beacons
// React example for an image creative; collapses the slot when unfilled
function AdSlot({ ad }: { ad: Decision }) {
if (!ad.filled || !ad.creative) return null;
const { creative, beacons } = ad;
if (creative.format === "rich" || creative.format === "custom_html") {
return (
<a href={beacons.click} target="_blank" rel="noopener noreferrer">
{/* host-controlled HTML from an approved creative */}
<div dangerouslySetInnerHTML={{ __html: creative.html ?? "" }} />
<img src={beacons.impression} width={1} height={1} alt="" aria-hidden />
</a>
);
}
return (
<a href={beacons.click} target="_blank" rel="noopener noreferrer">
<img
src={cdnUrl(creative.assetKey)} // resolve assetKey -> your CDN/object-store URL
width={creative.width ?? undefined}
height={creative.height ?? undefined}
alt=""
onLoad={() => navigator.sendBeacon?.(beacons.impression)}
/>
{/* fallback impression pixel if sendBeacon is unavailable */}
<img src={beacons.impression} width={1} height={1} alt="" aria-hidden />
</a>
);
}
Fire the impression beacon exactly once per render. Because beacons are deduped server-side, an accidental double-fire is harmless, but avoid firing it for ads that never actually become visible.
Field reference
Request fields
| Path | Type | Required | Default | Purpose |
|---|---|---|---|---|
placement | string | yes | — | Placement key to fill. |
channel | enum | no | marketplace | Beacon tag only. |
context.userId | string | no | — | Frequency-cap identity. |
context.ip | string | no | x-forwarded-for | Frequency-cap identity. |
context.userAgent | string | no | User-Agent header | Frequency-cap identity. |
Response fields (filled)
| Path | Type | Notes |
|---|---|---|
filled | boolean | true when an ad was selected. |
bookingId | uuid | Selected booking (attribution). |
placementId | uuid | Resolved placement. |
creativeId | uuid | Selected creative. |
creative.format | enum | image | video | rich | custom_html. |
creative.width | number | null | Pixel width. |
creative.height | number | null | Pixel height. |
creative.html | string | null | Inline markup for rich/custom_html. |
creative.assetKey | string | null | Object-storage key; resolve to a CDN URL. |
beacons.impression | url | Absolute, signed 1x1-pixel URL. |
beacons.click | url | Absolute, signed click+redirect URL. |
Not returned: clickThroughUrl (resolved server-side on click) and full asset
CDN URLs (you resolve assetKey against your object store).
Operational notes
- Fail soft. Ad fill should never break the page. On timeout, non-200, or
parse error, treat it as
{ "filled": false }and render nothing. - Idempotency / dedup. Beacons are single-use; the backend records each event
once (by nonce) in Postgres (
ad_events) and forwards best-effort to Tinybird for reporting at scale. - Email channel caveat. For
channel: "email", the same beacons embed in the Mailchimp creative. The impression pixel is unreliable on email (Apple Mail Privacy Protection pre-fetches it), so clicks are the trustworthy metric. See docs/PROJECT.md section 11. - Source of truth. This guide is the integration contract for external consumers. The terse internal version is apps/decision/CONTRACT.md; product/architecture context is in docs/PROJECT.md.
Request flow
sequenceDiagram
participant UI as Marketplace UI
participant BE as Marketplace Backend
participant DA as Decision API
participant User as End User
UI->>BE: render page (needs ad for "category:optics")
BE->>DA: POST /api/decision (Bearer token, placement, context)
DA-->>BE: { filled, creative, beacons } or { filled: false }
BE-->>UI: creative + beacon URLs (no service token)
UI->>User: render creative
UI->>DA: GET /b/imp?t=... (on visible)
DA-->>UI: 1x1 GIF
User->>DA: GET /b/click?t=... (on click)
DA-->>User: 302 redirect to advertiser landing page