Skip to content
TilloTech Docs

ADR-1: Promotions endpoint

Date: 10/04/2026 Author(s): Sam Sharma-Bell

Status

Accepted

Context

Our Buyers requested a way to manage upcoming and live promotions via the V2 API, and some of those Buyers indicated that this would unlock significant sales volume increases. Prior to the promotion endpoint Buyers were notified about promotions via email and Account Management. In the V1 API we surfaced live and upcoming promotions via the api/brands endpoint; there is no documentation clarifying why this wasn't included in the api/v2/brands endpoint.

We decided not to add this to the api/v2/brands endpoint as all Buyers are integrated to it and their integrations handle the current response shape (calling the endpoint with ?details=true has an expectation to return all info).

The promotion domain is still not completely fixed. In the future we may have new promotion types, so giving it a separate endpoint gave us some more flexibility. The separation saves us notifying all Buyers about future changes to the promotion domain instead of just those who are integrated to the api/v2/promotions endpoint.

We also needed to exclude certain promotions from the endpoint. Budget-constrained promotions (those with a face_value_limit set) are not surfaced because they are used for internal budget tracking and may not reflect what a Buyer can actually use. Open-loop products (Reward Pass via the SWIFT and HAWK_MARKETPLACE_OPEN_LOOP issuers) are also excluded as they are not really promotions in the domain sense they just use the promotions harness to manage discounts.

Decision

We built the GET api/v2/promotions endpoint to surface upcoming and live promotions to Buyers. The endpoint only returns promotions that are approved, active, not cancelled, and not expired. It also checks that the Buyer is enrolled in the promotion, that the promotion's brands overlap with the Buyer's brand relationships, and that at least one transaction type is shared between the promotion and the Buyer.

We set a rate limit of 50 requests per minute (configurable via the API_V2_PROMOTIONS_RATE_LIMIT environment variable), consistent with other informational V2 endpoints. The endpoint inherits the V2 middleware stack and auth process.

We implemented per-partner caching with a 1-hour TTL. Each Buyer gets their own cache entry keyed by their partner slug because discount rates, brand access, and transaction type access all vary per Buyer. Cache invalidation is handled via model hooks: when a promotion is edited the cache is cleared for all partners enrolled in that promotion, and when a flash_sales_partners record is created, updated, or deleted the cache is cleared for that specific partner.

We introduced a last_edited_at column on both the flash_sales and flash_sales_partners tables. The existing updated_at column changes on every save, including when operational fields like sales_count and face_value_total are incremented during normal transaction processing. These changes are not meaningful to Buyers. The last_edited_at column only updates when fields that Buyers care about change (e.g. name, description, start_at, end_at, discount, priority). The API returns the most recent last_edited_at between the flash_sales record, and flash_sales_partners record falling back to updated_at if last_edited_at is null.

We wrapped the promotions in the response under a standard key. This gives us room to introduce new promotion types in the future (e.g. budgeted, tiered) without breaking existing integrations. Promotions are grouped by brand slug and ordered by end date ascending so Buyers can prioritise promotions that are ending soonest. A single promotion can appear under multiple brand slugs if it covers multiple brands.

Consequences

Positive

  • Buyers can programmatically discover and act on promotions without relying on email or Account Management
  • Decoupling from the api/v2/brands endpoint means we can evolve the promotion domain without impacting all Buyers
  • Per-partner caching reduces database load and ensures Buyers only see their own data
  • The standard key in the response gives us a clear path for adding new promotion types without breaking changes
  • The last_edited_at field gives Buyers an accurate signal for when something meaningful changed, reducing unnecessary re-processing on their side
  • Targeted cache invalidation via model hooks keeps the cached data fresh without needing a scheduled purge

Negative

  • Cache invalidation logic is distributed across the FlashSale and FlashSalePartner models rather than being centralised, which adds cognitive overhead when modifying those models
  • We have a new endpoint to maintain, document, and support
  • The endpoint does not currently support filtering (e.g. by brand or date range), which Buyers may request in the future