7.4 KiB
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:
- Authenticate via a shareable invite link (no account needed)
- Read messages from scoped channels (read-only)
- Send DMs to the host (rate-limited)
Two auth models:
- Agent auth (
X-Agent-Addressheader) — for team members managing invite links - Visitor auth (
X-Visitor-Tokenheader) — 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:
{
"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:
{
"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:
{ "ID": 1 }
Response:
{ "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:
{
"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:
{
"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:
{
"InviteToken": "a1b2c3d4e5f6...",
"DisplayName": "Alex Chen"
}
| Field | Type | Required | Notes |
|---|---|---|---|
| InviteToken | string | ✅ | Token from invite URL |
| DisplayName | string | ❌ | Default: "Visitor" (max 100 chars) |
Response:
{
"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:
{
"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:
{ "Content": "Hi, I had a question about your Series A terms." }
| Field | Type | Required | Notes |
|---|---|---|---|
| Content | string | ✅ | Max 4000 chars |
Response:
{
"OK": true,
"MessageID": 201,
"DMChannelID": 15,
"CreatedAt": "2026-03-27T21:00:00Z"
}
Rate limit error (HTTP 429):
{
"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:
{
"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) andHub_Messages - Rate limiting: 10 messages per 5-minute sliding window, stored in
Hub_VisitorRateLimit - Visitor sender format:
visitor:{id}:{displayName}inSenderAddress - 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