Merchandising

Promotions, banners, stories and QR tables as API resources. Available on every plan with an active subscription; plan limits apply where noted.

Hybrid identity (:ref)#

Every resource carries an immutable opaque id and an optional caller-defined externalId (set it on create). Wherever a path or body takes a reference — :ref, productRef, linkedProductRef — you may pass either value; externalId is matched first. Creating with an externalId that already exists on that resource type → 409 EXTERNAL_ID_ALREADY_EXISTS. Unknown refs → 404 with PROMOTION_NOT_FOUND / BANNER_NOT_FOUND / STORY_NOT_FOUND / TABLE_NOT_FOUND / PRODUCT_NOT_FOUND.

All list endpoints use the same envelope as orders: { items, total, page, limit, totalPages } (page ≥1, limit 1–100).

Promotions#

http
GET    /v1/promotions
POST   /v1/promotions
PATCH  /v1/promotions/:ref
DELETE /v1/promotions/:ref
POST   /v1/promotions/:ref/items
PATCH  /v1/promotions/:ref/items/:productRef
DELETE /v1/promotions/:ref/items/:productRef

A promotion is a named group of products with sale prices, toggled by isActive. Create/update fields: name (≤100), schedule (same day/HH:MM shape as settings hours), isActive (update only), externalId (create only).

Items are addressed by (promotion, product) — no separate item ids. Adding an item: { "productRef": "...", "salePriceMinor": 9900 } (minor units). While a promotion is active, its items' sale prices are applied to the products automatically (and cleared on deactivate/delete, unless another active promotion still covers the product). A product can appear in a promotion once — re-adding it is a 400.

bash
curl -X POST https://api.duck-hub.com/v1/promotions \
  -H "Authorization: Bearer dk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Happy hour", "externalId": "promo-happy-hour" }'
 
curl -X POST https://api.duck-hub.com/v1/promotions/promo-happy-hour/items \
  -H "Authorization: Bearer dk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "productRef": "prod-margherita", "salePriceMinor": 15000 }'

Banners#

http
GET    /v1/banners
POST   /v1/banners
PATCH  /v1/banners/:ref
DELETE /v1/banners/:ref
POST   /v1/banners/:ref/image

Fields: title (≤60), description (≤2000), isActive (update), linkedProductRef (opens that product when the banner is tapped; null clears the link), clickable (boolean, default true — set false to make the banner purely decorative: tapping it does nothing and it shows no pointer cursor), externalId (create only). Attach the image via the media ingest, and translate title/description via PUT /v1/banners/:ref/translations. Banners appear on the public menu after the next publish.

GET /v1/banners?locale=xx returns title/description resolved to that locale (translation ?? canonical — same as the menu reads) with the applied locale echoed; invalid locales are 400 INVALID_LOCALE.

Creating past your plan's banner cap → 400 BANNER_LIMIT_REACHED (see your cap in GET /v1/reference).

Stories#

http
GET    /v1/stories
POST   /v1/stories
PATCH  /v1/stories/:ref
DELETE /v1/stories/:ref
POST   /v1/stories/:ref/image

Stories are the vertical, tappable frames shown behind the venue logo on the public menu. Unlike banners they are not part of the published snapshot — a story appears or disappears as soon as you write it, with no publish step.

Fields: title (≤60), description (≤200 — shorter than a banner because a story fills the whole screen), isActive (update), expiresAt, externalId (create only).

expiresAt is an optional ISO-8601 timestamp that must be in the future. Omitting it — or sending null — means the story is shown indefinitely, which is the normal case; do not invent an expiry the venue did not ask for.

Two consequences of how expiry works, both easy to miss:

  • Expiring is not deleting. An expired story stops being shown to guests but the record remains, and it still counts against your plan cap. Nothing removes it automatically — DELETE /v1/stories/:ref is the only way.
  • A story with no image is never shown to guests. Attach one via the media ingest with target story — it must be portrait 9:16, at least 720×1280.

Translate title/description through the bulk PUT /v1/translations using model story.

GET /v1/stories?locale=xx localizes title/description exactly like the banners list, and each row carries expiresAt and isActive so you can tell live, hidden and finished stories apart.

Creating past your plan's story cap → 400 STORY_LIMIT_REACHED (see your cap in GET /v1/reference). Remember that finished stories are included in that count.

Tables & QR#

http
GET    /v1/tables
POST   /v1/tables
PATCH  /v1/tables/:ref
DELETE /v1/tables/:ref

Create with { "name": "Table 1", "externalId": "table-1" } — the table gets a public id and a short link automatically; responses include qrUrl (the short URL your printed QR codes should encode) plus the shortLink details. Update fields: name, isActive, capacity, positionX/positionY.

Creating past your plan's table cap → 400 TABLE_LIMIT_REACHED.

bash
curl -X POST https://api.duck-hub.com/v1/tables \
  -H "Authorization: Bearer dk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Table 1", "externalId": "table-1" }'

Errors#

  • 404 PROMOTION_NOT_FOUND / BANNER_NOT_FOUND / STORY_NOT_FOUND / TABLE_NOT_FOUND / PRODUCT_NOT_FOUND / PROMOTION_ITEM_NOT_FOUND
  • 409 EXTERNAL_ID_ALREADY_EXISTS — duplicate externalId on create
  • 409 DUPLICATE_PROMOTION_ITEM — the product is already in that promotion
  • 400 BANNER_LIMIT_REACHED / STORY_LIMIT_REACHED / TABLE_LIMIT_REACHED — plan caps (finished stories still count toward the story cap)
  • 400 Validation failed — schema violations