Files
nick-doc/03 - API Reference/Tenant API.md
Siavash Sameni e52ffce48a docs: sync vault with codebase state (2026-06-12)
- Update backend, frontend, scanner, deployment, amanat-assist service docs
- Update System Overview, Scanner Architecture, Telegram Mini App flow
- Update 10 - Services/README.md
- Add Tenant data model, Tenant API reference, Tenant Storefront Flow
- Add Multi-Shop Branch Project Scan (2026-06-10)
- Add tenant.md service doc
- Append activity log entry
- Reflects archived/search/stats route fix and new E2E test suite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 11:42:18 +04:00

15 KiB

title, tags, aliases
title tags aliases
Tenant API
api
tenant
white-label
storefront
reference
White-Label API
Storefront API
Merchant API

Tenant API

Last updated: 2026-06-10 — current feature/white-label-shops scan. Related: Tenant, PRD - Seller-Owned White-Label Shops and Bots, Authentication API

Three route groups:

Mount File Auth Description
/api/tenants backend/src/routes/tenantRoutes.ts Required (JWT) Admin + tenant-owner management surface.
/api/storefront backend/src/routes/storefrontRoutes.ts None (public) Public storefront surface — tenant resolved from Host header.
/api/telegram backend/src/routes/tenantWebhookRoutes.ts Telegram webhook secret header Tenant-bot webhook receiver for claim activation.

Authentication and authorization

