Supercommerce API Docs
Admin API

Dynamic Link Module — Admin

HTTP surface for managing dynamic link groups (CMS-style tile collections — e.g. homepage banner slots) and the dynamic links inside each group. Groups are addressable by slug and…

HTTP surface for managing dynamic link groups (CMS-style tile collections — e.g. homepage banner slots) and the dynamic links inside each group. Groups are addressable by slug and read by the public storefront; this admin surface is the only writer.

Source: api-modules/dynamic-link/src/controllers/admin-dynamic-link-group.controller.ts, api-modules/dynamic-link/src/controllers/admin-dynamic-link.controller.ts.

A group is a named collection of links rendered as an ordered list (carousel, tile grid, feature row). A link is one renderable item with optional image / url / text and an order for sorting within its parent group.


Conventions

Authentication

All endpoints require a Better-Auth admin session and a role granting the matching permission.

Endpoint groupPermission
GET /admin/dynamic-link-groups, GET /admin/dynamic-link-groups/:iddynamicLinkGroup: read
POST /admin/dynamic-link-groups, POST /admin/dynamic-link-groups/:id/duplicatedynamicLinkGroup: create
PUT /admin/dynamic-link-groups/:iddynamicLinkGroup: update
DELETE /admin/dynamic-link-groups/:iddynamicLinkGroup: delete
GET /admin/dynamic-link-groups/:groupId/linksdynamicLink: read
POST /admin/dynamic-link-groups/:groupId/links, POST /admin/dynamic-link-groups/:groupId/links/:linkId/duplicatedynamicLink: create
PUT /admin/dynamic-link-groups/:groupId/links/:linkId, PATCH /admin/dynamic-link-groups/:groupId/links/reorderdynamicLink: update
DELETE /admin/dynamic-link-groups/:groupId/links/:linkIddynamicLink: delete

Response envelope

Successful responses are wrapped by ResponseInterceptor:

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* optional, e.g. pagination */ }
}

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409UNIQUE_VIOLATION (slug collision)
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Lifecycle

Groups and links are hard-deleted (no soft-delete). Deleting a group cascades to its links via the database foreign key.

Links additionally carry an isActive toggle (default true). Setting it to false hides the link from the public storefront read (GET /store/dynamic-link-groups/slug/:slug) without deleting it. Admin reads (GET .../links, GET /admin/dynamic-link-groups/:id) always return every link regardless of isActive, so the toggle can be flipped back later.

Links also carry a platform enum (APP | WEB | BOTH, default BOTH) reused from the platform enum shared with banner/discount/free-gift. The public storefront read can filter on it via ?platform= or the x-platform header.


Domain types

DynamicLinkGroupResponse

type DynamicLinkGroupResponse = {
  id: string;
  title: string;                                 // 1..255
  slug: string;                                  // lowercase alnum + hyphens, 1..255
  metadata: Record<string, unknown> | null;
  createdAt: string;                             // ISO
  updatedAt: string;
};

type DynamicLinkGroupWithLinksResponse = DynamicLinkGroupResponse & {
  links: DynamicLinkResponse[];                  // sorted by order ASC
};

DynamicLinkResponse

A link must have at least one of image, url, or text — the rest can be null.

type DynamicLinkResponse = {
  id: string;
  groupId: string;
  image: string | null;                          // max 2048 chars
  url: string | null;                            // max 2048 chars
  text: string | null;                           // max 1024 chars
  order: number;                                 // ASC sort within group, >= 0
  isActive: boolean;                             // default true; false hides it from the store read
  platform: "APP" | "WEB" | "BOTH";              // default BOTH
  metadata: Record<string, unknown> | null;
  createdAt: string;
  updatedAt: string;
};

Base path: /admin/dynamic-link-groups.

Required permission: dynamicLinkGroup: read. Standard QueryDto (page / limit / search / filters[]).

Response 200 — paginated envelope of DynamicLinkGroupResponse[].


Required permission: dynamicLinkGroup: read. Returns the group with nested links[] sorted by order ASC, including inactive links — unlike the public storefront read, this admin endpoint does not filter on isActive.

Response 200DynamicLinkGroupWithLinksResponse.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

POST /admin/dynamic-link-groups — Create a group

Required permission: dynamicLinkGroup: create.

Body

