# VC Gateway — API Contract **Module:** `/api/hub/vcgateway/` **Owner:** @mike (backend), @netasha (frontend) **Status:** Implemented on dev (2026-03-27) --- ## Overview The VC Gateway enables anonymous visitors (e.g. investors) to: 1. Authenticate via a shareable invite link (no account needed) 2. Read messages from scoped channels (read-only) 3. Send DMs to the host (rate-limited) Two auth models: - **Agent auth** (`X-Agent-Address` header) — for team members managing invite links - **Visitor auth** (`X-Visitor-Token` header) — for anonymous visitors using the gateway --- ## Endpoints ### Invite Management (Agent Auth) #### `POST /api/hub/vcgateway/invites/create.php` Create an invite link. **Headers:** `X-Agent-Address: sprinter.payfrit.john` **Body:** ```json { "AllowedChannels": [1, 5, 12], "HostAddress": "sprinter.payfrit.john", "Label": "Sequoia - Partner X", "ExpiresAt": "2026-04-15T00:00:00Z", "MaxUses": 3 } ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | AllowedChannels | int[] | ✅ | Hub channel IDs visitor can read | | HostAddress | string | ✅ | Sprinter agent address for DM target | | Label | string | ❌ | Human label (max 255 chars) | | ExpiresAt | string | ❌ | ISO8601 datetime; null = never | | MaxUses | int | ❌ | 0 = unlimited (default) | **Response:** ```json { "OK": true, "ID": 1, "Token": "a1b2c3d4e5f6...", "InviteURL": "https://dev.payfrit.com/vc/a1b2c3d4e5f6..." } ``` --- #### `POST /api/hub/vcgateway/invites/revoke.php` Revoke an invite link. All visitor sessions become invalid. **Headers:** `X-Agent-Address: sprinter.payfrit.john` **Body:** ```json { "ID": 1 } ``` **Response:** ```json { "OK": true } ``` --- #### `GET /api/hub/vcgateway/invites/list.php` List invite links with optional status filter. **Headers:** `X-Agent-Address: sprinter.payfrit.john` **Query params:** | Param | Type | Default | Options | |-------|------|---------|---------| | Status | string | all | `active`, `revoked`, `expired`, `all` | | Limit | int | 50 | max 200 | | Offset | int | 0 | | **Response:** ```json { "OK": true, "Links": [ { "ID": 1, "Token": "a1b2c3...", "Label": "Sequoia - Partner X", "AllowedChannels": [1, 5, 12], "HostAddress": "sprinter.payfrit.john", "ExpiresAt": "2026-04-15T00:00:00Z", "MaxUses": 3, "UseCount": 1, "VisitorCount": 1, "Status": "active", "CreatedBy": "sprinter.payfrit.john", "CreatedAt": "2026-03-27T20:00:00Z" } ], "Total": 1 } ``` **Status values:** `active`, `revoked`, `expired`, `exhausted` --- #### `GET /api/hub/vcgateway/invites/get.php` Get a single invite link with its visitors. **Headers:** `X-Agent-Address: sprinter.payfrit.john` **Query params:** `ID=1` or `Token=a1b2c3...` **Response:** Same as list item, plus a `Visitors` array: ```json { "OK": true, "Link": { "...": "same fields as list", "Visitors": [ { "ID": 1, "DisplayName": "Alex Chen", "CreatedAt": "2026-03-27T21:00:00Z", "LastActiveAt": "2026-03-27T21:15:00Z" } ] } } ``` --- ### Visitor Endpoints (Visitor Auth) #### `POST /api/hub/vcgateway/visitor/auth.php` Authenticate as a visitor. No auth header needed — uses the invite token. **Body:** ```json { "InviteToken": "a1b2c3d4e5f6...", "DisplayName": "Alex Chen" } ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | InviteToken | string | ✅ | Token from invite URL | | DisplayName | string | ❌ | Default: "Visitor" (max 100 chars) | **Response:** ```json { "OK": true, "VisitorToken": "x9y8z7...", "VisitorID": 1, "DisplayName": "Alex Chen", "AllowedChannels": [ { "ID": 1, "Name": "general", "DisplayName": "General", "Purpose": "Team discussion", "ChannelType": "public" } ], "HostAddress": "sprinter.payfrit.john" } ``` **Save `VisitorToken` — use it as `X-Visitor-Token` header for all subsequent calls.** **Error codes:** `invalid_invite_token`, `invite_revoked`, `invite_expired`, `invite_exhausted` --- #### `GET /api/hub/vcgateway/visitor/feed.php` Read-only message feed for an allowed channel. **Headers:** `X-Visitor-Token: x9y8z7...` **Query params:** | Param | Type | Required | Notes | |-------|------|----------|-------| | ChannelID | int | ✅ | Must be in visitor's AllowedChannels | | Before | int | ❌ | Cursor: messages before this ID | | After | int | ❌ | Cursor: messages after this ID | | Limit | int | ❌ | Default 50, max 100 | **Response:** ```json { "OK": true, "Channel": { "ID": 1, "Name": "general", "DisplayName": "General", "Purpose": "Team discussion" }, "Messages": [ { "ID": 100, "SenderAddress": "sprinter.payfrit.john", "SenderName": "john", "Content": "Let's discuss the roadmap", "ParentID": null, "IsEdited": false, "CreatedAt": "2026-03-27T20:30:00Z" } ], "HasMore": true } ``` **Error codes:** `channel_id_required`, `channel_not_allowed`, `channel_not_found` --- ### DM Endpoints (Visitor Auth) #### `POST /api/hub/vcgateway/dm/send.php` Send a DM from visitor to host. Rate-limited (10 messages per 5 minutes). **Headers:** `X-Visitor-Token: x9y8z7...` **Body:** ```json { "Content": "Hi, I had a question about your Series A terms." } ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | Content | string | ✅ | Max 4000 chars | **Response:** ```json { "OK": true, "MessageID": 201, "DMChannelID": 15, "CreatedAt": "2026-03-27T21:00:00Z" } ``` **Rate limit error (HTTP 429):** ```json { "OK": false, "ERROR": "rate_limit_exceeded", "Limit": 10, "WindowSeconds": 300, "ResetsAt": "2026-03-27T21:05:00Z" } ``` The DM channel is auto-created on first message. The host sees DMs in their normal Hub message list (channel type: `direct`). --- #### `GET /api/hub/vcgateway/dm/messages.php` List messages in the visitor's DM channel. **Headers:** `X-Visitor-Token: x9y8z7...` **Query params:** | Param | Type | Required | Notes | |-------|------|----------|-------| | Before | int | ❌ | Cursor: messages before this ID | | After | int | ❌ | Cursor: messages after this ID | | Limit | int | ❌ | Default 50, max 100 | **Response:** ```json { "OK": true, "Messages": [ { "ID": 201, "SenderAddress": "visitor:1:Alex Chen", "SenderName": "Alex Chen", "IsVisitor": true, "Content": "Hi, I had a question about your Series A terms.", "IsEdited": false, "CreatedAt": "2026-03-27T21:00:00Z" }, { "ID": 205, "SenderAddress": "sprinter.payfrit.john", "SenderName": "john", "IsVisitor": false, "Content": "Sure, happy to discuss. What are you thinking?", "IsEdited": false, "CreatedAt": "2026-03-27T21:02:00Z" } ], "DMChannelID": 15, "HasMore": false } ``` Returns empty `Messages` array if no DM channel exists yet. --- ## Architecture Notes - **Tables:** `Hub_InviteLinks`, `Hub_Visitors`, `Hub_VisitorRateLimit` - **DM channels** use existing `Hub_Channels` (type: `direct`) and `Hub_Messages` - **Rate limiting:** 10 messages per 5-minute sliding window, stored in `Hub_VisitorRateLimit` - **Visitor sender format:** `visitor:{id}:{displayName}` in `SenderAddress` - **Host replies** use normal Hub message send (existing endpoint), targeting the DM channel - **Revoking a link** invalidates all visitor sessions derived from it - **Schema:** `/api/hub/vcgateway/schema.sql`