API Routes
Guide to API routes and endpoints in the BISO Sites web app for external integrations and webhooks.
API Routes
API routes in the web app handle external integrations, webhooks, and server-side operations. They are defined in the app/api/ directory using Next.js Route Handlers.
Authentication API
Create Anonymous Session
Endpoint: POST /api/auth/anonymous
File: app/api/auth/anonymous/route.ts
Creates an anonymous session for unauthenticated users:
// app/api/auth/anonymous/route.ts
import { createAdminClient } from '@repo/api/server';
import { cookies } from 'next/headers';
export async function POST() {
const { account } = await createAdminClient();
try {
const session = await account.createAnonymousSession();
const cookieStore = await cookies();
cookieStore.set('session', session.secret, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
});
return Response.json({ success: true });
} catch (error) {
return Response.json({ error: 'Failed to create session' }, {
status: 500
});
}
}Check Authentication
Endpoint: GET /api/auth/check
File: app/api/auth/check/route.ts
Checks if user is authenticated:
// app/api/auth/check/route.ts
import { createSessionClient } from '@repo/api/server';
export async function GET() {
try {
const { account } = await createSessionClient();
const user = await account.get();
return Response.json({
authenticated: true,
user: {
id: user.$id,
email: user.email,
name: user.name,
}
});
} catch {
return Response.json({ authenticated: false });
}
}Payment Webhooks
Vipps Checkout Webhook
Endpoint: POST /api/payment/vipps/callback
File: app/api/payment/vipps/callback/route.ts
Handles Vipps payment status updates. Called by Vipps when payment status changes.
// app/api/payment/vipps/callback/route.ts
import { NextResponse } from 'next/server';
import { createAdminClient } from '@repo/api/server';
import { handleVippsCallback } from '@repo/payment/vipps';
import { headers } from 'next/headers';
/**
* Vipps Checkout Webhook Callback Endpoint
*
* States that trigger callbacks:
* - CREATED: Payment initiated
* - AUTHORIZED: User accepted payment
* - ABORTED: User cancelled
* - EXPIRED: Payment timed out
* - TERMINATED: Merchant cancelled
*/
export async function POST(request: Request) {
try {
const headersList = await headers();
const authToken = headersList.get('authorization')?.replace('Bearer ', '') || '';
// Parse the webhook payload
const payload = await request.json();
// Extract session ID from payload (Vipps sends different structures)
const sessionId = payload?.sessionId ||
payload?.checkoutSessionId ||
payload?.session?.sessionId ||
payload?.reference;
if (!sessionId) {
return NextResponse.json(
{ success: false, message: 'Missing session ID' },
{ status: 400 }
);
}
// Use admin client (webhooks don't have user sessions)
const { db } = await createAdminClient();
// Handle the callback using payment package
const result = await handleVippsCallback(authToken, sessionId, db);
if (!result.success) {
return NextResponse.json(result, {
status: result.message === 'Unauthorized' ? 401 : 400
});
}
return NextResponse.json(result, { status: 200 });
} catch (error) {
console.error('[Vipps Webhook] Error:', error);
return NextResponse.json(
{ success: false, message: 'Internal server error' },
{ status: 500 }
);
}
}Webhooks use admin client since they don't have user sessions. Always verify webhook signatures from Vipps.
Legacy Webhook (Deprecated)
Endpoint: POST /api/checkout/webhook
Status: 410 Gone (deprecated)
This endpoint is kept for backward compatibility but returns a 410 Gone status. Use /api/payment/vipps/callback instead.
Checkout Return
Endpoint: GET /api/checkout/return
File: app/api/checkout/return/route.ts
Handles user return from Vipps payment flow. Verifies order status and redirects accordingly:
// app/api/checkout/return/route.ts
import { NextResponse } from 'next/server';
import { createSessionClient } from '@repo/api/server';
import { verifyOrderStatus } from '@repo/payment/vipps';
/**
* Checkout Return Endpoint
*
* Users are redirected here after completing payment with Vipps.
* Handles race conditions where webhook might not have been processed yet.
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const orderId = searchParams.get('orderId');
if (!orderId) {
return NextResponse.redirect(new URL('/shop', process.env.NEXT_PUBLIC_BASE_URL));
}
// Get database client from user session
const { db } = await createSessionClient();
// Verify and update order status with Vipps
const order = await verifyOrderStatus(orderId, db);
if (!order) {
return NextResponse.redirect(
new URL('/shop?error=order_not_found', process.env.NEXT_PUBLIC_BASE_URL)
);
}
// Redirect based on order status
switch (order.status) {
case 'paid':
case 'authorized':
return NextResponse.redirect(
new URL(`/shop/order/${orderId}?success=true`, process.env.NEXT_PUBLIC_BASE_URL)
);
case 'cancelled':
return NextResponse.redirect(
new URL('/shop/cart?cancelled=true', process.env.NEXT_PUBLIC_BASE_URL)
);
case 'failed':
return NextResponse.redirect(
new URL('/shop/cart?error=payment_failed', process.env.NEXT_PUBLIC_BASE_URL)
);
default:
return NextResponse.redirect(
new URL(`/shop/order/${orderId}`, process.env.NEXT_PUBLIC_BASE_URL)
);
}
} catch (error) {
console.error('[Checkout Return] Error:', error);
return NextResponse.redirect(
new URL('/shop?error=unknown', process.env.NEXT_PUBLIC_BASE_URL)
);
}
}Cron Jobs
Cleanup Cart Reservations
Endpoint: GET /api/cron/cleanup-reservations
File: app/api/cron/cleanup-reservations/route.ts
Removes expired cart reservations. Should be called via a cron service:
// app/api/cron/cleanup-reservations/route.ts
import { createAdminClient } from '@repo/api/server';
import { Query } from '@repo/api';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
try {
// Verify cron secret for security
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
if (secret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { db } = await createAdminClient();
const now = new Date().toISOString();
// Find and delete expired reservations
const reservations = await db.listRows(
'app',
'cart_reservations',
[Query.lessThan('expires_at', now)]
);
let deletedCount = 0;
for (const reservation of reservations.rows) {
await db.deleteRow('app', 'cart_reservations', reservation.$id);
deletedCount++;
}
return NextResponse.json({
success: true,
deleted: deletedCount
});
} catch (error) {
console.error('[Cron] Cleanup error:', error);
return NextResponse.json(
{ success: false, error: 'Internal error' },
{ status: 500 }
);
}
}Configure your hosting platform (Vercel, etc.) to call this endpoint every 10-15 minutes with the CRON_SECRET parameter.
Configure this endpoint in your hosting provider's cron job settings to run every 15 minutes.
Utility Endpoints
Health Check
Endpoint: GET /api/health
File: app/api/health/route.ts
Simple health check endpoint:
// app/api/health/route.ts
export async function GET() {
return Response.json({
status: 'ok',
timestamp: new Date().toISOString(),
});
}Campus Leadership
Endpoint: GET /api/campus-leadership
File: app/api/campus-leadership/route.ts
Returns campus leadership data (cached).
AI Assistant
Process Document
Endpoint: POST /api/process-document
File: app/api/process-document/route.ts
Processes document uploads for AI assistant.
Public Assistant
Endpoint: POST /api/public-assistant
File: app/api/public-assistant/route.ts
Handles public AI assistant queries.
Expense Generation
Generate Description
Endpoint: POST /api/expense/generate-description
File: app/api/expense/generate-description/route.ts
Uses AI to generate expense descriptions from receipts.
Route Handler Patterns
Basic GET Handler
export async function GET(request: Request) {
try {
const data = await fetchData();
return Response.json(data);
} catch (error) {
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}POST Handler with Validation
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
export async function POST(request: Request) {
try {
const body = await request.json();
const validated = schema.parse(body);
// Process validated data
await processData(validated);
return Response.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json(
{ error: 'Validation failed', details: error.errors },
{ status: 400 }
);
}
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}Dynamic Route Parameters
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const user = await getUserById(id);
return Response.json(user);
}Security Considerations
Always validate and sanitize input data. Never trust client-side input.
Webhook Signature Verification
function verifyVippsSignature(signature: string | null): boolean {
if (!signature) return false;
const [type, token] = signature.split(' ');
if (type !== 'Bearer') return false;
// Verify token against Vipps subscription key
return token === process.env.VIPPS_SUBSCRIPTION_KEY;
}Rate Limiting
Implement rate limiting for public endpoints:
import { rateLimit } from '@/lib/rate-limit';
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for');
const { success } = await rateLimit(ip);
if (!success) {
return Response.json(
{ error: 'Too many requests' },
{ status: 429 }
);
}
// Process request
}CORS Configuration
export async function OPTIONS(request: Request) {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}Error Handling
Consistent Error Responses
interface ErrorResponse {
error: string;
details?: unknown;
code?: string;
}
function errorResponse(
message: string,
status: number,
details?: unknown
): Response {
return Response.json(
{
error: message,
details,
} satisfies ErrorResponse,
{ status }
);
}
// Usage
export async function POST(request: Request) {
try {
// ...
} catch (error) {
return errorResponse('Failed to process request', 500, error);
}
}Testing API Routes
// __tests__/api/health.test.ts
import { GET } from '@/app/api/health/route';
describe('/api/health', () => {
it('returns 200 OK', async () => {
const response = await GET(new Request('http://localhost/api/health'));
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveProperty('status', 'ok');
});
});Related Documentation
- Server Actions - Data mutations
- @repo/payment Package - Payment integration
- @repo/api Package - Appwrite client
- Deployment - Production setup
Best Practices
- Validate all input - Use Zod or similar validation library
- Handle errors gracefully - Return consistent error responses
- Secure webhooks - Always verify signatures
- Rate limit public endpoints - Prevent abuse
- Use TypeScript - Type all request/response data
- Log important events - Track webhook calls and errors
- Test thoroughly - Write integration tests
