Skip to content
Merchandising · 2026-04-22 · 6 min read

Promo banners that know when to fire.

A shopper searching for "sneaker" and a shopper searching for "wedding dress" shouldn't see the same banner. A flash sale that ended last night shouldn't still be bannered. Trooply's promo banners scope themselves so you stop having to remember.

The shape of a banner

A banner is a small record — title, optional body, optional CTA text and URL, optional image, optional colour overrides — plus three routing fields: position, scope_type, and (for non-always scopes) a scope_value. Optional start_at / end_at give you a self-expiring campaign.

POST /v1/promo-banners
{
  "title": "Sitewide 10% off",
  "body":  "This weekend only — auto-applied at checkout.",
  "cta_text": "Shop the sale",
  "cta_url":  "https://example.com/sale",
  "background_color": "#1E8F3E",
  "foreground_color": "#FFFFFF",
  "position": "top",
  "scope_type": "always",
  "priority": 50,
  "start_at": "2026-04-25T00:00:00Z",
  "end_at":   "2026-04-28T00:00:00Z"
}

Scope: the "when to fire" field

Four scopes, borrowed directly from merchandising rules so the mental model carries over:

  • always — shown on every search. Use sparingly; sitewide banners earn their place.
  • query_exact — shown only when the query string equals the scope value (case-insensitive).
  • query_contains — shown when the query contains the scope value as a substring. "sneaker drop" banner fires on "sneakers", "white sneaker 42", "puma sneakers for women".
  • category_match — shown when at least one result in the response has metadata.category equal to the scope value. This is the one merchants underuse: it lets a "Free shipping on books" banner appear whenever results happen to be books, even if the shopper query was "best-sellers" or a visual search.

Position: where the storefront renders it

We don't style the banner — that's your storefront's job. What we do is return the banner ordered with a position hint so the frontend can place it predictably:

  • top — above the results grid. Best for sitewide and most query-triggered banners.
  • middle — spliced between rows 4 and 5 in the drop-in widget. Useful for long result grids where a sitewide-top banner would be forgotten.
  • bottom — below the results. Usually for "browse the full collection" CTAs.

The drop-in widget renders all three positions; custom storefronts can use or ignore the hint.

The three-banner cap (and why)

No matter how many active banners match a query, a response carries at most three. The priority field (ascending — lower wins) decides which three. This is a product decision, not a technical limit — more than three banners reliably looks like an ad graveyard and trains shoppers to ignore the whole region.

If you have four banners that all match "sneaker", the top three by priority show up; the fourth is dropped for that response. Come Monday when one expires and a fifth kicks in, the slot reopens automatically.

What the response looks like

Banners are tacked onto the normal search response as a top-level promo_banners array. Nothing about results changes.

{
  "query_time_ms": 142,
  "count": 10,
  "results": [...],
  "promo_banners": [
    {
      "id": "...",
      "title": "Sitewide 10% off",
      "body":  "This weekend only — auto-applied at checkout.",
      "cta_text": "Shop the sale",
      "cta_url":  "https://example.com/sale",
      "background_color": "#1E8F3E",
      "foreground_color": "#FFFFFF",
      "position": "top"
    }
  ]
}

The shape is intentionally narrow — we don't expose scope internals, descriptions, or audit fields to the storefront, only what it needs to render.

Detail

Banners are resolved on every search, including on cache hits. The result cache only stores the results array — banners are re-computed fresh so a campaign that ended 30 seconds ago doesn't haunt a still-warm cache entry.

Three-rule example

A realistic setup uses all three scopes together:

  1. Sitewide, top, priority 50: "Free shipping over $75." scope_type: always.
  2. Sneaker drop, middle, priority 100: "New kicks, just in." scope_type: query_contains, scope_value: "sneaker".
  3. Books-category, bottom, priority 200: "Earn double loyalty points on Books." scope_type: category_match, scope_value: "Books".

A shopper searching "sneaker book" sees the sitewide top banner, the sneaker middle banner between rows 4 and 5, and — if the result set happens to include book-category items — the Books bottom banner. All self-scoping, all expiring when you told them to.

Operational notes

  • 60-second cache, invalidated on write. Any create / update / delete via the API or the portal clears the cache for that tenant immediately. Scheduled banners still wait for start_at to pass.
  • Colour is optional. Leave background_color and foreground_color null and the widget uses your theme defaults. Set hex values to override per banner.
  • CTA is all-or-nothing. cta_text and cta_url must be set together or both null. Half a CTA confuses the widget layout.
  • Portal UI: /portal/promos has a full editor with a colour swatch in the list view so you can eyeball which banners are active without opening each row.

Where next

Banners render wherever your search response lands — but the fastest way to get search + banners in front of shoppers is the drop-in widget. And the drop-in widget depends on one thing: never leaking your server secret into browser source. That's public-key auth.