Skip to content

Vendor API Platform (Keys & Vendor Leads)

The Vendor API Platform is the backend's public-facing, B2B, server-to-server API surface. It lets external partners (for example a lead-generation site like CollegeHai.com) push student leads directly into MyAnalyticsSchool's CRM and read back the status of leads they submitted. Access is gated by scoped, hashed API keys that MAS admins issue, rotate, disable, and revoke from the admin panel. Every authenticated call is metered into a per-key usage summary, and accepted leads flow through a VendorLead ingestion table and are later imported into the Sales CRM as RawLead rows.

Status: documented from source on this branch.


Overview

The platform has two distinct halves that share three entities:

  1. External vendor API (/api/v1/vendor/*) — authenticated by an X-API-Key header, NOT by JWT. A vendor POSTs leads and GETs back its own leads. This is the only versioned (/api/v1) public surface in the backend and is designed for server-to-server use only.
  2. Admin key management (/api/admin/vendor-keys/*) — authenticated by the normal JWT authMiddleware. Admins issue/rotate/revoke keys, set scopes, assign a vendor to a sales head, and inspect per-key leads and usage statistics.

Personas:

Persona Interface What they do
External vendor / partner /api/v1/vendor/* via X-API-Key Submit leads, poll lead status
MAS admin Admin panel → /api/admin/vendor-keys/* Issue, rotate, revoke keys; set scopes; view usage
Sales head Admin panel (read-only list) See vendors assigned to them; import vendor leads into their CRM pool

Where it sits in the suite: the vendor API is the front door for partner lead generation. Leads land in vendor_leads, and the Sales CRM later pulls them into raw_leads via RawLeadService.importFromVendor. CRM status changes flow back the other way, keeping the vendor's view of each lead current. See Sales CRM — Leads and Sales CRM — Assignment, Workflow & Targets.

Authoritative partner-facing integration guide lives at docs/VENDOR_LEAD_API.md in the backend repo.


Key concepts & entities

Concept Meaning
Vendor API key A credential of the form vk_live_<8 chars>.<43 chars secret>. The prefix is a plaintext lookup index; the secret is never stored.
Key prefix Public, indexed portion (vk_live_xxxxxxxx). Used to find the candidate key row in O(1).
Key hash HMAC-SHA256(secret, pepper) hex. The only thing persisted for verification.
Pepper A server-side secret (VENDOR_API_KEY_PEPPER, falls back to JWT_SECRET) mixed into the hash so a leaked DB cannot validate keys offline.
Scope / permission A key carries two booleans: canSubmit and canRead. Routes declare a required permission.
Vendor lead A lead submitted under a key. Always scoped to its issuing key (vendorApiKeyId).
externalRefId The vendor's own unique id for a lead, used for idempotent retries.
Access summary A deduplicated (key, ip, method, path) usage row that increments a hitCount instead of one row per request.
Assigned sales head The single sales head whose pool receives a vendor's leads; null = admin-only vendor.

Main TypeORM entities:

Entity Table File
VendorApiKey vendor_api_keys src/entities/VendorApiKey.ts
VendorLead vendor_leads src/entities/VendorLead.ts
VendorApiKeyAccessSummary vendor_api_key_access_summaries src/entities/VendorApiKeyAccessSummary.ts

Supporting code:

  • Key crypto: src/utils/vendorApiKey.ts
  • Vendor auth middleware: src/middleware/vendorAuth.middleware.ts
  • External vendor controller: src/controllers/vendorLead.controller.ts
  • Admin key controller: src/controllers/vendorKeyAdmin.controller.ts
  • Routes: src/routes/vendor.routes.ts, src/routes/vendorKeyAdmin.routes.ts, mounted in src/routes/index.ts
  • CRM bridge: src/services/RawLeadService.ts (importFromVendor, syncVendorLeadStatus)

Architecture

flowchart TD
  Vendor["External Vendor (server-to-server)"]
  Admin["MAS Admin / Sales Head (admin panel)"]

  subgraph PUB["Public API /api/v1/vendor"]
    VR["vendor.routes.ts"]
    VAUTH["vendorAuthMiddleware (X-API-Key)"]
    VLC["VendorLeadController"]
  end

  subgraph ADM["Admin API /api/admin/vendor-keys"]
    JWT["authMiddleware (JWT) + role guards"]
    VKC["VendorKeyAdminController"]
  end

  subgraph UTIL["Key crypto"]
    UK["utils/vendorApiKey.ts (HMAC-SHA256 + pepper)"]
  end

  subgraph DB["PostgreSQL (mas)"]
    KEYS["vendor_api_keys"]
    LEADS["vendor_leads"]
    SUM["vendor_api_key_access_summaries"]
    RAW["raw_leads (Sales CRM)"]
    COURSE["mas_courses (slug validation)"]
  end

  CRM["RawLeadService importFromVendor + syncVendorLeadStatus"]

  Vendor -->|X-API-Key| VR --> VAUTH
  VAUTH --> UK
  VAUTH -->|lookup by prefix| KEYS
  VAUTH -->|fire-and-forget usage write| SUM
  VAUTH --> VLC
  VLC -->|read / write own leads| LEADS
  VLC -->|optional slug check| COURSE

  Admin -->|JWT| JWT --> VKC
  VKC --> UK
  VKC -->|CRUD + lifecycle| KEYS
  VKC -->|per-key leads + counts| LEADS
  VKC -->|usage stats| SUM

  CRM -->|pull NEW vendor leads| LEADS
  CRM -->|create CRM lead| RAW
  CRM -->|status writeback| LEADS

Data model

erDiagram
  VENDOR_API_KEY ||--o{ VENDOR_LEAD : "issues"
  VENDOR_API_KEY ||--o{ VENDOR_API_KEY_ACCESS_SUMMARY : "metered by"
  USER ||--o{ VENDOR_API_KEY : "assigned sales head"
  USER ||--o{ VENDOR_API_KEY : "created by"
  VENDOR_LEAD ||--o| RAW_LEAD : "imported into CRM"

  VENDOR_API_KEY {
    uuid id PK
    varchar vendor_name
    varchar contact_email
    varchar key_prefix UK
    varchar key_hash
    enum status
    boolean can_submit
    boolean can_read
    text notes
    uuid assigned_sales_head_id FK
    uuid created_by FK
    timestamp last_used_at
    varchar last_ip
    varchar last_user_agent
    bigint request_count
    timestamp revoked_at
    timestamp created_at
    timestamp updated_at
  }

  VENDOR_LEAD {
    uuid id PK
    uuid vendor_api_key_id FK
    varchar name
    varchar phone
    varchar email
    varchar interest
    varchar course_slug
    varchar guardian_name
    varchar guardian_contact
    varchar class
    varchar high_school
    varchar jee_score
    varchar twelfth_percentage
    text address
    varchar source
    varchar medium
    varchar external_ref_id
    enum status
    jsonb raw_payload
    text notes
    timestamp created_at
    timestamp updated_at
  }

  VENDOR_API_KEY_ACCESS_SUMMARY {
    uuid id PK
    uuid vendor_api_key_id FK
    varchar ip
    varchar method
    varchar path
    bigint hit_count
    int last_status_code
    varchar last_user_agent
    int last_response_ms
    timestamp first_seen
    timestamp last_seen
    timestamp created_at
  }

Notable constraints and enums:

  • vendor_api_keys: unique index on key_prefix. status enum is ACTIVE | DISABLED | REVOKED (VendorApiKeyStatus). request_count is a bigint (serialized as string in TypeORM).
  • vendor_leads: unique constraint uq_vendor_lead_external_ref on (vendorApiKeyId, externalRefId) — this is the idempotency guard. Indexes on vendorApiKeyId, phone, status. FK to the key uses onDelete: 'RESTRICT' (you cannot delete a key that still has leads). status enum is NEW | CONTACTED | QUALIFIED | CONVERTED | REJECTED (VendorLeadStatus). raw_payload keeps a verbatim copy of the original POST body for audit/replay.
  • vendor_api_key_access_summaries: unique constraint uq_vendor_access_tuple on (vendorApiKeyId, ip, method, path); FK to the key uses onDelete: 'CASCADE'. Query string is stripped from path before storage so /leads?limit=1 and /leads?limit=50 collapse into one row.

API surface

External vendor API — mounted at /api/v1/vendor (src/routes/vendor.routes.ts)

Auth: vendorAuthMiddleware(permission) reading X-API-Key (or Authorization: Bearer <key>).

Method Path Auth / scope Purpose
POST /api/v1/vendor/leads API key with canSubmit Submit a new lead; idempotent on externalRefId
GET /api/v1/vendor/leads API key with canRead List the caller's own leads, paginated and filterable
GET /api/v1/vendor/leads/:id API key with canRead Fetch one of the caller's own leads by id

Admin key management — mounted at /api/admin/vendor-keys (src/routes/vendorKeyAdmin.routes.ts)

Auth: JWT authMiddleware for all routes, then role guards. GET / allows ADMIN or SALES_HEAD (read guard); every other route is ADMIN-only.

Method Path Auth / role Purpose
GET /api/admin/vendor-keys/ ADMIN or SALES_HEAD List keys (sales head sees only their assigned vendors) with lead counts
POST /api/admin/vendor-keys/ ADMIN Issue a new key; returns fullKey once
GET /api/admin/vendor-keys/:id ADMIN Get one key (metadata, never the hash)
PATCH /api/admin/vendor-keys/:id ADMIN Update name, email, scopes, notes, assigned sales head
POST /api/admin/vendor-keys/:id/enable ADMIN Set status back to ACTIVE
POST /api/admin/vendor-keys/:id/disable ADMIN Set status to DISABLED
POST /api/admin/vendor-keys/:id/revoke ADMIN Permanently revoke; records revokedAt
POST /api/admin/vendor-keys/:id/rotate ADMIN Issue a new secret for the same key row; returns new fullKey once
GET /api/admin/vendor-keys/:id/leads ADMIN List leads submitted under this key (full rows)
GET /api/admin/vendor-keys/:id/access-stats ADMIN Per-IP, per-status, and per-pattern usage breakdown

User journeys

Journey 1 — Admin issues a scoped vendor API key

An admin onboards a partner. The full key is returned exactly once; only the prefix and the keyed hash are persisted. The admin must capture and securely deliver the key immediately.

sequenceDiagram
  participant FE as Admin Panel
  participant API as POST /api/admin/vendor-keys
  participant MW as authMiddleware plus admin guard
  participant C as VendorKeyAdminController.create
  participant UK as utils generateVendorKey
  participant DB as vendor_api_keys

  FE->>API: create key with vendorName scopes assignedSalesHeadId
  API->>MW: verify JWT and ADMIN role
  MW-->>API: ok
  API->>C: create
  alt vendorName missing
    C-->>FE: 400 vendorName is required
  end
  opt assignedSalesHeadId provided
    C->>DB: load user and check role is SALES_HEAD
    alt not a sales head
      C-->>FE: 400 assignedSalesHeadId does not reference a sales head
    end
  end
  C->>UK: generateVendorKey
  UK-->>C: fullKey plus keyPrefix plus keyHash
  C->>DB: insert key row status ACTIVE
  DB-->>C: saved
  C-->>FE: 201 with serialized key and fullKey shown once plus warning
  Note over FE: Admin must save fullKey now. It is never retrievable again.

Journey 2 — Vendor authenticates and submits a lead

The headline path. A partner pushes a student lead with their key. The middleware authenticates, checks scope, attaches the key to the request, and meters the call asynchronously. The controller validates, optionally maps the course slug, applies idempotency, and persists.

sequenceDiagram
  participant V as Vendor Backend
  participant API as POST /api/v1/vendor/leads
  participant MW as vendorAuthMiddleware submit
  participant UK as utils hashSecret
  participant KDB as vendor_api_keys
  participant C as VendorLeadController.submit
  participant CDB as mas_courses
  participant LDB as vendor_leads
  participant SUM as vendor_api_key_access_summaries

  V->>API: X-API-Key plus lead body
  API->>MW: parse key
  alt key missing or malformed
    MW-->>V: 401 missing or malformed API key
  end
  MW->>KDB: find by keyPrefix
  MW->>UK: hash secret with pepper
  Note over MW: always run the compare even if no row to avoid a timing oracle
  alt no row or hash mismatch
    MW-->>V: 401 invalid API key
  else status not ACTIVE
    MW-->>V: 403 disabled or revoked
  else lacks submit scope
    MW-->>V: 403 key has no submit permission
  end
  MW->>C: attach req.vendor then next
  C->>C: validate required fields name phone interest guardianName guardianContact
  alt missing required fields
    C-->>V: 400 with missing list
  end
  opt courseSlug provided
    C->>CDB: lookup slug
    Note over C: unknown slug is dropped to null but kept in rawPayload
  end
  opt externalRefId provided
    C->>LDB: find existing by key and externalRefId
    alt already exists
      C-->>V: 200 deduplicated true with original lead
    end
  end
  C->>LDB: insert lead status NEW with rawPayload
  alt unique violation 23505 race
    C->>LDB: re-fetch existing
    C-->>V: 200 deduplicated true
  else saved
    LDB-->>C: saved row
    C-->>V: 201 created with serialized lead
  end
  Note over MW,SUM: on response finish setImmediate upserts the access summary and bumps requestCount

Journey 3 — Vendor lists and polls its own leads

A vendor reads back leads to track conversions. Results are always scoped to the caller's key, so one vendor can never see another vendor's leads.

sequenceDiagram
  participant V as Vendor Backend
  participant API as GET /api/v1/vendor/leads
  participant MW as vendorAuthMiddleware read
  participant C as VendorLeadController.list
  participant LDB as vendor_leads

  V->>API: query page limit status from to with X-API-Key
  API->>MW: authenticate and check read scope
  alt lacks read scope
    MW-->>V: 403 key has no read permission
  end
  MW->>C: attach req.vendor
  C->>C: clamp page to min 1 and limit to max 100
  C->>C: validate status against VendorLeadStatus and parse date range
  C->>LDB: findAndCount where vendorApiKeyId equals caller ordered by createdAt desc
  LDB-->>C: rows and total
  C-->>V: 200 with page limit total totalPages and leads
  Note over V: poll on a 15 to 60 minute cadence using a from watermark on updatedAt

Journey 4 — Vendor lead becomes a CRM lead, then status flows back

After ingestion, a sales head or admin imports vendor leads into the Sales CRM as RawLead rows. As CRM staff work the lead, its CRM interest level maps back onto the VendorLead.status the partner sees.

sequenceDiagram
  participant Sales as Sales Head or Admin
  participant RLS as RawLeadService importFromVendor
  participant VDB as vendor_leads
  participant RDB as raw_leads
  participant Sync as RawLeadService syncVendorLeadStatus

  Sales->>RLS: import with vendorIds sessionYear filters
  RLS->>VDB: select vendor leads for the chosen keys and filters
  VDB-->>RLS: source rows
  Note over RLS: dedup by vendorLeadId and by phone then skip rows missing name or phone
  RLS->>RDB: insert new raw_leads carrying vendorApiKeyId and vendorLeadId
  RDB-->>RLS: inserted count and duplicate phones
  RLS-->>Sales: summary total inserted duplicates invalid

  Note over Sales,Sync: later a CRM admin updates interest or webinar status
  Sales->>Sync: interest or webinar status change on the raw lead
  Sync->>VDB: update mapped VendorLead status
  Note over Sync: HOT to CONVERTED WARM to QUALIFIED COLD or calls to CONTACTED NOT_INTERESTED to REJECTED else NEW
  Note over VDB: vendor now sees the new status via GET /api/v1/vendor/leads

Journey 5 — Admin rotates or revokes a compromised key

When a key leaks, the admin can rotate (same row, new secret, old secret invalidated immediately) or revoke (terminal). Lead history is preserved in both cases because leads reference the key row id, which does not change on rotation.

sequenceDiagram
  participant FE as Admin Panel
  participant API as POST /api/admin/vendor-keys/:id/rotate
  participant C as VendorKeyAdminController.rotate
  participant UK as utils generateVendorKey
  participant DB as vendor_api_keys

  FE->>API: rotate key id
  API->>C: rotate after JWT and ADMIN guard
  C->>DB: load key
  alt not found
    C-->>FE: 404 key not found
  else status is REVOKED
    C-->>FE: 400 cannot rotate a revoked key
  end
  C->>UK: generateVendorKey
  UK-->>C: new fullKey prefix and hash
  C->>DB: update keyPrefix keyHash and set status ACTIVE
  C-->>FE: 200 with new fullKey shown once and warning previous key invalidated
  Note over FE: existing vendor_leads stay linked because the row id is unchanged

Journey 6 — Admin reviews per-key usage statistics

sequenceDiagram
  participant FE as Admin Panel
  participant API as GET /api/admin/vendor-keys/:id/access-stats
  participant C as VendorKeyAdminController.accessStats
  participant KDB as vendor_api_keys
  participant SUM as vendor_api_key_access_summaries

  FE->>API: request stats for key id
  API->>C: accessStats after JWT and ADMIN guard
  C->>KDB: load key
  alt not found
    C-->>FE: 404 key not found
  end
  C->>SUM: group by ip summing hitCount for ipBreakdown top 200
  C->>SUM: group by last_status_code summing hitCount for statusBreakdown
  C->>SUM: fetch up to 500 patterns ordered by lastSeen
  C-->>FE: 200 with totalRequests uniqueIps ipBreakdown statusBreakdown and patterns

Background jobs & async

There are no BullMQ queues or Socket.IO events dedicated to this domain. The one asynchronous mechanism is fire-and-forget usage metering inside vendorAuthMiddleware:

  • The middleware registers a res.on('finish', ...) handler and runs all DB writes inside setImmediate(...) so they never delay the response or the next request.
  • On every authenticated request it performs an atomic upsert into vendor_api_key_access_summaries keyed by uq_vendor_access_tuple — inserting a new (key, ip, method, path) row or incrementing hit_count and refreshing last_status_code, last_user_agent, last_response_ms, and last_seen.
  • It also bumps the key's request_count, last_used_at, last_ip, and last_user_agent via a request_count = request_count + 1 SQL increment.
  • Failures here are swallowed with a console.warn — metering never breaks the API.

The CRM import (RawLeadService.importFromVendor) is invoked on demand from the Sales CRM admin UI, not on a schedule. Status writeback (syncVendorLeadStatus) runs synchronously inside CRM lead-update operations.


External integrations

This domain has no third-party SaaS dependency. The only external actors are the vendor backends that call the API. Relevant configuration:

Env var Purpose Fallback
VENDOR_API_KEY_PEPPER HMAC key mixed into the stored secret hash Falls back to JWT_SECRET, then to the literal change-me-vendor-pepper
JWT_SECRET Used both for admin auth and as the pepper fallback Required for admin routes

Operational notes:

  • The pepper fallback means the feature works out of the box, but ops should set a dedicated VENDOR_API_KEY_PEPPER. Changing the pepper later invalidates all existing keys (their stored hashes no longer match), forcing a full rotation.
  • Course-slug validation is an internal lookup against mas_courses (MasCourse), not an external call.
  • No feature flag gates the platform — it is always mounted.

Status lifecycles

Vendor API key (VendorApiKeyStatus)

stateDiagram-v2
  [*] --> ACTIVE: create
  ACTIVE --> DISABLED: disable
  DISABLED --> ACTIVE: enable
  ACTIVE --> ACTIVE: rotate (new secret)
  DISABLED --> ACTIVE: rotate (re-activates)
  ACTIVE --> REVOKED: revoke
  DISABLED --> REVOKED: revoke
  REVOKED --> [*]
  note right of REVOKED
    Terminal. rotate, enable, disable,
    and update all reject with 400.
  end note
  • ACTIVE keys authenticate normally. DISABLED and REVOKED keys are rejected with 403 by the vendor middleware (different messages).
  • rotate always sets status back to ACTIVE (so a disabled key becomes usable again when rotated), except on a REVOKED key which is rejected.
  • revoke is one-way and stamps revokedAt.

Vendor lead (VendorLeadStatus)

stateDiagram-v2
  [*] --> NEW: lead submitted
  NEW --> CONTACTED: first call logged or marked Cold
  NEW --> QUALIFIED: marked Warm
  NEW --> CONVERTED: marked Hot
  NEW --> REJECTED: marked Not Interested
  CONTACTED --> QUALIFIED: marked Warm
  CONTACTED --> CONVERTED: marked Hot
  QUALIFIED --> CONVERTED: marked Hot
  CONTACTED --> REJECTED: marked Not Interested
  QUALIFIED --> REJECTED: marked Not Interested

Status is never set by the vendor — it is computed by RawLeadService.syncVendorLeadStatus from the linked CRM lead's interest level and webinar status. The mapping is: NOT_INTERESTED webinar status to REJECTED; interest HOT to CONVERTED; WARM to QUALIFIED; COLD to CONTACTED; any logged call to CONTACTED; otherwise NEW.


Edge cases, limits & gotchas

  • One-time secret exposure. fullKey is returned only by create and rotate. The DB stores only the prefix and the keyed hash, so MAS cannot recover a lost key — the only remedy is rotation.
  • Timing-attack hardening. vendorAuthMiddleware always computes the candidate hash even when no key row matches the prefix, then uses constantTimeEqual (crypto.timingSafeEqual) for the comparison, to avoid leaking whether a prefix exists.
  • Idempotency is per key. The unique constraint is (vendorApiKeyId, externalRefId). The same externalRefId from two different keys creates two leads. Re-posting under the same key returns the original lead with deduplicated: true and HTTP 200 (not 201). A unique-violation race (Postgres 23505) is caught and resolved by re-fetching.
  • Tenant isolation. list and getOne always filter on vendorApiKeyId, so GET /leads/:id returns 404 for a lead that exists but belongs to another vendor — indistinguishable from a missing lead.
  • Unknown course slug is non-fatal. An invalid courseSlug is silently dropped to null on the stored column, but the raw value is preserved in raw_payload for audit.
  • Extra fields are tolerated. Any field not in the known set is still kept in raw_payload; it never causes a 400.
  • No x-platform multi-tenant behavior here. Unlike most of the suite, the vendor API does not branch on the x-platform header; isolation is purely by API key.
  • No enforced rate limit (yet). docs/VENDOR_LEAD_API.md documents a soft ceiling of ~60 req/min. The per-key access summary exists to observe traffic, not to throttle it. IP allowlisting is on the roadmap, not implemented.
  • List/pagination clamps. Vendor GET /leads clamps limit to a max of 100 and page to a min of 1; admin :id/leads clamps limit to a max of 200; access-stats returns at most 200 IP rows and 500 pattern rows.
  • Admin role guard quirk. In vendorKeyAdmin.routes.ts both guards contain the duplicated condition role !== UserRole.ADMIN && role !== UserRole.ADMIN. Because the former SUPERADMIN role was merged into ADMIN, the effective behavior is correct (admin-only for management, admin-or-sales-head for the list), but the second clause is dead code (inferred — likely a leftover from the SUPERADMIN removal).
  • onDelete asymmetry. Deleting a key is blocked while leads exist (RESTRICT on vendor_leads), but access summaries cascade-delete with the key (CASCADE).
  • request_count is a bigint string. Both VendorApiKey.requestCount and VendorApiKeyAccessSummary.hitCount are bigint columns surfaced as strings by TypeORM and parsed with parseInt in the controller serializers.