Overview
All TPO (Time/Place/Occasion) endpoints are located under /api/tpo/. The API uses:
REST architecture with JSON payloads
Internal API key authentication for admin endpoints (via x-internal-api-key header)
Surge webhook signature validation for SMS webhooks
Public Endpoints
POST /api/tpo/signup
Initiates user onboarding via SMS.
Authentication : None (public)
E.164 format phone number (e.g., +12125551234)
Error message if signup failed
Request Example
curl -X POST https://josh.example.com/api/tpo/signup \
-H "Content-Type: application/json" \
-d '{"phoneNumber": "+12125551234"}'
Response Examples
Success (201)
Already Exists (409)
Banned (403)
Invalid Phone (400)
Source Code
Location : src/app/api/tpo/signup/route.ts:9
export async function POST ( req : NextRequest ) {
const body = await req . json ();
const { phoneNumber } = body ;
if ( ! / ^ \+ 1 \d {10} $ / . test ( phoneNumber )) {
return NextResponse . json (
{ message: "Invalid phone number. Expected E.164 format (+1XXXXXXXXXX)." },
{ status: 400 }
);
}
await db . tpoUser . create ({
data: {
phoneNumber ,
status: "ONBOARDING" ,
onboardingStep: "AWAITING_ABOUT" ,
onboardingQuestionIndex: 0 ,
},
});
await sendSms ( phoneNumber , TPO_INTRO_TEXT );
// ... continues with first question
}
POST /api/tpo/webhook
Receives SMS messages from Surge API. Handles onboarding, scheduling, and message relay.
Authentication : Surge signature validation
Headers :
surge-signature - HMAC signature for request validation
Body : Surge webhook payload (see Surge API docs)
Show Surge Webhook Payload Structure
interface SurgeWebhookPayload {
type : "message.received" ;
data : {
conversation : {
contact : {
phone_number : string ; // E.164 format
};
};
body ?: string ;
attachments ?: {
url ?: string ;
media_url ?: string ;
file_url ?: string ;
download_url ?: string ;
type ?: string ;
}[];
};
}
Response
Source Code
Location : src/app/api/tpo/webhook/route.ts:958
export async function POST ( req : NextRequest ) {
const rawBody = await req . text ();
const signatureHeader = req . headers . get ( "surge-signature" );
if ( ! validateSurgeSignature ( signatureHeader , rawBody )) {
return NextResponse . json ({ message: "Invalid signature" }, { status: 401 });
}
const payload = JSON . parse ( rawBody );
const senderPhone = payload . data . conversation ?. contact ?. phone_number ;
const user = await db . tpoUser . findUnique ({ where: { phoneNumber: senderPhone } });
if ( user . status === "ONBOARDING" ) {
await handleOnboarding ( user , messageBody , attachments );
} else if ( user . status === "APPROVED" ) {
const schedulingDate = await db . tpoDate . findFirst ({
where: {
status: "ACTIVE" ,
portalEnabled: false ,
OR: [{ userA: { phoneNumber: senderPhone } }, { userB: { phoneNumber: senderPhone } }],
},
});
if ( schedulingDate ) {
await handleScheduling ( senderPhone , messageBody , schedulingDate );
} else {
await handleMessageRelay ( senderPhone , messageBody );
}
}
}
Admin Endpoints
All admin endpoints require the x-internal-api-key header.
GET /api/tpo/admin/users
Fetches users with optional status filter.
Authentication : Internal API key
Filter by status: ONBOARDING, PENDING_REVIEW, APPROVED, REJECTED, BANNED
Request Example
curl https://josh.example.com/api/tpo/admin/users?status=PENDING_REVIEW \
-H "x-internal-api-key: your-secret-key"
Response Example
{
"users" : [
{
"id" : "clx123abc" ,
"phoneNumber" : "+12125551234" ,
"status" : "PENDING_REVIEW" ,
"onboardingStep" : "COMPLETE" ,
"aboutMe" : "Q: tell us about yourself. \n A: I'm a software engineer..." ,
"city" : "New York" ,
"photoUrls" : [ "photos/2125551234/1234567890.jpg" ],
"dlName" : "John Doe" ,
"dlAge" : 28 ,
"createdAt" : "2026-03-01T12:00:00Z"
}
]
}
Source : src/app/api/tpo/admin/users/route.ts:8
POST /api/tpo/admin/review
Approves or rejects a pending user.
Authentication : Internal API key
New user status: APPROVED or REJECTED
Request Example
curl -X POST https://josh.example.com/api/tpo/admin/review \
-H "Content-Type: application/json" \
-H "x-internal-api-key: your-secret-key" \
-d '{
"userId": "clx123abc",
"action": "approve"
}'
Response Example
{
"success" : true ,
"status" : "APPROVED"
}
Source : src/app/api/tpo/admin/review/route.ts:10
Show What Happens on Approval
User status updated to APPROVED
SMS sent to user with acceptance message
User can now be paired for dates
POST /api/tpo/admin/pair
Creates a matched pair and initiates scheduling.
Authentication : Internal API key
First user ID (must be APPROVED)
Second user ID (must be APPROVED)
Whether pairing succeeded
Request Example
curl -X POST https://josh.example.com/api/tpo/admin/pair \
-H "Content-Type: application/json" \
-H "x-internal-api-key: your-secret-key" \
-d '{
"userAId": "clx123abc",
"userBId": "clx456def"
}'
Response Example
{
"success" : true ,
"dateId" : "clx789ghi"
}
Source : src/app/api/tpo/admin/pair/route.ts:10
Validates both users are APPROVED
Checks neither has an active date
Creates TpoDate with schedulingPhase: WAITING_FOR_A_REPLY
Calls Mistral to propose initial time slot
Sends “you’ve been matched!” to User A
Sends time proposal to User A
User B notified only after A confirms availability
POST /api/tpo/admin/user-action
Performs administrative actions on a user.
Authentication : Internal API key
One of:
delete - Permanently delete user and their dates
ban - Set status to BANNED and end active dates
restart_onboarding - Reset user to beginning of onboarding
ping_onboarding - Send reminder SMS about current step
cancel_onboarding - Delete user record (for incomplete onboarding)
reparse_profile - Re-run AI profile structuring
edit_badge - Update a specific profile field
delete_badge - Remove a specific profile field
For badge actions: about or preferences
For badge actions: field name (e.g., hobbies, city, age)
For badge actions: current value to replace
For edit_badge: new value (omit for delete_badge)
Request Examples
Ban User
Restart Onboarding
Edit Profile Badge
Ping Onboarding
{
"userId" : "clx123abc" ,
"action" : "ban"
}
Response Examples
{
"success" : true ,
"status" : "BANNED"
}
Source : src/app/api/tpo/admin/user-action/route.ts:245
GET /api/tpo/admin/dates
Fetches all dates with optional status filter.
Authentication : Internal API key
Filter by status: ACTIVE or ENDED
Array of date objects with user details and message count
Request Example
curl https://josh.example.com/api/tpo/admin/dates?status=ACTIVE \
-H "x-internal-api-key: your-secret-key"
Response Example
{
"dates" : [
{
"id" : "clx789ghi" ,
"createdAt" : "2026-03-02T10:00:00Z" ,
"status" : "ACTIVE" ,
"schedulingPhase" : "AGREED" ,
"proposedSlot" : "Friday, March 7th at 7pm" ,
"agreedTime" : "Friday, March 7th at 7pm" ,
"suggestedPlace" : "Cafe Maud in East Village, New York — cozy but lively, great for first dates" ,
"portalEnabled" : true ,
"userA" : {
"id" : "clx123abc" ,
"phoneNumber" : "+12125551234" ,
"city" : "New York" ,
"dlName" : "John Doe" ,
"dlAge" : 28
},
"userB" : {
"id" : "clx456def" ,
"phoneNumber" : "+12125555678" ,
"city" : "Brooklyn" ,
"dlName" : "Jane Smith" ,
"dlAge" : 26
},
"_count" : {
"messages" : 12
}
}
]
}
Source : src/app/api/tpo/admin/dates/route.ts:8
GET /api/tpo/admin/messages
Fetches all messages for a specific date.
Authentication : Internal API key
Date ID to fetch messages for
Array of messages in chronological order
Request Example
curl "https://josh.example.com/api/tpo/admin/messages?dateId=clx789ghi" \
-H "x-internal-api-key: your-secret-key"
Response Example
{
"messages" : [
{
"id" : "clxmsg001" ,
"createdAt" : "2026-03-02T10:05:00Z" ,
"fromPhone" : "system" ,
"toPhone" : "+12125551234" ,
"body" : "let's get the date on the calendar. how does Friday, March 7th at 7pm work for you?" ,
"blocked" : false
},
{
"id" : "clxmsg002" ,
"createdAt" : "2026-03-02T10:07:00Z" ,
"fromPhone" : "+12125551234" ,
"toPhone" : "system" ,
"body" : "works for me!" ,
"blocked" : false
},
{
"id" : "clxmsg003" ,
"createdAt" : "2026-03-02T10:08:00Z" ,
"fromPhone" : "system" ,
"toPhone" : "+12125555678" ,
"body" : "your match is free on Friday, March 7th at 7pm. does that work for you?" ,
"blocked" : false
}
]
}
Source : src/app/api/tpo/admin/messages/route.ts:8
POST /api/tpo/admin/end-date
Manually ends an active date.
Authentication : Internal API key
Request Example
curl -X POST https://josh.example.com/api/tpo/admin/end-date \
-H "Content-Type: application/json" \
-H "x-internal-api-key: your-secret-key" \
-d '{"dateId": "clx789ghi"}'
Response Example
Source : src/app/api/tpo/admin/end-date/route.ts:10
Show What Happens When Date Ends
Date status set to ENDED
endedAt timestamp recorded
Both users sent notification SMS
Users can no longer message each other
Users become available for new matches
POST /api/tpo/admin/signed-url
Generates temporary signed URLs for Supabase storage objects.
Authentication : Internal API key
Array of storage paths (e.g., ["photos/2125551234/1234567890.jpg"])
thumbnail (400x400) or full (1200px width). Defaults to thumbnail.
Map of original paths to signed URLs
Request Example
curl -X POST https://josh.example.com/api/tpo/admin/signed-url \
-H "Content-Type: application/json" \
-H "x-internal-api-key: your-secret-key" \
-d '{
"paths": [
"photos/2125551234/1234567890.jpg",
"ids/2125551234/9876543210.jpg"
],
"size": "full"
}'
Response Example
{
"urls" : {
"photos/2125551234/1234567890.jpg" : "https://supabase.co/storage/v1/object/sign/tpo-uploads/photos/2125551234/1234567890.jpg?token=xyz&transform=..." ,
"ids/2125551234/9876543210.jpg" : "https://supabase.co/storage/v1/object/sign/tpo-uploads/ids/2125551234/9876543210.jpg?token=abc&transform=..."
}
}
Source : src/app/api/tpo/admin/signed-url/route.ts:53
Signed URLs expire after 1 hour. For direct URLs (legacy fallback refs), the original URL is returned.
Error Responses
All endpoints return consistent error structures:
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
500 Internal Server Error
{
"message" : "userId and action (approve|reject) are required"
}
Authentication
Internal API Key
Admin endpoints require the x-internal-api-key header:
const key = req . headers . get ( "x-internal-api-key" );
if ( ! hasValidInternalApiKey ( key )) {
return NextResponse . json ({ message: "Unauthorized" }, { status: 401 });
}
Set via environment variable :
INTERNAL_API_KEY = your-secret-key
Surge Webhook Validation
The /api/tpo/webhook endpoint validates Surge signatures:
const signatureHeader = req . headers . get ( "surge-signature" );
if ( ! validateSurgeSignature ( signatureHeader , rawBody )) {
return NextResponse . json ({ message: "Invalid signature" }, { status: 401 });
}
Set via environment variable :
SURGE_WEBHOOK_SECRET = your-webhook-secret
Skip validation in development:
SURGE_SKIP_WEBHOOK_VALIDATION = true
Rate Limiting
Currently no rate limiting is implemented. Consider adding rate limiting for production deployments.
Architecture Overview System design and AI integration points
Database Schema Prisma models and relationships