Vendor Module — Admin
Create vendors directly, review vendor applications, and manage the vendor directory.
HTTP surface for the platform-admin side of vendor onboarding: create active vendors directly, list / inspect vendor applications, approve them (provisioning a Better-Auth organization + vendor profile) or reject them with a reason; and list / inspect existing approved vendors via the vendor directory. The vendor-self application submit / "my applications" endpoints live in the same module but are out of scope here (see vendor/vendor.md).
Source:
api-modules/vendor/src/controllers/vendor-admin.controller.ts,api-modules/vendor/src/controllers/vendor-management.controller.ts. The vendor-selfvendor-application.controller.tsbelongs to the vendor folder, not this one.Approving an application provisions a Better-Auth organization (the vendor's tenant within the platform) and creates the vendor profile row. The applicant becomes the organization's first
ownermember.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching vendor:* permission.
| Endpoint group | Permission |
|---|---|
GET /admin/vendor/applications, GET /admin/vendor/applications/:id | vendor: view |
POST /admin/vendor/applications/:id/approve, POST /admin/vendor/applications/:id/reject | vendor: approve |
GET /admin/vendors, GET /admin/vendors/:id, GET /admin/vendors/:id/analytics/dashboard | vendor: view |
POST /admin/vendors | vendor: create |
POST /admin/vendors/:id/impersonate | vendor: impersonate |
POST /vendor/impersonation/redeem | none (guarded by the single-use handoff token) |
POST /admin/vendors/impersonate/stop | none (guarded by the admin_session cookie) |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT, UNIQUE_VIOLATION (slug taken at approval time) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Application lifecycle
vendor_application.status values:
| Status | Set by | Reversible |
|---|---|---|
pending | POST /vendor/registration (vendor-self) | yes — admin acts on it |
approved | POST /admin/vendor/applications/:id/approve or POST /admin/vendors | no |
rejected | POST /admin/vendor/applications/:id/reject | no — applicant must submit a new application |
A user may have at most one application in pending (enforced on the vendor-self submit side).
Domain types
ApplicationResponse
type ApplicationStatus = "pending" | "approved" | "rejected";
type ApplicationResponse = {
id: string;
userId: string; // applicant's user id
applicant: { id: string; name: string; email: string } | null;
businessName: string; // shop name from the registration wizard
slug: string; // derived from the name; unique on approval
businessEmail: string;
businessPhone: string;
businessDescription: string;
country: string | null; // shop country
motivation: string | null; // "what brings you" onboarding answer
categories: string[]; // category ids of interest
bank: { // payout snapshot; null until collected
bankName: string;
accountHolderName: string;
accountNumber: string; // masked to last 4 in the list; raw in detail
routingNumber: string | null;
swiftCode: string | null;
} | null;
status: ApplicationStatus;
rejectionReason: string | null;
reviewedBy: string | null; // admin user id
reviewer: { id: string; name: string; email: string } | null;
reviewedAt: string | null; // ISO
createdAt: string;
updatedAt: string;
};Account-number masking: the list endpoint masks
bank.accountNumberto its last 4 digits; the application-detail endpoint returns the raw row (full number) for vetting.
Vendor profile (admin reads)
The admin vendor list returns the Better-Auth organization joined with the vendor profile plus a team-member count. Exact shape lives in VendorProfileService; at minimum:
type VendorProfileSummary = {
id: string; // == organization id (the vendor id used everywhere else)
businessName: string;
slug: string;
businessEmail: string;
businessPhone: string;
businessDescription: string;
memberCount: number;
createdAt: string;
updatedAt: string;
members?: Array<{ // only on the detail endpoint
userId: string;
email: string;
name: string;
role: string; // "owner" | "admin" | "member"
joinedAt: string;
}>;
};Direct vendor creation
POST /admin/vendors — Create an active vendor
Required permission: vendor: create. In the admin portal, open Vendors → All Vendors → Add Vendor (the directory also requires vendor: view).
{
"businessName": "Acme Supplies",
"businessEmail": "shop@acme.example",
"businessPhone": "+919876543210",
"businessDescription": "Everyday essentials",
"country": "India",
"owner": {
"type": "new",
"name": "Acme Owner",
"email": "owner@acme.example",
"password": "a-strong-initial-password"
}
}businessName (1–255 characters), businessEmail, country (1–100 characters), and owner are required. Phone (up to 50 characters) and description (up to 2000 characters) are optional. Emails are trimmed and lowercased. For a new owner, supply a name (1–255 characters), email, and password (8–128 characters). The account has no staff permissions and its email remains unverified; share its login details securely, as the password is never emailed or returned.
To use an existing account, supply owner: { "type": "existing", "email": "owner@acme.example" }. Its name, credentials, and email-verification state are preserved. Staff, guest, and banned accounts cannot be owners. An owner already belonging to a vendor or holding a pending/approved application is rejected; review an existing pending application instead. Business emails already used by a vendor or pending application are rejected too.
The server derives an available slug, provisions the organization with the owner as its first member, and writes the profile plus an approved application record containing the creating admin and review time. It selects the vendor on the owner's active sessions and emits the existing vendor.application.approved event after persistence (including the operator-editable approval email). Bank details can be added later through the vendor portal. No pending submission notification is sent.
Response 201:
{
"data": {
"id": "vendor-id",
"name": "Acme Supplies",
"slug": "acme-supplies",
"ownerUserId": "owner-user-id"
},
"message": "Success",
"statusCode": 201
}Errors: 400 invalid input or ineligible owner; 403 missing permission; 404 existing owner email not found; 409 duplicate new-account email, vendor membership/application, business email, or slug collision. A failed provisioning attempt cleans up the newly provisioned organization and any newly created owner account; existing accounts are retained.
Applications
Base path: /admin/vendor/applications.
GET /admin/vendor/applications — List applications
Required permission: vendor: view. Standard QueryDto (page / limit / search / filters[]).
Response 200 — paginated envelope of ApplicationResponse[].
GET /admin/vendor/applications/:id — Application detail
Required permission: vendor: view. Returns ApplicationResponse plus the applicant's user details (name, email, image) — joined in the service. Also returns a top-level reviewer: { id: string; name: string; email: string } | null (the admin who reviewed the application; null while pending).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/vendor/applications/:id/approve — Approve
Required permission: vendor: approve. Allowed only from pending.
Side effects
- Validates
sluguniqueness against the organization slug column. On collision → 409UNIQUE_VIOLATION. - Creates a new Better-Auth organization with
slug = application.slugandname = businessName. - Adds the applicant as the organization's
ownermember. - Creates the
vendor_profilerow pointing at the organization (carryingcountryfrom the application). - When the application captured payout bank details, materialises a
vendor_bank_accountrow from the snapshot (1:1), in the same transaction. - Stamps
status="approved",reviewedBy = current admin user id,reviewedAt. - Emits
vendor.application.approved(consumed by notifications — sends a welcome email).
The applicant's existing session does not automatically gain access to the new organization — they need to renew the session so Better-Auth surfaces the new activeOrganizationId.
Response 200 — updated ApplicationResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown application id |
| 409 | CONFLICT | Application is not pending |
| 409 | UNIQUE_VIOLATION | slug already taken by another organization since submission |
POST /admin/vendor/applications/:id/reject — Reject
Required permission: vendor: approve. Allowed only from pending.
Body
{ "reason": "Required documents not provided" }| Field | Type | Constraints |
|---|---|---|
reason | string | 1..2000 |
Side effects
- Stamps
status="rejected",rejectionReason,reviewedBy,reviewedAt. - Emits
vendor.application.rejected(consumed by notifications — sends an email with the reason).
Response 200 — updated ApplicationResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Empty / oversized reason |
| 404 | NOT_FOUND | Unknown application id |
| 409 | CONFLICT | Application is not pending |
Vendor directory
Base path: /admin/vendors.
GET /admin/vendors — List approved vendors
Required permission: vendor: view. Standard QueryDto. Returns approved vendors with profile + member count.
Response 200 — paginated envelope of VendorProfileSummary[] (without the members[] field).
GET /admin/vendors/:id — Vendor detail
Required permission: vendor: view. Returns the full VendorProfileSummary with members[] populated.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown vendor id |
GET /admin/vendors/:id/analytics/dashboard — Vendor analytics (admin scope)
Required permission: vendor: view. Admin-scoped read of a single vendor's precomputed analytics snapshot — the same payload the vendor sees on their own dashboard (GET /vendor/analytics/dashboard). No new computation: served from the vendor_analytics_snapshot row maintained by the vendor-analytics refresh job.
Query
| Param | Type | Default | Notes |
|---|---|---|---|
window | today | 7d | 30d | all_time | 30d | Snapshot window |
Response 200 — VendorAnalyticsDashboard (headline KPIs: grossSales, netEarnings, commissionPaid, ordersCount, unitsSold, aov, fulfillment timing, product/inventory counts; plus salesTrend, statusBreakdown, topProductsByRevenue/ByUnits, lowStockList, deltas). A vendor with no snapshot yet returns a zeroed payload with computedAt: null.
Vendor impersonation
Signs the admin into the vendor portal as one of the vendor's own members, so they see exactly what that seller sees. Mirrors customer impersonation (admin/customer.md) but in vendor context: the minted session also carries activeVendorId, without which every /vendor/* endpoint would 403 on resolveActiveVendorId.
The session is minted natively by us behind PermissionsGuard (never via Better-Auth's static-role check), and impersonatedBy + the admin_session cookie point at the real acting admin, so stopping restores that admin's own session. The redemption and the stop are both written to user_admin_audit with source admin-vendor-portal.
Why this is a two-step handoff
Each frontend proxies the API through its own origin (/bff/* → BACKEND_URL, see each app's next.config.js). That is deliberate: Better-Auth writes its session cookie onto whatever origin the request lands on, so routing through per-app origins keeps admin, vendor and store sessions isolated instead of sharing one cookie on a common API host.
The direct consequence is that the admin app cannot be given a vendor session. A Set-Cookie on the admin's impersonate call is scoped to the admin origin — it would sign the admin in as the vendor inside the admin app, and the vendor portal would still see nothing.
So the start call returns a single-use token instead, and the vendor portal redeems it through its own origin:
admin app ──POST /admin/vendors/:id/impersonate──▶ API (validates, mints token)
◀──────────── handoffUrl ──────────────
│
└─▶ opens https://vendor.example.com/impersonate?token=…
│
└─▶ POST /vendor/impersonation/redeem (via the VENDOR origin's /bff)
◀── Set-Cookie: session scoped to the vendor originPOST /admin/vendors/:id/impersonate — Issue a handoff link
Required permission: vendor: impersonate. Resolves the vendor's owner, falling back to its longest-standing member; pass memberUserId to choose a specific member. Validates everything up front, then mints a token — no session and no cookie are created here.
Body (optional)
{ "memberUserId": "<user-id|null>" }Response 200
{
"vendorId": "…",
"vendorName": "Acme Supplies",
"impersonatedUserId": "…",
"impersonatedUserEmail": "owner@acme.test",
"impersonatedUserName": "Owner",
"memberRole": "owner",
"vendorPortalUrl": "https://vendor.example.com", // admin.vendor_urls.portal_url; null when unset
"handoffPath": "/impersonate?token=…", // for callers that know their own portal origin
"handoffUrl": "https://vendor.example.com/impersonate?token=…", // null when portal URL unset
"expiresAt": "2026-08-01T12:01:00.000Z" // TOKEN expiry (60s), not the session's
}Errors — 404 NOT_FOUND (unknown vendor, named user isn't a member, or the vendor has no members); 400 BAD_REQUEST (the member is banned); 403 FORBIDDEN (caller lacks vendor:impersonate, or the target member is a staff account). That last rule matters: a staff member sitting on a vendor's team is never impersonable here, so a vendor:impersonate grant can't be used to reach an admin — let alone a superAdmin — account.
POST /vendor/impersonation/redeem — Redeem the token
Public by necessity — the browser has no session on the vendor origin yet; creating one is the point of the call. Authority is the token itself: 32 random bytes, single-use (consumption is atomic, so a replay finds nothing) and valid for 60 seconds. The target is re-checked on redemption, so a ban or a promotion to staff inside that window still takes effect.
Body — { "token": "…" }
Response 200 — Set-Cookie establishes the session on the calling origin:
{
"vendorId": "…",
"vendorName": "Acme Supplies",
"impersonatedUserId": "…",
"impersonatedUserEmail": "owner@acme.test",
"impersonatedUserName": "Owner",
"memberRole": "owner",
"expiresAt": "2026-08-01T13:00:00.000Z" // SESSION expiry, 1h default
}Errors — 400 BAD_REQUEST (token missing, already used, or expired); the same 403/400 target checks as above if the member changed in the meantime.
The vendor portal serves this at /impersonate, which redeems the token, strips it from the URL and history, and redirects to the dashboard.
POST /admin/vendors/impersonate/stop — Stop
No permission required, by design: while impersonating, the caller's session is the (permissionless) vendor member's. Authority comes from the admin_session cookie, so it can only restore the admin who started this session. This is what the vendor portal's impersonation banner calls.
Response 200 — { "stopped": true }, with Set-Cookie restoring the admin's session.
Domain events
Emitted via EventEmitter2. Listeners include the notifications module (onboarding / rejection emails).
| Event | Fired when |
|---|---|
vendor.application.approved | POST /admin/vendor/applications/:id/approve |
vendor.application.rejected | POST /admin/vendor/applications/:id/reject |
Related modules
admin-rbac— gates every endpoint viavendor:view/vendor:approve. Seeadmin-rbac.md.auth— owns the Better-Authuser+organization+membertables. Approval creates an organization row.settings— vendor self-service settings (shipping, tax, etc.) are scoped to the organization id created here. The platform-admin override endpoints insettings.md/shipping.md/tax.mduse the same vendor id.notifications— consumes thevendor.application.*events to email the applicant.
Tax Module — Admin
HTTP surface for the platform-admin override of any vendor's flat-tax config — the list of tax lines (e.g. { type: "GST", rate: 18 }) that the flat tax provider applies to that…
Webhooks Module — Admin
Archive of every inbound provider webhook delivery — the request we received and the exact response we sent back, including deliveries we rejected and ones rate-limited before routing. Stored in ClickHouse with a 90-day TTL, joined to the idempotency ledger, and served as a filterable, paginated admin read API with an operator resend that recomputes the provider credential rather than storing it.