Happy House - Ecommerce Docs
Developer Resourcesbanners

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

TermMeaning
publicIdA uuid7 string identifying a resource. Used in all URL paths and response bodies instead of integer IDs.
slugURL-safe identifier for a placement (e.g., homepage-hero). Used in the public serving endpoint.
scheduleStatusBanner activation state: evergreen / scheduled / active / expired.
campaignStatusCampaign lifecycle state: draft / scheduled / active / paused / ended / cancelled.
targeting contextHTTP header values extracted from the client request to evaluate targeting rules.
correlationIdClient-generated UUID to deduplicate impression/click events. Used as BullMQ jobId for tracking.
paisaNPR 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/banners

Auth: JWT + Banners_CREATE

Request body fields (see apps/api/src/modules/banners/admin/banners/dto/create-banner.dto.ts for full DTO):

FieldTypeRequiredNotes
titlestringYesMax 255 chars
descriptionstringNo
source"internal" | "sponsored" | "affiliate" | "system"NoDefault internal
primaryImageUrlstringYes
primaryImageAltstringNoMax 255
mobileImageUrlstringNo
videoUrlstringNo
mediaVariantsRecord<string, string>NoPer-locale/per-size variant URLs
headline / subheadlinestringNoMax 255
ctaLabelstringNoMax 100
ctaUrlstring (URL)No
ctaOpenNewTabbooleanNoDefault false
utmSource / utmMedium / utmCampaign / utmContentstringNoMax 100
bgColorstringNoHex #RRGGBB
prioritynumberNoDefault 0
seoIdstring (UUID)NoFK to SEO module
publishAt / expiresAtstring (ISO 8601)No
scheduleTimezonestringNoIANA tz, default UTC
recurrenceRulestringNo
recurrenceWindowStart / recurrenceWindowEndstring (HH:MM:SS)No
scheduleStatus"evergreen" | "scheduled" | "active" | "expired"NoDefault evergreen
isEvergreenFallbackbooleanNo
fallbackPlaceholderUrlstringNo
targetKind"product" | "category" | "brand" | "url"NoTyped CTA target discriminator. See Typed CTA Target below.
targetProductPublicIdstring (UUID)ConditionalRequired when targetKind = "product"
targetCategoryPublicIdstring (UUID)ConditionalRequired when targetKind = "category"
targetBrandPublicIdstring (UUID)ConditionalRequired 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/banners

Auth: 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/:publicId

Auth: JWT + Banners_READ

Update Banner

PATCH /api/admin/banners/:publicId

Auth: JWT + Banners_UPDATE

Partial update; invalidates list cache on success.

Soft-Delete Banner

DELETE /api/admin/banners/:publicId

Auth: JWT + Banners_DELETE

Errors: BANNER_ALREADY_DELETED (400 if already deleted).

Restore Banner

POST /api/admin/banners/:publicId/restore

Auth: JWT + Banners_UPDATE

Errors: BANNER_NOT_DELETED (400 if not deleted).

Update Schedule

PATCH /api/admin/banners/:publicId/schedule

Auth: JWT + Banners_UPDATE

Body: ScheduleBannerDto (scheduleStatus, publishAt, expiresAt, recurrence fields).

Activate / Deactivate

PATCH /api/admin/banners/:publicId/activate
PATCH /api/admin/banners/:publicId/deactivate

Auth: JWT + Banners_UPDATE


Admin — Placement Endpoints

Base path: /api/admin/placements

ActionEndpointPermission
List placementsGET /admin/placementsPlacements_READ
Get placementGET /admin/placements/:publicIdPlacements_READ
Create placementPOST /admin/placementsPlacements_CREATE
Update placementPATCH /admin/placements/:publicIdPlacements_UPDATE
Soft-delete placementDELETE /admin/placements/:publicIdPlacements_DELETE
ActivatePATCH /admin/placements/:publicId/activatePlacements_UPDATE
DeactivatePATCH /admin/placements/:publicId/deactivatePlacements_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

ActionEndpointPermission
List campaignsGET /admin/campaignsCampaigns_READ
Get campaignGET /admin/campaigns/:publicIdCampaigns_READ
Create campaignPOST /admin/campaignsCampaigns_CREATE
Update campaignPATCH /admin/campaigns/:publicIdCampaigns_UPDATE
Soft-delete campaignDELETE /admin/campaigns/:publicIdCampaigns_DELETE
Restore campaignPOST /admin/campaigns/:publicId/restoreCampaigns_UPDATE
SchedulePATCH /admin/campaigns/:publicId/scheduleCampaigns_UPDATE
ActivatePATCH /admin/campaigns/:publicId/activateCampaigns_UPDATE
PausePATCH /admin/campaigns/:publicId/pauseCampaigns_UPDATE
EndPATCH /admin/campaigns/:publicId/endCampaigns_UPDATE
CancelPATCH /admin/campaigns/:publicId/cancelCampaigns_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).


Nested under campaigns: /api/admin/campaigns/:publicId/placements

ActionEndpointPermission
Add placement to campaignPOST /admin/campaigns/:publicId/placementsAssignments_CREATE
Remove placement from campaignDELETE /admin/campaigns/:publicId/placements/:placementPublicIdAssignments_DELETE
Activate placement linkPATCH /admin/campaigns/:publicId/placements/:placementPublicId/activateAssignments_UPDATE
Deactivate placement linkPATCH /admin/campaigns/:publicId/placements/:placementPublicId/deactivateAssignments_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

ActionEndpointPermission
List banner assignmentsGET /admin/campaigns/:publicId/placements/:placementPublicId/bannersAssignments_READ
Assign banner to slotPOST /admin/campaigns/:publicId/placements/:placementPublicId/bannersAssignments_CREATE
Update banner assignmentPATCH /admin/campaigns/:publicId/placements/:placementPublicId/banners/:bannerPublicIdAssignments_UPDATE
Remove banner assignmentDELETE /admin/campaigns/:publicId/placements/:placementPublicId/banners/:bannerPublicIdAssignments_DELETE
ActivatePATCH .../banners/:bannerPublicId/activateAssignments_UPDATE
DeactivatePATCH .../banners/:bannerPublicId/deactivateAssignments_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

ActionEndpointPermission
List targeting rulesGET /admin/targeting-rules?campaignPublicId=...TargetingRules_READ
Add targeting rulePOST /admin/targeting-rulesTargetingRules_CREATE
Update targeting rulePATCH /admin/targeting-rules/:publicIdTargetingRules_UPDATE
Delete targeting ruleDELETE /admin/targeting-rules/:publicIdTargetingRules_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/:slug

Auth: None. @Public() class decorator bypasses global JwtAuthGuard.

Rate limit: 300 requests/minute per IP (configurable via @IpThrottle).

URL params:

ParamTypeNotes
slugstringPlacement slug (e.g., homepage-hero)

Headers used for targeting (all optional):

HeaderUsed For
User-AgentDevice 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-LanguagePrimary language tag (lowercased, split on [-;])
x-device-id (or x-session-id)Absence → isNewVisitor = true
RefererExtracted 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/impression

Auth: 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/click

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