{
  "title": "Homepage Hero Tiles",
  "slug": "homepage-hero-tiles",
  "metadata": { "platform": "WEB" }
}
FieldTypeConstraints
titlestring1..255
slugstring1..255, lowercase alnum + hyphens
metadataRecord<string, unknown> | null?Free-form

Response 201DynamicLinkGroupResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod
409UNIQUE_VIOLATIONslug already in use

Required permission: dynamicLinkGroup: update. Partial body.

Response 200DynamicLinkGroupResponse.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id
409UNIQUE_VIOLATIONslug taken by another group

Required permission: dynamicLinkGroup: delete. Hard delete; cascades to child links via DB FK.

Response 204 No Content.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

POST /admin/dynamic-link-groups/:id/duplicate — Duplicate a group

Required permission: dynamicLinkGroup: create. Clones the source group with a fresh title + slug. Source metadata is copied as-is; every child link is cloned with its order, image, url, text, isActive, platform, and metadata.

Body

{ "title": "Homepage Hero Tiles (v2)", "slug": "homepage-hero-tiles-v2" }

Response 201DynamicLinkGroupWithLinksResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod
404NOT_FOUNDSource group not found
409UNIQUE_VIOLATIONNew slug taken

Base path: /admin/dynamic-link-groups/:groupId/links.

Required permission: dynamicLink: read. Returns the full ordered list (no pagination — link counts per group are small by design), including inactive links (isActive: false) — the admin table needs to see and toggle them.

Response 200DynamicLinkResponse[] sorted by order ASC.

Errors

StatusCodeWhen
404NOT_FOUNDGroup not found

POST .../links — Create a link

Required permission: dynamicLink: create. At least one of image, url, text must be present (whitespace-only inputs are normalized to null before the check).

Body

{
  "image": "tiles/promo-summer.png",
  "url":   "/sale/summer",
  "text":  "Summer Sale",
  "order": 0,
  "metadata": { "campaign": "Q2" }
}
FieldTypeConstraints
imagestring | null?max 2048; trimmed; empty → null
urlstring | null?max 2048
textstring | null?max 1024
orderint>= 0. Default 0
isActiveboolean?Default true
platform"APP" / "WEB" / "BOTH", optionalDefault BOTH
metadataRecord<string, unknown> | null?Free-form

Response 201DynamicLinkResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORAll three of image/url/text are empty, or body otherwise invalid
404NOT_FOUNDGroup not found

Required permission: dynamicLink: update. Partial; the "at least one renderable" rule re-runs against the merged state. This is also how the isActive toggle is flipped — send { "isActive": false } to hide the link from the store read, or { "isActive": true } to restore it. platform can be changed the same way (e.g. { "platform": "APP" }).

Response 200DynamicLinkResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod
404NOT_FOUNDLink not in this group

Required permission: dynamicLink: delete. Hard delete.

Response 204 No Content.

Errors

StatusCodeWhen
404NOT_FOUNDLink not in this group

POST .../links/:linkId/duplicate — Duplicate a link

Required permission: dynamicLink: create. Clones the link within the same group with order = max(order) + 1; isActive and platform are copied verbatim from the source link.

Response 201 — cloned DynamicLinkResponse.

Errors

StatusCodeWhen
404NOT_FOUNDLink not in this group

PATCH .../links/reorder — Bulk reorder

Required permission: dynamicLink: update. Reapplies order for every supplied id. The literal route segment reorder is declared before :linkId so it cannot collide with a UUID.

Body

{
  "items": [
    { "linkId": "01J9aaaa...", "order": 0 },
    { "linkId": "01J9bbbb...", "order": 1 },
    { "linkId": "01J9cccc...", "order": 2 }
  ]
}
FieldTypeConstraints
items[].linkIdstringRequired
items[].orderint>= 0
itemsarrayMin 1 entry

Response 200 — reordered DynamicLinkResponse[] in their new order.

Errors

StatusCodeWhen
400VALIDATION_ERROREmpty items, missing linkId, etc.
404NOT_FOUNDGroup not found (links not in group are silently ignored at the service layer)

  • admin-rbac — gates every endpoint via dynamicLinkGroup:* / dynamicLink:*. See admin-rbac.md.
  • store/dynamic-link — unauthenticated storefront read of a group by slug.

On this page