API

← Tacswap Decision API

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:

  1. Render the creative (assetKey -> CDN image, or html for rich/custom HTML).
  2. Fire the impression beacon when the ad becomes visible (load beacons.impression as a 1x1 pixel).
  3. Make the ad clickable using beacons.click as 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

EnvironmentBase URL
Productionhttps://decision.tacswap.com
Local devhttp://localhost:3001

Always use the absolute beacons.impression / beacons.click URLs 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_TOKEN is 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
  }
}
FieldTypeRequiredNotes
placementstringyesThe placement key (see Placements).
channel"marketplace" | "email"no (default marketplace)Tags the resulting beacons only. Does not change which ad is selected.
context.userIdstringnoStable per-user id. Pass it so per-user frequency caps work.
context.ipstringnoFalls back to x-forwarded-for.
context.userAgentstringnoFalls back to the User-Agent request header.

context is used only to compute a non-reversible userHash (SHA-256 of userId|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:

StatusBodyMeaning
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.

Seeded/example keys:

KeyKindDescription
category:opticscategory_sponsorshipSponsorship of the optics category.
email:weekly-dealsemail_slotSlot in the weekly deals newsletter.
takeover:homepagesection_takeoverHomepage 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 capacity is 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:

  1. Placement is active. The placement key exists and is active.
  2. Booking is live. Only bookings with status live are served. Other statuses (reserved, paused, completed, cancelled) are skipped.
  3. In flight window. The booking's [startDate, endDate] range includes today (UTC). Outside that window it is not served.
  4. Creative is approved. Only creatives with status approved are served.
  5. 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 per userHash, so you must pass a stable context.userId (and/or ip/userAgent) for capping to apply. If you send no context, frequency caps cannot be enforced for that request.

Among eligible bookings, selection is:

Not supported (do not build around these)

The Decision API does not implement:

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:

  1. Render the creative according to creative.format:
    • image / video: load the asset referenced by creative.assetKey from object storage / CDN, sized to creative.width x creative.height.
    • rich / custom_html: inject creative.html (the assetKey may be null).
  2. Fire the impression beacon when the unit renders / becomes visible — e.g. <img src="{beacons.impression}"> or a fetch(beacons.impression).
  3. Wrap the ad in the click beacon — use beacons.click as the href / link target so clicks are tracked and the user is redirected.

Beacon properties:

clickThroughUrl is 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 to beacons.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

PathTypeRequiredDefaultPurpose
placementstringyesPlacement key to fill.
channelenumnomarketplaceBeacon tag only.
context.userIdstringnoFrequency-cap identity.
context.ipstringnox-forwarded-forFrequency-cap identity.
context.userAgentstringnoUser-Agent headerFrequency-cap identity.

Response fields (filled)

PathTypeNotes
filledbooleantrue when an ad was selected.
bookingIduuidSelected booking (attribution).
placementIduuidResolved placement.
creativeIduuidSelected creative.
creative.formatenumimage | video | rich | custom_html.
creative.widthnumber | nullPixel width.
creative.heightnumber | nullPixel height.
creative.htmlstring | nullInline markup for rich/custom_html.
creative.assetKeystring | nullObject-storage key; resolve to a CDN URL.
beacons.impressionurlAbsolute, signed 1x1-pixel URL.
beacons.clickurlAbsolute, 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


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