Banners API Reference
Banners Module — API Reference
Audience: Frontend engineers, mobile engineers, API consumers integrating with the Banners module.
All endpoints are prefixed with /api. Admin endpoints require a valid JWT and the appropriate permission. Public serving and tracking endpoints are accessible without authentication (@Public() class decorator bypasses the global JwtAuthGuard) and are served under the /api/mobile/ prefix only.
Concepts and Terminology
| Term | Meaning |
|---|---|
publicId | A uuid7 string identifying a resource. Used in all URL paths and response bodies instead of integer IDs. |
slug | URL-safe identifier for a placement (e.g., homepage-hero). Used in the public serving endpoint. |
scheduleStatus | Banner activation state: evergreen / scheduled / active / expired. |
campaignStatus | Campaign lifecycle state: draft / scheduled / active / paused / ended / cancelled. |
targeting context | HTTP header values extracted from the client request to evaluate targeting rules. |
correlationId | Client-generated UUID to deduplicate impression/click events. Used as BullMQ jobId for tracking. |
paisa | NPR minor unit (100 paisa = 1 NPR). All monetary values in API use paisa (integer). |
Admin — Banner Endpoints
Base path: /api/admin/banners
Create Banner
POST /api/admin/bannersAuth: JWT + Banners_CREATE
Request body fields (see apps/api/src/modules/banners/admin/banners/dto/create-banner.dto.ts for full DTO):
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | Yes | Max 255 chars |
description | string | No | |
source | "internal" | "sponsored" | "affiliate" | "system" | No | Default internal |
primaryImageUrl | string | Yes | |
primaryImageAlt | string | No | Max 255 |
mobileImageUrl | string | No | |
videoUrl | string | No | |
mediaVariants | Record<string, string> | No | Per-locale/per-size variant URLs |
headline / subheadline | string | No | Max 255 |
ctaLabel | string | No | Max 100 |
ctaUrl | string (URL) | No | |
ctaOpenNewTab | boolean | No | Default false |
utmSource / utmMedium / utmCampaign / utmContent | string | No | Max 100 |
bgColor | string | No | Hex #RRGGBB |
priority | number | No | Default 0 |
seoId | string (UUID) | No | FK to SEO module |
publishAt / expiresAt | string (ISO 8601) | No | |
scheduleTimezone | string | No | IANA tz, default UTC |
recurrenceRule | string | No | |
recurrenceWindowStart / recurrenceWindowEnd | string (HH:MM:SS) | No | |
scheduleStatus | "evergreen" | "scheduled" | "active" | "expired" | No | Default evergreen |
isEvergreenFallback | boolean | No | |
fallbackPlaceholderUrl | string | No | |
targetKind | "product" | "category" | "brand" | "url" | No | Typed CTA target discriminator. See Typed CTA Target below. |
targetProductPublicId | string (UUID) | Conditional | Required when targetKind = "product" |
targetCategoryPublicId | string (UUID) | Conditional | Required when targetKind = "category" |
targetBrandPublicId | string (UUID) | Conditional | Required when targetKind = "brand" |
Response: 200 OK with ResponseDto<BannerAdminResponseDto>. Service sets createdBy from the JWT admin id.
Errors: BANNER_SEO_NOT_FOUND (404), BANNER_SCHEDULE_INVALID (400 — publishAt >= expiresAt), BANNER_TARGET_INVALID (400 — targetKind set without its matching id, an id set for a kind that isn't targetKind, or targetKind = "url" without ctaUrl).
List Banners
GET /api/admin/bannersAuth: JWT + Banners_READ
Query params (see fetch-banners-admin.dto.ts): page, size, sort (updatedAt/createdAt/priority/publishAt/expiresAt/title), order (asc/desc), search (title + description ILIKE), isActive (boolean), scheduleStatus, includeDeleted (boolean).
Response: 200 OK paginated ResponseDto<BannerAdminResponseDto[]> with pagination && { count, page, size }.
Banner payloads do not include catalog tag linkage — campaign/placement assignment still
controls where a banner can serve, and no endpoint accepts or returns tags. Banner payloads do
now carry an optional typed CTA target (targetKind + one of targetProductPublicId /
targetCategoryPublicId / targetBrandPublicId) — see Typed CTA Target.
Typed CTA Target
A banner's call-to-action can point at a product, category, or brand by id instead of a hand-typed
URL. target_kind / target_product_id / target_category_id / target_brand_id are nullable
columns on banners, guarded by the CHECK chk_banners_target_exactly_one
(packages/db/src/schema/banners/banners.ts) — exactly one of the four target-kind arms may match,
or all four may be null (untargeted, legacy behavior). The existing ctaUrl column is kept, not
replaced: for targetKind = "url" (or no target) it is the destination as before, and for a
product/category/brand target the serving layer populates it from the target's current slug so a
client reading only ctaUrl keeps working unchanged.
Resolution is by slug, not a stored URL. BannerServingService resolves the current slug for
the target's kind at serve time (apps/api/src/modules/banners/customer/serving/), so renaming a
product or category does not 404 an existing banner — the link updates automatically on the next
serve.
A soft-deleted target serves target: null and falls back to ctaUrl. The resolver excludes
soft-deleted rows (deletedAt IS NOT NULL) from its lookup; when a banner's target isn't found, the
response's target field is null and resolvedCtaUrl falls back to the banner's stored ctaUrl
rather than breaking the CTA.
Response shape (GET /api/mobile/banners/serve/:slug and admin read/list):
{
"targetKind": "product",
"target": { "kind": "product", "publicId": "...", "slug": "marshall-acton-iii" },
"ctaUrl": "/products/marshall-acton-iii"
}target is null when targetKind is null/"url", or when the typed target has been
soft-deleted.
Get Banner
GET /api/admin/banners/:publicIdAuth: JWT + Banners_READ
Update Banner
PATCH /api/admin/banners/:publicIdAuth: JWT + Banners_UPDATE
Partial update; invalidates list cache on success.
Soft-Delete Banner
DELETE /api/admin/banners/:publicIdAuth: JWT + Banners_DELETE
Errors: BANNER_ALREADY_DELETED (400 if already deleted).
Restore Banner
POST /api/admin/banners/:publicId/restoreAuth: JWT + Banners_UPDATE
Errors: BANNER_NOT_DELETED (400 if not deleted).
Update Schedule
PATCH /api/admin/banners/:publicId/scheduleAuth: JWT + Banners_UPDATE
Body: ScheduleBannerDto (scheduleStatus, publishAt, expiresAt, recurrence fields).
Activate / Deactivate
PATCH /api/admin/banners/:publicId/activate
PATCH /api/admin/banners/:publicId/deactivateAuth: JWT + Banners_UPDATE
Admin — Placement Endpoints
Base path: /api/admin/placements
| Action | Endpoint | Permission |
|---|---|---|
| List placements | GET /admin/placements | Placements_READ |
| Get placement | GET /admin/placements/:publicId | Placements_READ |
| Create placement | POST /admin/placements | Placements_CREATE |
| Update placement | PATCH /admin/placements/:publicId | Placements_UPDATE |
| Soft-delete placement | DELETE /admin/placements/:publicId | Placements_DELETE |
| Activate | PATCH /admin/placements/:publicId/activate | Placements_UPDATE |
| Deactivate | PATCH /admin/placements/:publicId/deactivate | Placements_UPDATE |
Request body fields (CreatePlacementDto): slug (unique), label, layoutType (10 enum values), pageContext, maxBanners (default 1), recommendedWidth/Height/AspectRatio, notes, isActive, sortOrder, fallbackPlaceholderUrl, allowPartialRender.
Errors: PLACEMENT_SLUG_EXISTS (409), PLACEMENT_HAS_ACTIVE_ASSIGNMENTS (409 on delete), PLACEMENT_NOT_FOUND (404).
Admin — Campaign Endpoints
Base path: /api/admin/campaigns
| Action | Endpoint | Permission |
|---|---|---|
| List campaigns | GET /admin/campaigns | Campaigns_READ |
| Get campaign | GET /admin/campaigns/:publicId | Campaigns_READ |
| Create campaign | POST /admin/campaigns | Campaigns_CREATE |
| Update campaign | PATCH /admin/campaigns/:publicId | Campaigns_UPDATE |
| Soft-delete campaign | DELETE /admin/campaigns/:publicId | Campaigns_DELETE |
| Restore campaign | POST /admin/campaigns/:publicId/restore | Campaigns_UPDATE |
| Schedule | PATCH /admin/campaigns/:publicId/schedule | Campaigns_UPDATE |
| Activate | PATCH /admin/campaigns/:publicId/activate | Campaigns_UPDATE |
| Pause | PATCH /admin/campaigns/:publicId/pause | Campaigns_UPDATE |
| End | PATCH /admin/campaigns/:publicId/end | Campaigns_UPDATE |
| Cancel | PATCH /admin/campaigns/:publicId/cancel | Campaigns_UPDATE |
Request body fields (CreateCampaignDto): name, description, source, startsAt, endsAt, budgetTotalPaisa, budgetDailyPaisa, impressionCap, clickCap, advertiserName, advertiserContact, contractRef, priority. Service sets status = "draft" regardless of body.
Errors: CAMPAIGN_NOT_FOUND (404), CAMPAIGN_DATE_INVALID (400), CAMPAIGN_INVALID_TRANSITION (400), CAMPAIGN_ALREADY_DELETED (400), CAMPAIGN_NOT_DELETED (400).
Admin — Campaign Placement Links
Nested under campaigns: /api/admin/campaigns/:publicId/placements
| Action | Endpoint | Permission |
|---|---|---|
| Add placement to campaign | POST /admin/campaigns/:publicId/placements | Assignments_CREATE |
| Remove placement from campaign | DELETE /admin/campaigns/:publicId/placements/:placementPublicId | Assignments_DELETE |
| Activate placement link | PATCH /admin/campaigns/:publicId/placements/:placementPublicId/activate | Assignments_UPDATE |
| Deactivate placement link | PATCH /admin/campaigns/:publicId/placements/:placementPublicId/deactivate | Assignments_UPDATE |
Body: { placementPublicId, isActive? }. The service resolves publicId → integer id and uses composite (campaign_id, placement_id) PK.
Errors: CAMPAIGN_PLACEMENT_NOT_FOUND (404), CAMPAIGN_PLACEMENT_ALREADY_EXISTS (409).
Admin — Banner Assignments
Nested under campaign placements: /api/admin/campaigns/:publicId/placements/:placementPublicId/banners
| Action | Endpoint | Permission |
|---|---|---|
| List banner assignments | GET /admin/campaigns/:publicId/placements/:placementPublicId/banners | Assignments_READ |
| Assign banner to slot | POST /admin/campaigns/:publicId/placements/:placementPublicId/banners | Assignments_CREATE |
| Update banner assignment | PATCH /admin/campaigns/:publicId/placements/:placementPublicId/banners/:bannerPublicId | Assignments_UPDATE |
| Remove banner assignment | DELETE /admin/campaigns/:publicId/placements/:placementPublicId/banners/:bannerPublicId | Assignments_DELETE |
| Activate | PATCH .../banners/:bannerPublicId/activate | Assignments_UPDATE |
| Deactivate | PATCH .../banners/:bannerPublicId/deactivate | Assignments_UPDATE |
Body (AssignBannerDto): bannerPublicId, displayOrder? (default 0), weight? (default 100), isFallback?, fallbackPriority?, overrideCtaLabel/Url/Headline/Subheadline?, transitionDurationMs?.
UpdateBannerAssignmentDto = PartialType(OmitType(AssignBannerDto, ['bannerPublicId'] as const)) — bannerPublicId is in URL.
Errors: BANNER_ASSIGNMENT_NOT_FOUND (404), BANNER_ASSIGNMENT_ALREADY_EXISTS (409), CAMPAIGN_PLACEMENT_NOT_FOUND (404), BANNER_NOT_FOUND (404), PLACEMENT_NOT_FOUND (404), CAMPAIGN_NOT_FOUND (404).
Admin — Targeting Rule Endpoints
Base path: /api/admin/targeting-rules
| Action | Endpoint | Permission |
|---|---|---|
| List targeting rules | GET /admin/targeting-rules?campaignPublicId=... | TargetingRules_READ |
| Add targeting rule | POST /admin/targeting-rules | TargetingRules_CREATE |
| Update targeting rule | PATCH /admin/targeting-rules/:publicId | TargetingRules_UPDATE |
| Delete targeting rule | DELETE /admin/targeting-rules/:publicId | TargetingRules_DELETE |
Body fields: campaignPublicId, ruleType (9 enum values), operator (validated by TargetingAdminService.VALID_OPERATORS per rule_type), value (jsonb), isActive?.
Errors: TARGETING_RULE_NOT_FOUND (404), TARGETING_RULE_INVALID_OPERATOR (400), CAMPAIGN_NOT_FOUND (404).
Public — Serving Endpoint
Get Banners for Placement
GET /api/mobile/banners/serve/:slugAuth: None. @Public() class decorator bypasses global JwtAuthGuard.
Rate limit: 300 requests/minute per IP (configurable via @IpThrottle).
URL params:
| Param | Type | Notes |
|---|---|---|
slug | string | Placement slug (e.g., homepage-hero) |
Headers used for targeting (all optional):
| Header | Used For |
|---|---|
User-Agent | Device type detection (regex-based: iPad/Android(no Mobile) → tablet, Mobile/Android/iPhone/iPod → mobile, else desktop) |
x-country-code (fallback: cf-ipcountry) | ISO 3166-1 alpha-2 country code |
Authorization (Bearer ... header present) | isLoggedIn = true (token NOT verified — endpoint is @Public()) |
Accept-Language | Primary language tag (lowercased, split on [-;]) |
x-device-id (or x-session-id) | Absence → isNewVisitor = true |
Referer | Extracted domain for referrer_domain rule |
Response shape: ResponseDto<BannerServeResponseDto>:
{
message: "Banners served successfully",
data: {
placement: {
slug: string,
label: string,
layoutType: PlacementLayout,
maxBanners: number,
allowPartialRender: boolean,
fallbackPlaceholderUrl: string | null,
},
banners: Array<{
publicId: string,
title: string,
primaryImageUrl: string,
primaryImageAlt: string | null,
mobileImageUrl: string | null,
videoUrl: string | null,
mediaVariants: Record<string, string> | null,
headline: string | null,
subheadline: string | null,
ctaLabel: string | null,
ctaUrl: string | null,
ctaOpenNewTab: boolean,
utmSource: string | null,
utmMedium: string | null,
utmCampaign: string | null,
utmContent: string | null,
bgColor: string | null,
transitionDurationMs: number | null,
isFallback: boolean,
displayOrder: number,
}>,
servedAt: string // ISO 8601; always the live timestamp, NEVER cached
}
}banners is the ordered list of internal banners resolved for this placement, ordered by displayOrder.
Errors: PLACEMENT_NOT_FOUND (404). banners may be [] if no active campaigns match.
Public — Event Tracking Endpoints
Record Impression
POST /api/mobile/banners/events/impressionAuth: None. @Public(). Rate limit: 600 requests/minute per IP.
Body fields (see record-impression.dto.ts): bannerPublicId (UUID), placementPublicId (UUID), campaignPublicId? (UUID), sessionId? (max 128), userId? (UUID), deviceType? (max 20), countryCode? (max 2), pageUrl?, referrerUrl?, occurredAt (ISO 8601), correlationId (max 128).
Response: 202 Accepted with ResponseDto after enqueueing the job.
Service flow: validates the banner/placement (and, if provided, campaign) exist via parallel Postgres lookups, then enqueues BannerJob.RECORD_IMPRESSION with jobId = correlationId. The enqueued payload carries the original bannerPublicId/placementPublicId/campaignPublicId strings directly — not resolved integer ids (see analytics.mdx for why). Duplicate correlationId silently no-ops (idempotent).
Errors: BANNER_NOT_FOUND (404), PLACEMENT_NOT_FOUND (404). Missing/nonexistent campaignPublicId is fine — campaignPublicId is omitted from the enqueued payload without throwing.
Record Click
POST /api/mobile/banners/events/clickSame shape as impression, plus required destinationUrl (string). Persisted to the BannerClick MongoDB collection (see analytics.mdx).
Response: 202 Accepted. Same idempotency semantics.
BullMQ Job Contracts
Defined in packages/jobs/src/index.ts:
export enum QueueName {
// ...
BANNERS = "banners",
}
export enum BannerJob {
RECORD_IMPRESSION = "banner.record_impression",
RECORD_CLICK = "banner.record_click",
SYNC_SCHEDULE_STATUS = "banner.sync_schedule_status",
ROLLUP_STATS = "banner.rollup_stats",
}
export interface RecordBannerImpressionPayload {
bannerPublicId: string;
placementPublicId: string;
campaignPublicId?: string;
sessionId?: string;
userId?: string;
deviceType?: string;
countryCode?: string;
pageUrl?: string;
referrerUrl?: string;
occurredAt: string; // ISO 8601
correlationId: string;
}
export interface RecordBannerClickPayload {
bannerPublicId: string;
placementPublicId: string;
campaignPublicId?: string;
sessionId?: string;
userId?: string;
deviceType?: string;
countryCode?: string;
destinationUrl?: string;
pageUrl?: string;
occurredAt: string;
correlationId: string;
}
export interface SyncBannerScheduleStatusPayload {
correlationId: string;
}
export interface RollupBannerStatsPayload {
statDate: string; // YYYY-MM-DD (UTC)
correlationId: string;
}
export type BannerQueueJobs =
| { name: BannerJob.RECORD_IMPRESSION; data: RecordBannerImpressionPayload }
| { name: BannerJob.RECORD_CLICK; data: RecordBannerClickPayload }
| { name: BannerJob.SYNC_SCHEDULE_STATUS; data: SyncBannerScheduleStatusPayload }
| { name: BannerJob.ROLLUP_STATS; data: RollupBannerStatsPayload };Admin — Banner Analytics Endpoints
Two read-only endpoints under apps/api/src/modules/banners/admin/analytics/, added so admins can inspect banner/campaign performance without direct database access.
GET /admin/banner-analytics/stats
Paginated daily rollup, backed by BannerStat (MongoDB). Requires JwtAuthGuard/RoleGuard/BannerAnalytics_READ.
Query parameters: bannerPublicId?, placementPublicId?, campaignPublicId? (all optional uuid filters), from/to (required, YYYY-MM-DD, inclusive — the window must not exceed 90 days, or the endpoint returns 400 with BANNER_ANALYTICS_TIME_WINDOW_TOO_LARGE), plus the standard page/size/pagination fields inherited from QueryDto.
Response: ResponseDto<BannerStatResponseDto[]> with the standard offset-pagination metadata (count/currentPage/totalPage, per this codebase's ResponseDto convention).
GET /admin/banner-analytics/events
Cursor-paginated raw event drill-down, backed by BannerImpression or BannerClick (MongoDB) depending on the required type query parameter ("impression" | "click"). Requires the same guards/permission as /stats.
Query parameters: type (required), bannerPublicId?/placementPublicId?/campaignPublicId? (optional filters), from?/to? (optional — defaults to the last 7 days when omitted; same 90-day cap applies), cursor? (opaque, from a previous page's nextCursor), limit? (default 20, max 100).
Response: ResponseDto<BannerEventResponseDto[]> with nextCursor (string or null on the last page) carried in ResponseDto's pagination argument.
Both endpoints are documented in Swagger under the "Banner Analytics (Admin)" tag at /api/api-docs.
Common Response Envelope
All endpoints follow the shared ResponseDto<T> envelope:
{ message: string, data: T | null }For paginated list responses, an additional pagination object is added: { count, page, size } (only when pagination: true in query).
Error responses (thrown via NotFoundException / BadRequestException / ConflictException):
{ statusCode: number, message: string, errorCode: "BANNER_NOT_FOUND" }The frontend must rely on errorCode (not message text) for UI logic.