Supercommerce API Docs
Admin API

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-self vendor-application.controller.ts belongs 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 owner member.


Conventions

Authentication

All endpoints require a Better-Auth admin session and a role granting the matching vendor:* permission.

Endpoint groupPermission
GET /admin/vendor/applications, GET /admin/vendor/applications/:idvendor: view
POST /admin/vendor/applications/:id/approve, POST /admin/vendor/applications/:id/rejectvendor: approve
GET /admin/vendors, GET /admin/vendors/:id, GET /admin/vendors/:id/analytics/dashboardvendor: view
POST /admin/vendorsvendor: create
POST /admin/vendors/:id/impersonatevendor: impersonate
POST /vendor/impersonation/redeemnone (guarded by the single-use handoff token)
POST /admin/vendors/impersonate/stopnone (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

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409CONFLICT, UNIQUE_VIOLATION (slug taken at approval time)
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Application lifecycle

vendor_application.status values:

StatusSet byReversible
pendingPOST /vendor/registration (vendor-self)yes — admin acts on it
approvedPOST /admin/vendor/applications/:id/approve or POST /admin/vendorsno
rejectedPOST /admin/vendor/applications/:id/rejectno — 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.accountNumber to 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

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 and 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 = 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

StatusCodeWhen
404NOT_FOUNDUnknown application id
409CONFLICTApplication is not pending
409UNIQUE_VIOLATIONslug 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" }
FieldTypeConstraints
reasonstring1..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

StatusCodeWhen
400VALIDATION_ERROREmpty / oversized reason
404NOT_FOUNDUnknown application id
409CONFLICTApplication 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

StatusCodeWhen
404NOT_FOUNDUnknown 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

ParamTypeDefaultNotes
windowtoday | 7d | 30d | all_time30dSnapshot window

Response 200VendorAnalyticsDashboard (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 origin

POST /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
}

Errors404 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
}

Errors400 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).

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

  • admin-rbac — gates every endpoint via vendor:view / vendor:approve. See admin-rbac.md.
  • auth — owns the Better-Auth user + organization + member tables. 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 in settings.md / shipping.md / tax.md use the same vendor id.
  • notifications — consumes the vendor.application.* events to email the applicant.

On this page