Product Management
This guide is the canonical reference for the admin product write surface. Every property surfaced on the public client product list / detail endpoints is settable here in a single create or update call.
The same DTO is used by POST /admin/products and (with all fields optional)
by PUT /admin/products/:id. Schemas are auto-generated into the admin
OpenAPI spec — see CreateProductDto and UpdateProductDto.
Anatomy of a product
products ← name, slug, description, brand_id, product_type_id, …├── product_variants ← SKUs, prices, currency, inventory controls│ └── partner_product_sources ← (variant × partner) priority-ordered sources├── product_categories├── product_tags ← also drives `is_new` / `is_popular` (slugs `new` / `popular`)├── product_payment_methods├── instruction_steps ← JSON array of redemption steps└── fulfillment_config ← typed JSON describing recipient inputs + deliveryEverything from instruction_image and instruction_video to per-variant
stock_cap and per-partner send_value is settable on a single product
write — there is no second-pass requirement.
Top-level product fields
| Field | Type | Notes |
|---|---|---|
name, slug | string | slug must be unique across all products. |
short_description, description | string | Public copy. |
status | 'draft' | 'active' | 'archived' | 'pending' | 'discarded' | Only active is shown to customers. |
brand_id | int | FK to brands. Brand must be active to surface on client API. |
product_type_id | int | FK to product_types. |
redirect_url | string | null | Used when fulfillment_config.modality = 'REDIRECT'. |
thumbnail, banner | string | URLs to images already uploaded via the file manager. Inherited from the brand on first create when omitted. |
instruction_image, instruction_video | string | Asset URLs. |
instruction_steps | array | Either string steps or rich { step, title, description } objects. Stored as JSON. |
delivery_method | string | Legacy — prefer fulfillment_config.delivery_type. Still returned in the public response for back-compat. |
validity_text, refund_policy | string | Public copy. |
meta_title, meta_description, seo_og_image_url | string | SEO. |
featured, trending | bool | Toggles for homepage sections of the same names. |
support_config | object | Free-form support metadata (help-desk URL, agent contact, …). |
fulfillment_config | object (typed) | See Fulfillment config below. |
category_ids | int[] | FK list — replaces existing categories on update. |
tag_ids | int[] | FK list. Attach the new / popular tag slugs to surface in homepage sections. |
payment_method_ids | int[] | FK list (preferred). Falls back to payment_methods: string[] (slugs/codes). |
variants | CreateProductVariantDto[] | Optional inline variants on create. See Variants. |
Variants
Each variant is one purchasable SKU on a product. Inline-create on the same
request as the product, or attach later via
POST /admin/products/:id/variants.
{ "name": "Standard 1-month", "sku": "SKU-1M", "currency": "BDT", "original_price": 1200, "price": 1100, "discount_type": "percentage", "discount_value": 0, "validity_days": 30, "is_active": true,
// Inventory controls — see docs/INVENTORY.md "track_inventory": true, "stock_cap": 1000, "low_stock_threshold": 5,
// Optional: assign a partner source in the same call (writes a // partner_product_sources row). Omit to keep the variant inhouse. "partner_id": 5, "partner_cost": 1080, "partner_priority": 1, "partner_external_offer_id": "off-basic-1m", "partner_sku": "OFFER-1M", "partner_currency": "USD", "partner_offer_type": "VOUCHER", "partner_price_type": "FIXED", "partner_send_value": 25, "partner_send_currency": "USD", "partner_metadata": { "region": "US" }, "partner_source_active": true}Inventory controls
| Field | Default | Effect |
|---|---|---|
track_inventory | true | When true, the order pipeline reserves and decrements stock. Set to false for variants with infinite supply (dynamic partner-issued vouchers). |
stock_cap | null | Optional hard ceiling on on-hand units. Independent of preloaded voucher count. |
low_stock_threshold | 5 | When stock drops to or below this number, a low_stock admin_alerts row is raised. |
Multi-partner: priority-ordered sources
Each variant can have many partners attached. The fulfillment engine picks the
lowest priority number with is_active = true (ties broken by lowest
partner_cost). Manage many sources at once via the dedicated endpoints:
| Method | Endpoint | Purpose |
|---|---|---|
GET | /admin/partner-sources?variant_id=42 | List sources for a variant. |
POST | /admin/partner-sources | Attach a new partner to a variant. |
PUT | /admin/partner-sources/:id | Update one source (cost, priority, offer details, metadata). |
DELETE | /admin/partner-sources/:id | Detach a partner. |
PATCH | /admin/partner-sources/priorities | Bulk-reorder by [{ id, priority }]. |
metadata_replace flag
PUT /admin/partner-sources/:id defaults to merging incoming metadata on
top of existing keys. Send metadata_replace: true to do a hard overwrite
(matches the variant DTO partner_metadata semantics, which are merge-only).
Fulfillment config
fulfillment_config is the typed JSON describing how vouchers are delivered
and what recipient inputs the customer must provide.
{ "modality": "CODE", // 'CODE' | 'REDIRECT' | 'TOPUP' | 'ESIM' "delivery_type": "instant", "average_delivery_seconds": 30,
// Per-field configuration for the storefront's checkout form. Each entry // declares name, label, type and required. The single-identifier rule // (email | player_id | account_id | uid) applies — at most one identifier // key may appear across all entries. "fields": [ { "name": "player_uid", "label": "Player UID", "type": "text", "required": true }, { "name": "zone_id", "label": "Zone ID", "type": "text", "required": false } ]}The public API at /api/v1/products/:id returns the same shape under
fulfillment_input:
"fulfillment_input": { "mode": "CODE", "fields": [ { "name": "player_uid", "label": "Player UID", "type": "text", "required": true }, { "name": "zone_id", "label": "Zone ID", "type": "text", "required": false } ], "identifier_field": null}Supported type values map to standard HTML inputs: text (default), email,
number, phone, select, url. Unknown types are passed through verbatim so
storefronts can implement custom renderers.
Single-identifier rule
A product accepts exactly one recipient identifier:
| Key | Use |
|---|---|
email | Voucher emailed to a recipient. |
player_id | Game account / player ID (e.g. Free Fire UID). |
account_id | Account number for direct top-ups. |
uid | Generic user ID. |
If you list more than one identifier key in fields[].name, the admin write
API rejects with 400 before persistence:
fields[].name may contain at most one of: email, player_id, account_id, uidThe client-side read path additionally normalises any legacy storage rows so the storefront only ever renders one identifier input — it picks the highest-priority key by the order above.
Order-time enforcement
When the customer places an order, POST /api/v1/orders validates the
submitted fulfillment_input per item against the resolved identifier and
rejects multi-identifier or mismatched payloads with a 400. See the
checkout flow guide.
Payment methods
Two equivalent ways to set the allowed gateways for a product:
// Preferred — by FK ID from the `payment_methods` lookup table:{ "payment_method_ids": [1, 3, 4] }
// Or by slug / code:{ "payment_methods": ["bkash", "nagad", "sslcommerz"] }When both are provided, payment_method_ids wins.
Inactive payment methods (status = false) are filtered out of the public
client product response automatically — no admin action needed.
Categories & tags
| Field | Behavior |
|---|---|
category_ids | Replaces the product’s product_categories rows. |
tag_ids | Replaces the product’s product_tags rows. Attach the canonical new / popular tags (slugs) to surface in homepage sections. |
Sorting & curation
Both products and variants carry a sort_order INTEGER DEFAULT 0 column.
Lower values surface first; ties break by id ASC for deterministic pagination.
Reorder them in a single atomic call:
PATCH /admin/products/reorder # cross-catalogue product orderPATCH /admin/products/:productId/variants/reorder # variant order inside one product
{ "items": [ { "id": 7, "sort_order": 0 }, { "id": 2, "sort_order": 1 }, { "id": 11, "sort_order": 2 } ]}The client API picks up the new order on the next request. The full reorder contract (limits, error codes, every endpoint in the family) lives in the Sorting & curation guide.
Examples
POST /admin/productsAuthorization: Bearer $ADMIN_TOKENContent-Type: application/json
{ "name": "Free Fire 100 Diamonds", "slug": "free-fire-100-diamonds", "status": "draft", "brand_id": 12, "product_type_id": 3, "fulfillment_config": { "modality": "TOPUP", "fields": [ { "name": "player_id", "label": "Free Fire UID", "type": "text", "required": true } ] }}POST /admin/productsAuthorization: Bearer $ADMIN_TOKENContent-Type: application/json
{ "name": "Netflix Gift Card", "slug": "netflix-gift-card", "status": "active", "brand_id": 8, "product_type_id": 5, "thumbnail": "/uploads/netflix-thumb.png", "banner": "/uploads/netflix-banner.png", "instruction_image": "/uploads/netflix-instr.png", "instruction_video": "https://youtu.be/xyz", "instruction_steps": [ { "step": 1, "title": "Login", "description": "Sign in to your Netflix account" }, { "step": 2, "title": "Redeem", "description": "Paste the code under Account → Redeem" } ], "delivery_method": "instant", "validity_text": "Codes are valid for 12 months from purchase", "refund_policy": "Non-refundable once delivered", "meta_title": "Netflix Gift Card | bdvoucher", "meta_description": "Buy Netflix gift cards instantly via bKash / Nagad", "featured": true, "fulfillment_config": { "modality": "CODE", "delivery_type": "instant", "average_delivery_seconds": 30, "fields": [ { "name": "email", "label": "Recipient Email", "type": "email", "required": true } ] }, "category_ids": [4, 9], "tag_ids": [12, 18, 22], "payment_method_ids": [1, 2, 3], "variants": [ { "name": "$25", "sku": "NFX-25", "original_price": 3000, "price": 2900, "currency": "BDT", "validity_days": 365, "track_inventory": true, "low_stock_threshold": 10, "partner_id": 5, "partner_cost": 25, "partner_priority": 1, "partner_external_offer_id": "netflix-25-usd", "partner_currency": "USD", "partner_offer_type": "VOUCHER", "partner_price_type": "FIXED", "partner_send_value": 25, "partner_send_currency": "USD" }, { "name": "$50", "sku": "NFX-50", "original_price": 5800, "price": 5700, "currency": "BDT", "validity_days": 365, "track_inventory": true, "partner_id": 5, "partner_cost": 50, "partner_priority": 1, "partner_external_offer_id": "netflix-50-usd", "partner_currency": "USD", "partner_offer_type": "VOUCHER", "partner_price_type": "FIXED", "partner_send_value": 50, "partner_send_currency": "USD" } ]}PUT /admin/products/42Authorization: Bearer $ADMIN_TOKENContent-Type: application/json
{ "fulfillment_config": { "modality": "CODE", "fields": [ { "name": "email", "label": "Recipient Email", "type": "email", "required": true }, { "name": "phone", "label": "Phone (optional)", "type": "phone", "required": false } ] }}Related guides
- Checkout flow — how products surface to the client
and how
fulfillment_inputis validated at order time. - Admin fulfillment recovery — manual codes, partner retry, attempt log.
- Inventory —
track_inventory,stock_cap,low_stock_thresholdsemantics in detail.