Vendor Module
HTTP surface for vendor onboarding (a user applies to become a vendor; an admin reviews + approves/rejects) and vendor directory reads (admin lists all approved vendors with team…
HTTP surface for vendor onboarding (a user applies to become a vendor; an admin reviews + approves/rejects) and vendor directory reads (admin lists all approved vendors with team membership).
Source:
api-modules/vendor(registered viaVendorModule.forRoot()inapps/api/src/app.module.ts).Approving an application provisions a Better-Auth organization (the vendor's tenant within the platform) and creates the vendor profile row. The applying user is added as the organization's first member; the session token they renew next will surface the new
activeOrganizationId, which downstream modules consume viaresolveActiveVendorId(session).
Conventions
Authentication
| Endpoint group | Auth | Permission |
|---|---|---|
POST /vendor/applications | required (any logged-in user) | — |
GET /vendor/applications/my | required (any logged-in user) | — |
GET /admin/vendor/applications, GET /admin/vendor/applications/:id | required | vendor: view |
POST /admin/vendor/applications/:id/approve|reject | required | vendor: approve |
GET /admin/vendors, GET /admin/vendors/:id | required | vendor: view |
The application submit endpoint requires a session because the applicant becomes the first member of the new organization — there is no "anonymous vendor signup" flow.
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 already taken), pending application exists |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle
vendor_application.status values:
| Status | Set by | Reversible |
|---|---|---|
pending | POST /vendor/applications | yes — admin acts on it |
approved | POST /admin/vendor/applications/:id/approve | 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. Submitting again while a pending one exists returns 409 CONFLICT. Rejected applications stay on file; a fresh submission creates a new row.
Domain types
ApplicationResponse
type ApplicationStatus = "pending" | "approved" | "rejected";
type ApplicationResponse = {
id: string;
userId: string; // applicant's user id
businessName: string;
slug: string; // lowercase alnum + hyphens; unique on approval
businessEmail: string;
businessPhone: string;
businessDescription: string;
status: ApplicationStatus;
rejectionReason: string | null;
reviewedBy: string | null; // admin user id
reviewedAt: string | null; // ISO
createdAt: string;
updatedAt: string;
};Vendor profile (admin reads)
The admin vendor list returns the underlying Better-Auth organization joined with the vendor profile and a team-member count. Exact shape lives in VendorProfileService.findAll / findByIdWithDetails — at minimum:
type VendorProfileSummary = {
id: string; // == organization id (the vendor id)
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;
}>;
};Vendor — applications
Base path: /vendor/applications. Requires a logged-in user.
POST /vendor/applications — Submit an application
The applicant's userId and userEmail are taken from the session; the body carries business details only.
Body
{
"businessName": "Acme Bakery",
"slug": "acme-bakery",
"businessEmail": "hello@acme-bakery.example",
"businessPhone": "+919876543210",
"businessDescription": "Artisan sourdough since 2018."
}| Field | Type | Constraints |
|---|---|---|
businessName | string | 1..255 chars |
slug | string | 1..255 chars; lowercase alphanumeric + hyphens; uniqueness checked at approval time, not submission |
businessEmail | string | RFC 5322 email |
businessPhone | string | 1..50 chars |
businessDescription | string | 1..2000 chars |
Response 201 — ApplicationResponse with status="pending".
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod (invalid email, slug regex, etc.) |
| 409 | CONFLICT | User already has a pending application |
GET /vendor/applications/my — My application history
Returns every application the caller has submitted (ordered most recent first). Use this to show a "Resubmit" prompt after a rejection or to surface review status to the applicant.
Response 200 — array of ApplicationResponse.
Admin — applications
Base path: /admin/vendor/applications. Permissions on the vendor:* resource.
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).
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.slug, name =businessName. - Adds the applicant as the organization's
ownermember. - Creates the
vendor_profilerow pointing at the organization. - Stamps
status="approved",reviewedBy,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 (re-login or hit /api/auth/session/refresh) 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 taken since submission (admin should bounce back to applicant for a new slug) |
POST /admin/vendor/applications/:id/reject — Reject
Required permission: vendor: approve. Allowed only from pending.
Body
{ "reason": "Required documents not provided" }| Field | Constraints |
|---|---|
reason | 1..2000 chars |
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 |
|---|---|---|
| 404 | NOT_FOUND | Unknown application id |
| 409 | CONFLICT | Application is not pending |
Admin — 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.
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 |
Domain events
Emitted via EventEmitter2. Listeners include the notifications module (onboarding email, rejection email).
| Event | Fired when |
|---|---|
vendor.application.submitted | POST /vendor/applications |
vendor.application.approved | POST /admin/vendor/applications/:id/approve |
vendor.application.rejected | POST /admin/vendor/applications/:id/reject |
Related modules
auth— owns the Better-Authuser+organization+membertables. Approval here creates an organization row.admin-rbac— providesPermissionsGuard+vendor:view/vendor:approvepermissions. Seeadmin-rbac.md.settings— vendor self-service settings (shipping, tax, etc.) are scoped to the organization id created here. Seesettings.md.notifications— consumes thevendor.application.*events to email the applicant. Seenotifications.md.
Tax Module
Two-part tax system. Base tax module defines the neutral TaxProvider port + registry and shared types (TaxConfigLine, TaxComponent). tax-flat provider is the concrete…
Affiliate Module — Storefront
HTTP surface for the customer-facing affiliate plugin. Customers apply to join, generate trackable referral links, see their commission balance + payout history, and (anonymous…