All /api/tenants/* routes require Authorization: Bearer <jwt> via authenticateToken.

Three authorization tiers:

Tier How enforced Who
Platform admin authorizeRoles('admin') middleware Users with role = 'admin'
Tenant role requireTenantRole(...roles) middleware Users with a matching row in tenant_user_roles for the target tenant
Self (tenant creation) Inline check Any authenticated user (creates for themselves)

requireTenantRole looks up tenant_user_roles by (tenantId, req.user.id). It passes if the user's role is in the allowed list or if the user is a platform admin.

/api/storefront/* routes are fully public — no authenticateToken. Tenant identity comes from the Host header only, never from the request body.


Storefront routes — GET /api/storefront/...

GET /api/storefront/bootstrap

Resolves the tenant from the Host header and returns the bootstrap payload for the frontend TenantProvider.

Auth: None. Middleware: tenantResolutionMiddlewarestorefrontRateLimiter (120 req/min per tenant+IP).

Response 200:

{
  "success": true,
  "data": {
    "tenantId": "uuid",
    "slug": "myshop",
    "shopId": "uuid",
    "brand": {
      "name": "My Shop",
      "logoUrl": "https://cdn.example.com/logo.png",
      "primaryColor": "#1F6FEB",
      "supportEmail": "support@example.com"
    },
    "features": {
      "escrowCheckout": true,
      "directCheckout": false,
      "externalPayments": false,
      "telegramMiniApp": false
    },
    "paymentRails": ["amn_escrow"],
    "localeDefaults": ["en", "fa"]
  }
}

Response 404: Host does not match any active tenant or domain.

[!note] Amanat default The frontend TenantProvider treats a 404 as "no tenant for this host" and falls back to Amanat platform defaults — this is not an error condition for the frontend.


GET /api/storefront/t/:slug/bootstrap

Preview-only bootstrap by tenant slug. Allowed only when the request arrives from the platform base domain (amn.gg, localhost). Owner can preview a pending tenant this way.

Auth: None. Restrictions: Returns 403 PREVIEW_FORBIDDEN if the Host is not the platform base domain.


GET /api/storefront/catalog (Phase 2 stub)

POST /api/storefront/checkout (Phase 2 stub)

GET /api/storefront/orders/:orderId (Phase 2 stub)

All return 501 NOT_IMPLEMENTED. Namespace reserved.


Tenant management routes — /api/tenants/...

POST /api/tenants — create tenant

Any authenticated user may create a tenant for themselves (ownerUserId = req.user.id). Only platform admins may supply a different ownerUserId.

Created tenants start with status = 'pending'. A platform admin must call POST /activate before the tenant becomes publicly accessible.

Auth: authenticateToken.

Request body:

{
  "slug": "myshop",
  "displayName": "My Shop",
  "type": "hosted_seller",
  "brand": { "name": "My Shop", "primaryColor": "#1F6FEB" },
  "features": { "escrowCheckout": true },
  "localeDefaults": ["en"]
}
Field Required Description
slug yes URL-safe identifier [a-z0-9-]{3,40}. Lowercased automatically.
displayName yes Human-readable shop name.
type no One of hosted_seller, white_label, isolated, enterprise. Default hosted_seller.
brand no { name?, logoUrl?, primaryColor?, supportEmail? }
features no Feature flag overrides.
localeDefaults no Default ['en'].
ownerUserId no Admin-only override.

Response 201: Created tenant record.

Response 409 TENANT_SLUG_TAKEN: Slug already in use.

Side effects on create:

  1. Auto-grants owner role to the creating user (tenant_user_roles).
  2. Seeds a default tenant_payment_policies row with allowedRails: ['amn_escrow'].

GET /api/tenants — list tenants

Platform admins only.

Auth: authenticateToken + authorizeRoles('admin').

Query params:

Param Description
status Filter by tenantStatus enum value.
type Filter by tenantType enum value.
page Page number (default 1).
limit Page size (default 20).

Response 200: { data: { tenants: Tenant[], total: number } }


GET /api/tenants/:tenantId — get tenant

Auth: authenticateToken + any tenant role.

Response 200: Full tenant record (no secrets).

Response 404 TENANT_NOT_FOUND


GET /api/tenants/:tenantId/bootstrap — authenticated bootstrap

Same payload shape as the storefront route, but requires authentication and a tenant role. Used by the merchant admin dashboard.

Auth: authenticateToken + any tenant role.


PATCH /api/tenants/:tenantId — update tenant

Auth: authenticateToken + tenant role owner.

Request body (all optional):

{
  "displayName": "Updated Shop Name",
  "brand": { "primaryColor": "#FF6B35" },
  "features": { "telegramMiniApp": true },
  "localeDefaults": ["en", "fa"]
}

Response 200: Updated tenant record.


POST /api/tenants/:tenantId/suspend — suspend tenant

Platform admins only. Sets status = 'suspended'.

Auth: authenticateToken + authorizeRoles('admin').


POST /api/tenants/:tenantId/activate — activate tenant

Platform admins only. Sets status = 'active'. A new tenant must be activated before it is publicly accessible.

Auth: authenticateToken + authorizeRoles('admin').


POST /api/tenants/:tenantId/domains — add custom domain

Auth: authenticateToken + tenant role owner.

Request body:

{
  "hostname": "shop.example.com",
  "mode": "cname"
}
Field Required Description
hostname yes Full hostname to register. Must be globally unique.
mode no cname (default) or managed_ns.

Response 201: Domain record including verificationToken (the merchant uses this for DNS proof).

Response 400: hostname missing.

Domain starts with status = 'pending'. The tenant admin can trigger verification manually, and the backend domain poller retries pending domains on an interval. DNS can point either directly at the configured server IP or by CNAME to the configured Caddy target.


POST /api/tenants/:tenantId/domains/:domainId/verify — verify DNS and provision route

Checks whether the hostname resolves to the multi-stack ingress. If DNS passes, the backend adds an idempotent Caddy Admin API route for the hostname and marks the domain active with tlsStatus = 'pending'.

Auth: authenticateToken + tenant role owner or developer.

Response 200:

{
  "success": true,
  "data": { "...": "updated domain" },
  "meta": { "dnsVerified": true },
  "statusCode": 200
}

If DNS is not ready yet, the response still succeeds with dnsVerified: false and the domain remains pending.


POST /api/tenants/:tenantId/domains/:domainId/tls-check — check TLS status

Probes HTTPS for an active domain and updates tlsStatus to issued, pending, or failed.

Auth: authenticateToken + tenant role owner or developer.

Response 400 DOMAIN_NOT_ACTIVE: Domain must be active before TLS can be checked.


DELETE /api/tenants/:tenantId/domains/:domainId — remove domain

Deprovisions the Caddy route and marks the domain suspended with tlsStatus = 'expired'.

Auth: authenticateToken + tenant role owner.

Response 200: { "data": { "removed": true } }


GET /api/tenants/:tenantId/domains — list domains

Auth: authenticateToken + any tenant role.

Response 200: Array of TenantDomain records.


POST /api/tenants/:tenantId/telegram/bot — register Telegram bot

Stores the encrypted bot token, derives telegramBotId from the token prefix, resolves the username via Telegram getMe when not supplied, registers the tenant webhook when APP_URL or FRONTEND_URL is configured, and attempts to set the bot chat menu button to the tenant Mini App URL.

Auth: authenticateToken + tenant role owner or developer.

Request body:

{
  "botToken": "123456789:AAABB...",
  "username": "MyShopBot",
  "miniAppUrl": "https://myshop.amn.gg"
}
Field Required Description
botToken yes BotFather token. Must use <numeric_id>:<secret> format. Stored AES-256-GCM encrypted — never returned in responses.
username no Bot @username without @. If omitted, backend calls Telegram getMe and stores the returned username when available.
miniAppUrl no Tenant storefront base URL. If omitted, backend derives https://<tenantSlug>.<TENANT_BASE_DOMAIN>. The menu button opens <miniAppUrl>/telegram/.

Response 201: Public bot record (no encrypted token fields and no webhook secret). Pending bots include claimUrl.

[!warning] Token handling botToken in the request body is write-only. The API never returns it. Keep it out of logs.


Returns the pending bot's Telegram deep link:

{ "success": true, "data": { "claimUrl": "https://t.me/MyShopBot?start=..." } }

Auth: authenticateToken + tenant role owner or developer.


DELETE /api/tenants/:tenantId/telegram/bot/:botId — remove bot

Physically deletes the bot row after verifying it belongs to the tenant.

Auth: authenticateToken + tenant role owner or developer.

Response 200: { "data": { "removed": true } }


POST /api/telegram/tenant-webhook/:botId — tenant Telegram webhook

Unauthenticated public endpoint mounted before global auth/rate-limit middleware. Telegram must send X-Telegram-Bot-Api-Secret-Token; the route compares it to the stored webhookSecret.

Current handled update:

Update Behavior
/start <claimToken> on a pending bot Calls tenantBotService.claimAdmin(), stores adminTelegramUserId, flips bot status to active, and sends a confirmation message to the claimant.
Any other valid update Acknowledged with 200 { ok: true } and ignored for now.

GET /api/tenants/:tenantId/telegram/bots — list bots

Auth: authenticateToken + any tenant role.

Response 200: Array of public bot records. Secret fields are excluded. Each pending bot may include claimUrl.

Public bot field Description
id Internal bot row id.
tenantId Owning tenant id.
telegramBotId Numeric Telegram bot id as text.
username Bot username without @.
status pending, active, suspended, or revoked.
miniAppUrl Stored Mini App base URL, if supplied.
claimUrl Derived Telegram deep link while pending; null after claim.
adminTelegramUserId Telegram user id that claimed admin control, if any.

[!warning] Bot token handling encryptedToken, encryptedTokenIv, encryptedTokenTag, and webhookSecret never appear in route responses.


PUT /api/tenants/:tenantId/payment-policy — upsert payment policy

Idempotent — creates or replaces the single policy row for the tenant.

Auth: authenticateToken + tenant role owner or finance.

Request body:

{
  "allowedRails": ["amn_escrow", "amn_direct"],
  "defaultRail": "amn_escrow",
  "buyerDisclosureMode": "strict",
  "escrowRequiredAboveAmount": "500.000000000000000000",
  "escrowRequiredForCategories": ["digital-goods"]
}

defaultRail must be a member of allowedRails — returns 400 VALIDATION_ERROR if not.

Response 200: Policy record.


GET /api/tenants/:tenantId/payment-policy — get payment policy

Auth: authenticateToken + any tenant role.

Response 200: Policy record or null if none exists yet.


POST /api/tenants/:tenantId/roles — grant role

Auth: authenticateToken + tenant role owner.

Request body:

{ "userId": "uuid", "role": "manager" }

Response 201: Role grant record.


DELETE /api/tenants/:tenantId/roles — revoke role

Auth: authenticateToken + tenant role owner.

Request body:

{ "userId": "uuid", "role": "manager" }

Response 200: { "data": { "removed": true } }


Error codes

Code HTTP Meaning
TENANT_SLUG_TAKEN 409 Slug already registered.
TENANT_SLUG_INVALID 400 Slug does not match [a-z0-9-]{3,40}.
TENANT_NOT_FOUND 404 No tenant with that id / slug / host.
PREVIEW_FORBIDDEN 403 Slug preview requested from a non-platform host.
DOMAIN_NOT_FOUND 404 Domain id does not exist or is not owned by the tenant.
DOMAIN_NOT_ACTIVE 400 TLS check requested before domain status is active.
VALIDATION_ERROR 400 Missing required field or invalid value (e.g. defaultRail ∉ allowedRails).
RATE_LIMIT_EXCEEDED 429 Storefront rate limiter: 120 req/min per tenant+IP.

Tenant resolution middleware

tenantResolutionMiddleware (backend/src/shared/middleware/tenantResolution.ts) runs on every storefront route. It attaches req.tenant and req.tenantDomain (when a custom domain matched).

Resolution order:

  1. Preview — Host is platform base (amn.gg, localhost) and req.params.slug or req.query.t is present → resolveTenantBySlug(slug, { previewOnly: true }).
  2. Subdomain — Host ends with .amn.gg (single label only, e.g. seller.amn.gg) → resolveTenantByHost → slug lookup.
  3. Custom domain — Any other host → resolveTenantByHostfindByHostname.

On no match, req.tenant is undefined and the route handler returns 404.

[!important] Security invariants

  • Never reads X-Tenant-ID or any client-supplied header.
  • Only resolves preview by slug when the Host is the platform base.
  • Fail-open: resolution errors call next() without crashing the request.

Related: Tenant, Authentication API, PRD - Seller-Owned White-Label Shops and Bots.