BISO Sites
Web App

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 }
    );
  }
}
⚠️
Webhook Security

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 }
    );
  }
}
ℹ️
Cron Setup

Configure your hosting platform (Vercel, etc.) to call this endpoint every 10-15 minutes with the CRON_SECRET parameter.

ℹ️
Setting Up Cron Jobs

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

⚠️
API Security

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');
  });
});

Best Practices

API Route Best Practices
  1. Validate all input - Use Zod or similar validation library
  2. Handle errors gracefully - Return consistent error responses
  3. Secure webhooks - Always verify signatures
  4. Rate limit public endpoints - Prevent abuse
  5. Use TypeScript - Type all request/response data
  6. Log important events - Track webhook calls and errors
  7. Test thoroughly - Write integration tests