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:
- External vendor API (
/api/v1/vendor/*) — authenticated by anX-API-Keyheader, NOT by JWT. A vendorPOSTs leads andGETs 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. - Admin key management (
/api/admin/vendor-keys/*) — authenticated by the normal JWTauthMiddleware. 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 insrc/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 onkey_prefix.statusenum isACTIVE | DISABLED | REVOKED(VendorApiKeyStatus).request_countis abigint(serialized as string in TypeORM).vendor_leads: unique constraintuq_vendor_lead_external_refon(vendorApiKeyId, externalRefId)— this is the idempotency guard. Indexes onvendorApiKeyId,phone,status. FK to the key usesonDelete: 'RESTRICT'(you cannot delete a key that still has leads).statusenum isNEW | CONTACTED | QUALIFIED | CONVERTED | REJECTED(VendorLeadStatus).raw_payloadkeeps a verbatim copy of the original POST body for audit/replay.vendor_api_key_access_summaries: unique constraintuq_vendor_access_tupleon(vendorApiKeyId, ip, method, path); FK to the key usesonDelete: 'CASCADE'. Query string is stripped frompathbefore storage so/leads?limit=1and/leads?limit=50collapse 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 insidesetImmediate(...)so they never delay the response or the next request. - On every authenticated request it performs an atomic upsert into
vendor_api_key_access_summarieskeyed byuq_vendor_access_tuple— inserting a new(key, ip, method, path)row or incrementinghit_countand refreshinglast_status_code,last_user_agent,last_response_ms, andlast_seen. - It also bumps the key's
request_count,last_used_at,last_ip, andlast_user_agentvia arequest_count = request_count + 1SQL 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
ACTIVEkeys authenticate normally.DISABLEDandREVOKEDkeys are rejected with403by the vendor middleware (different messages).rotatealways sets status back toACTIVE(so a disabled key becomes usable again when rotated), except on aREVOKEDkey which is rejected.revokeis one-way and stampsrevokedAt.
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.
fullKeyis returned only bycreateandrotate. 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.
vendorAuthMiddlewarealways computes the candidate hash even when no key row matches the prefix, then usesconstantTimeEqual(crypto.timingSafeEqual) for the comparison, to avoid leaking whether a prefix exists. - Idempotency is per key. The unique constraint is
(vendorApiKeyId, externalRefId). The sameexternalRefIdfrom two different keys creates two leads. Re-posting under the same key returns the original lead withdeduplicated: trueand HTTP200(not201). A unique-violation race (Postgres23505) is caught and resolved by re-fetching. - Tenant isolation.
listandgetOnealways filter onvendorApiKeyId, soGET /leads/:idreturns404for a lead that exists but belongs to another vendor — indistinguishable from a missing lead. - Unknown course slug is non-fatal. An invalid
courseSlugis silently dropped tonullon the stored column, but the raw value is preserved inraw_payloadfor audit. - Extra fields are tolerated. Any field not in the known set is still kept in
raw_payload; it never causes a400. - No
x-platformmulti-tenant behavior here. Unlike most of the suite, the vendor API does not branch on thex-platformheader; isolation is purely by API key. - No enforced rate limit (yet).
docs/VENDOR_LEAD_API.mddocuments 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 /leadsclampslimitto a max of 100 andpageto a min of 1; admin:id/leadsclampslimitto a max of 200;access-statsreturns at most 200 IP rows and 500 pattern rows. - Admin role guard quirk. In
vendorKeyAdmin.routes.tsboth guards contain the duplicated conditionrole !== UserRole.ADMIN && role !== UserRole.ADMIN. Because the formerSUPERADMINrole was merged intoADMIN, 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). onDeleteasymmetry. Deleting a key is blocked while leads exist (RESTRICTonvendor_leads), but access summaries cascade-delete with the key (CASCADE).request_countis a bigint string. BothVendorApiKey.requestCountandVendorApiKeyAccessSummary.hitCountarebigintcolumns surfaced as strings by TypeORM and parsed withparseIntin the controller serializers.
Related docs¶
- Sales CRM — Leads —
RawLeadingestion, the destination for vendor leads. - Sales CRM — Assignment, Workflow & Targets — sales heads, lead ownership, the import-from-vendor flow.
- Identity & Access — JWT
authMiddleware,UserRole, role guards used by the admin surface. - Comms & Telephony (Exotel) — call logging that drives the
CONTACTEDstatus mapping. - System Design — where the public
/api/v1surface sits in the overall backend.