Vendor Module
HTTP surface for vendor onboarding (a user registers to become a vendor via the step-wise wizard; an admin reviews + approves/rejects) and vendor directory reads (admin lists all approved vendors with team…
HTTP surface for vendor onboarding (a user registers to become a vendor via the step-wise wizard; 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 |
|---|---|---|
GET /vendor/registration/availability | required (any logged-in user) | — |
POST /vendor/registration | 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 registration 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/registration | yes — admin acts on it |
approved | POST /admin/vendor/applications/:id/approve | no |
rejected | POST /admin/vendor/applications/:id/reject | no — applicant must register again |
A user may have at most one application in pending (and may not register again once approved). Submitting in either case returns 409 CONFLICT. Registration also returns 409 when the caller already belongs to a vendor organization (including seeded/migrated vendors that never had an application row), or when the account email is already used by a live vendor profile or another pending application. Rejected applications stay on file; a fresh registration creates a new row when those email/membership checks still pass.
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;
};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 — registration
Base path: /vendor/registration. Requires a logged-in user. This is the step-wise onboarding wizard's single-submit surface.
GET /vendor/registration/availability — Check shop-name / slug availability
Slugifies the supplied name (or explicit slug) and reports whether it is free, checking both live vendors and other pending applications. Returns a free alternative the wizard can fall back to so the final submit can't fail late.
GET /vendor/registration/availability?name=Glow%20BeautyResponse 200 — { requestedSlug, available, suggestedSlug }.
Errors — 400 BAD_REQUEST when neither name nor slug is supplied, or the input yields an empty slug.
POST /vendor/registration — Submit a registration
The applicant's userId/userEmail come from the session. The slug is derived server-side from name (uniqueness suffix appended on collision); businessEmail defaults to the account email; businessPhone/businessDescription are optional.
Body — RegisterVendorInput
{
"name": "Glow Beauty",
"country": "AE",
"motivation": "expand_elsewhere",
"categories": ["01J9...", "01JA..."],
"bank": {
"bankName": "Emirates NBD",
"accountHolderName": "Glow Beauty LLC",
"accountNumber": "1234567890",
"routingNumber": "ENBD123",
"swiftCode": null
}
}| Field | Type | Constraints |
|---|---|---|
name | string | 1..255 chars; slugified to the shop slug |
country | string | 1..100 chars |
motivation | enum | exploring | ready_first_time | expand_elsewhere | new_store |
categories | string[] | category ids; each must exist and be active (else 400) |
bank | object | bankName, accountHolderName, accountNumber required; routingNumber/swiftCode optional |
businessPhone | string | optional, 1..50 chars |
businessDescription | string | optional, 1..2000 chars |
The wizard's "confirm account number" field is a client-side equality check only — the API receives
accountNumberonce.
Response 201 — ApplicationResponse with status="pending" (bank accountNumber masked).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod (bad motivation enum, missing required field) |
| 400 | BAD_REQUEST | One or more categories ids are unknown or inactive |
| 409 | CONFLICT | User already has a pending/approved application; already belongs to a vendor org; or the account email is already used by a live vendor or another 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 "Register again" 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). 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.slug, name =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,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/registration |
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…
Webhooks — Shared Inbox and Delivery Archive
Platform-agnostic inbound-webhook plumbing: a claim-and-outcome idempotency inbox any provider module can use, and a 90-day archive of every delivery to /webhooks/*.