Supercommerce API Docs
Full Module Docs

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 via VendorModule.forRoot() in apps/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 via resolveActiveVendorId(session).


Conventions

Authentication

Endpoint groupAuthPermission
GET /vendor/registration/availabilityrequired (any logged-in user)
POST /vendor/registrationrequired (any logged-in user)
GET /vendor/applications/myrequired (any logged-in user)
GET /admin/vendor/applications, GET /admin/vendor/applications/:idrequiredvendor: view
POST /admin/vendor/applications/:id/approve|rejectrequiredvendor: approve
GET /admin/vendors, GET /admin/vendors/:idrequiredvendor: 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

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409CONFLICT, UNIQUE_VIOLATION (slug already taken), pending application exists
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Lifecycle

vendor_application.status values:

StatusSet byReversible
pendingPOST /vendor/registrationyes — admin acts on it
approvedPOST /admin/vendor/applications/:id/approveno
rejectedPOST /admin/vendor/applications/:id/rejectno — 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%20Beauty

Response 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.

BodyRegisterVendorInput

{
  "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
  }
}
FieldTypeConstraints
namestring1..255 chars; slugified to the shop slug
countrystring1..100 chars
motivationenumexploring | ready_first_time | expand_elsewhere | new_store
categoriesstring[]category ids; each must exist and be active (else 400)
bankobjectbankName, accountHolderName, accountNumber required; routingNumber/swiftCode optional
businessPhonestringoptional, 1..50 chars
businessDescriptionstringoptional, 1..2000 chars

The wizard's "confirm account number" field is a client-side equality check only — the API receives accountNumber once.

Response 201ApplicationResponse with status="pending" (bank accountNumber masked).

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod (bad motivation enum, missing required field)
400BAD_REQUESTOne or more categories ids are unknown or inactive
409CONFLICTUser 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

StatusCodeWhen
404NOT_FOUNDUnknown id

POST /admin/vendor/applications/:id/approve — Approve

Required permission: vendor: approve. Allowed only from pending.

Side effects

  • Validates slug uniqueness against the organization slug column. On collision → 409 UNIQUE_VIOLATION.
  • Creates a new Better-Auth organization with slug = application.slug, name = businessName.
  • Adds the applicant as the organization's owner member.
  • Creates the vendor_profile row pointing at the organization (carrying country from the application).
  • When the application captured payout bank details, materialises a vendor_bank_account row 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

StatusCodeWhen
404NOT_FOUNDUnknown application id
409CONFLICTApplication is not pending
409UNIQUE_VIOLATIONSlug 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" }
FieldConstraints
reason1..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

StatusCodeWhen
404NOT_FOUNDUnknown application id
409CONFLICTApplication 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

StatusCodeWhen
404NOT_FOUNDUnknown vendor id

Domain events

Emitted via EventEmitter2. Listeners include the notifications module (onboarding email, rejection email).

EventFired when
vendor.application.submittedPOST /vendor/registration
vendor.application.approvedPOST /admin/vendor/applications/:id/approve
vendor.application.rejectedPOST /admin/vendor/applications/:id/reject

  • auth — owns the Better-Auth user + organization + member tables. Approval here creates an organization row.
  • admin-rbac — provides PermissionsGuard + vendor:view / vendor:approve permissions. See admin-rbac.md.
  • settings — vendor self-service settings (shipping, tax, etc.) are scoped to the organization id created here. See settings.md.
  • notifications — consumes the vendor.application.* events to email the applicant. See notifications.md.

On this page