Skip to main content

API Reference

Complete reference for all OneLibro API endpoints.

Table of Contents​

Overview​

OneLibro uses Next.js API Routes (serverless functions) for all backend operations.

Base URL (Development):

http://localhost:3000/api

Base URL (Production):

https://finance.yatheeshnagella.com/api

API Categories​

CategoryBase PathAuth Required
Plaid Integration/api/plaid/*User JWT
Transactions/api/transactions/*User JWT
Admin Auth/api/admin/auth/*None (creates session)
Admin Management/api/admin/*Admin Session Token

Authentication​

User API Authentication​

All user APIs require JWT token from Supabase:

Authorization: Bearer <supabase_jwt_token>

Example:

const { data: { session } } = await supabase.auth.getSession();

const response = await fetch('/api/plaid/create-link-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
body: JSON.stringify({ userId: user.id }),
});

Admin API Authentication​

Admin APIs require session token (from login):

Cookie: admin_session=<session_token>

Or via header:

Authorization: Bearer <session_token>

Plaid APIs​

Generate a Plaid Link token for connecting bank accounts.

Endpoint: POST /api/plaid/create-link-token

Authentication: User JWT required

Request Body:

{
"userId": "uuid-string"
}

Response (200 OK):

{
"link_token": "link-sandbox-12345678-1234-1234-1234-123456789012",
"expiration": "2024-12-04T12:00:00Z"
}

Response (401 Unauthorized):

{
"error": "Unauthorized"
}

Response (500 Error):

{
"error": "Failed to create link token"
}

Example Usage:

const createLinkToken = async (userId: string) => {
const { data: { session } } = await supabase.auth.getSession();

const response = await fetch('/api/plaid/create-link-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
body: JSON.stringify({ userId }),
});

const data = await response.json();
return data.link_token;
};

Exchange Public Token​

Exchange Plaid's public token for an access token after user connects bank.

Endpoint: POST /api/plaid/exchange-token

Authentication: User JWT required

Request Body:

{
"publicToken": "public-sandbox-12345678-1234-1234-1234-123456789012",
"userId": "uuid-string",
"institutionId": "ins_3",
"institutionName": "Chase"
}

Response (200 OK):

{
"success": true,
"itemId": "uuid-of-plaid-item",
"accounts": [
{
"id": "uuid-of-account",
"name": "Chase Checking",
"type": "depository",
"subtype": "checking",
"mask": "0000",
"currentBalance": 250000,
"availableBalance": 245000
}
]
}

Response (400 Bad Request):

{
"error": "Missing required fields"
}

Response (500 Error):

{
"error": "Failed to exchange token"
}

Implementation Details:

  1. Exchanges public token with Plaid API
  2. Encrypts access token (AES-256-CBC)
  3. Stores encrypted token in plaid_items table
  4. Fetches and stores accounts in accounts table
  5. Returns account list to client

Example Usage:

const exchangeToken = async (publicToken: string, metadata: PlaidLinkMetadata) => {
const { data: { session } } = await supabase.auth.getSession();

const response = await fetch('/api/plaid/exchange-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
body: JSON.stringify({
publicToken,
userId: user.id,
institutionId: metadata.institution?.institution_id,
institutionName: metadata.institution?.name,
}),
});

return response.json();
};

Sync Transactions​

Sync transactions from Plaid for a specific item.

Endpoint: POST /api/plaid/sync-transactions

Authentication: User JWT required

Request Body:

{
"itemId": "uuid-of-plaid-item"
}

Response (200 OK):

{
"success": true,
"added": 15,
"modified": 3,
"removed": 1,
"nextCursor": "cursor-string-for-next-sync"
}

Response (404 Not Found):

{
"error": "Plaid item not found"
}

Response (500 Error):

{
"error": "Failed to sync transactions"
}

Implementation Details:

  1. Retrieves encrypted access token from database
  2. Decrypts access token
  3. Calls Plaid transactionsSync API with cursor
  4. Processes added/modified transactions (upsert to DB)
  5. Processes removed transactions (soft delete)
  6. Updates account balances
  7. Saves next cursor for incremental sync

Example Usage:

const syncTransactions = async (itemId: string) => {
const { data: { session } } = await supabase.auth.getSession();

const response = await fetch('/api/plaid/sync-transactions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
body: JSON.stringify({ itemId }),
});

return response.json();
};

Disconnect a Plaid item and remove all associated accounts.

Endpoint: POST /api/plaid/unlink-account

Authentication: User JWT required

Request Body:

{
"itemId": "uuid-of-plaid-item"
}

Response (200 OK):

{
"success": true
}

Response (404 Not Found):

{
"error": "Item not found"
}

Example Usage:

const unlinkAccount = async (itemId: string) => {
const { data: { session } } = await supabase.auth.getSession();

const response = await fetch('/api/plaid/unlink-account', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
body: JSON.stringify({ itemId }),
});

return response.json();
};

Plaid Webhook​

Receive webhooks from Plaid for transaction updates, errors, etc.

Endpoint: POST /api/plaid/webhook

Authentication: Webhook signature verification

Request Body (Example - TRANSACTIONS_UPDATE):

{
"webhook_type": "TRANSACTIONS",
"webhook_code": "SYNC_UPDATES_AVAILABLE",
"item_id": "plaid-item-id-12345",
"initial_update_complete": true,
"historical_update_complete": true
}

Response (200 OK):

{
"success": true
}

Webhook Types Handled:

  • TRANSACTIONS.SYNC_UPDATES_AVAILABLE - New transactions available
  • ITEM.ERROR - Item connection error
  • ITEM.LOGIN_REQUIRED - User needs to re-authenticate

Implementation:

export async function POST(request: Request) {
const body = await request.json();

// Verify webhook signature
const signature = request.headers.get('plaid-verification');
const isValid = verifyWebhookSignature(signature, body);

if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}

// Handle webhook
switch (body.webhook_code) {
case 'SYNC_UPDATES_AVAILABLE':
// Trigger background sync
await triggerTransactionSync(body.item_id);
break;

case 'LOGIN_REQUIRED':
// Update item status
await updateItemStatus(body.item_id, 'login_required');
break;
}

return NextResponse.json({ success: true });
}

Transaction APIs​

Create Transaction​

Create a manual transaction (not from Plaid).

Endpoint: POST /api/transactions/create

Authentication: User JWT required

Request Body:

{
"userId": "uuid-string",
"accountId": "uuid-of-account",
"transactionDate": "2024-12-01",
"amount": 4567,
"merchantName": "Whole Foods",
"category": "Food and Drink",
"description": "Groceries"
}

Field Details:

  • amount: Integer in cents (4567 = $45.67)
  • transactionDate: ISO date string (YYYY-MM-DD)
  • category: String (optional)

Response (200 OK):

{
"success": true,
"transaction": {
"id": "uuid-of-transaction",
"user_id": "uuid-string",
"account_id": "uuid-of-account",
"transaction_date": "2024-12-01",
"amount": 4567,
"merchant_name": "Whole Foods",
"category": "Food and Drink",
"description": "Groceries",
"is_pending": false,
"created_at": "2024-12-04T12:00:00Z"
}
}

Response (400 Bad Request):

{
"error": "Missing required fields"
}

Example Usage:

const createTransaction = async (data: TransactionInput) => {
const { data: { session } } = await supabase.auth.getSession();

const response = await fetch('/api/transactions/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
body: JSON.stringify({
userId: user.id,
accountId: data.accountId,
transactionDate: data.date,
amount: dollarsToCents(data.amount), // Convert $45.67 to 4567
merchantName: data.merchant,
category: data.category,
description: data.description,
}),
});

return response.json();
};

Update Transaction​

Update an existing transaction.

Endpoint: PUT /api/transactions/update

Authentication: User JWT required

Request Body:

{
"transactionId": "uuid-of-transaction",
"userId": "uuid-string",
"amount": 5000,
"merchantName": "Updated Merchant",
"category": "Updated Category",
"description": "Updated description"
}

Response (200 OK):

{
"success": true,
"transaction": {
"id": "uuid-of-transaction",
"amount": 5000,
"merchant_name": "Updated Merchant",
"category": "Updated Category",
"description": "Updated description",
"updated_at": "2024-12-04T12:00:00Z"
}
}

Response (403 Forbidden):

{
"error": "Cannot update Plaid-synced transactions"
}

Note: Only manual transactions (with plaid_transaction_id = null) can be updated.


Delete Transaction​

Delete a manual transaction.

Endpoint: DELETE /api/transactions/delete

Authentication: User JWT required

Request Body:

{
"transactionId": "uuid-of-transaction",
"userId": "uuid-string"
}

Response (200 OK):

{
"success": true
}

Response (403 Forbidden):

{
"error": "Cannot delete Plaid-synced transactions"
}

Response (404 Not Found):

{
"error": "Transaction not found"
}

Admin Auth APIs​

Create First Admin​

Create the initial admin user (only works if no admins exist).

Endpoint: POST /api/admin/auth/create-first-admin

Authentication: None (public, but only works once)

Request Body:

{
"email": "admin@example.com",
"password": "strong-password-here",
"fullName": "Admin User"
}

Response (200 OK):

{
"success": true,
"admin": {
"id": "uuid-string",
"email": "admin@example.com",
"full_name": "Admin User",
"created_at": "2024-12-04T12:00:00Z"
}
}

Response (400 Bad Request):

{
"error": "Admin user already exists"
}

Security Note: This endpoint is disabled after the first admin is created.


Admin Login​

Authenticate admin user (step 1 of 2FA).

Endpoint: POST /api/admin/auth/login

Authentication: None

Request Body:

{
"email": "admin@example.com",
"password": "password123"
}

Response (200 OK) - No 2FA:

{
"success": true,
"requiresTOTP": false,
"session": {
"id": "session-uuid",
"token": "64-char-hex-token",
"expires_at": "2024-12-04T20:00:00Z"
},
"user": {
"id": "admin-uuid",
"email": "admin@example.com",
"full_name": "Admin User",
"totp_enabled": false
}
}

Response (200 OK) - Requires 2FA:

{
"requiresTOTP": true,
"userId": "admin-uuid"
}

Response (401 Unauthorized):

{
"error": "Invalid email or password"
}

Response (423 Locked):

{
"error": "Account is temporarily locked. Please try again later."
}

Verify TOTP​

Verify 2FA code and create session (step 2 of 2FA).

Endpoint: POST /api/admin/auth/verify-totp

Authentication: None (requires userId from login step)

Request Body:

{
"userId": "admin-uuid",
"code": "123456"
}

Response (200 OK):

{
"success": true,
"session": {
"id": "session-uuid",
"token": "64-char-hex-token",
"expires_at": "2024-12-04T20:00:00Z"
},
"user": {
"id": "admin-uuid",
"email": "admin@example.com",
"full_name": "Admin User",
"totp_enabled": true,
"totp_verified": true
}
}

Response (401 Unauthorized):

{
"error": "Invalid code"
}

Setup TOTP​

Generate TOTP secret and QR code for 2FA setup.

Endpoint: POST /api/admin/auth/setup-totp

Authentication: Admin session required

Request Body:

{
"userId": "admin-uuid"
}

Response (200 OK):

{
"secret": "JBSWY3DPEHPK3PXP",
"qrCodeUrl": "otpauth://totp/OneLibro%20Admin:admin@example.com?secret=JBSWY3DPEHPK3PXP&issuer=OneLibro%20Admin"
}

Usage: Display QR code to user to scan with authenticator app.


Verify TOTP Setup​

Verify first-time TOTP setup with code from authenticator app.

Endpoint: POST /api/admin/auth/verify-totp-setup

Authentication: Admin session required

Request Body:

{
"userId": "admin-uuid",
"code": "123456"
}

Response (200 OK):

{
"success": true
}

Response (400 Bad Request):

{
"error": "Invalid code. Please try again."
}

Verify Session​

Check if admin session is still valid.

Endpoint: POST /api/admin/auth/verify-session

Authentication: Admin session token required

Request Body:

{
"token": "session-token-string"
}

Response (200 OK):

{
"valid": true,
"user": {
"id": "admin-uuid",
"email": "admin@example.com",
"full_name": "Admin User"
}
}

Response (401 Unauthorized):

{
"valid": false,
"error": "Session expired"
}

Logout​

Invalidate admin session.

Endpoint: POST /api/admin/auth/logout

Authentication: Admin session token required

Request Body:

{
"token": "session-token-string"
}

Response (200 OK):

{
"success": true
}

Admin Management APIs​

Get Users​

Retrieve list of all users.

Endpoint: GET /api/admin/get-users

Authentication: Admin session required

Query Parameters:

  • limit (optional): Number of results (default: 50)
  • offset (optional): Pagination offset (default: 0)

Response (200 OK):

{
"users": [
{
"id": "user-uuid",
"email": "user@example.com",
"full_name": "John Doe",
"is_admin": false,
"invite_code": "WELCOME2024",
"invited_by": "admin-uuid",
"last_login_at": "2024-12-04T12:00:00Z",
"created_at": "2024-12-01T10:00:00Z"
}
],
"total": 125
}

Update User​

Update user details or admin status.

Endpoint: PUT /api/admin/users/[id]

Authentication: Admin session required

Request Body:

{
"fullName": "Updated Name",
"isAdmin": true
}

Response (200 OK):

{
"success": true,
"user": {
"id": "user-uuid",
"email": "user@example.com",
"full_name": "Updated Name",
"is_admin": true,
"updated_at": "2024-12-04T12:00:00Z"
}
}

Create Invite Code​

Generate a new invite code.

Endpoint: POST /api/admin/invites/create

Authentication: Admin session required

Request Body:

{
"maxUses": 100,
"expiresAt": "2024-12-31T23:59:59Z",
"createdBy": "admin-uuid"
}

Response (200 OK):

{
"success": true,
"invite": {
"id": "invite-uuid",
"code": "ABC123XYZ789",
"max_uses": 100,
"used_count": 0,
"expires_at": "2024-12-31T23:59:59Z",
"is_active": true,
"created_at": "2024-12-04T12:00:00Z"
}
}

Update Invite Code​

Update invite code settings.

Endpoint: PUT /api/admin/invites/[id]

Authentication: Admin session required

Request Body:

{
"maxUses": 200,
"isActive": false
}

Response (200 OK):

{
"success": true,
"invite": {
"id": "invite-uuid",
"code": "ABC123XYZ789",
"max_uses": 200,
"is_active": false,
"updated_at": "2024-12-04T12:00:00Z"
}
}

Get Email Campaigns​

Retrieve list of all email campaigns.

Endpoint: GET /api/admin/campaigns

Authentication: Admin session required

Query Parameters:

  • None

Response (200 OK):

{
"campaigns": [
{
"id": "campaign-uuid",
"name": "Beta Launch Campaign",
"subject": "You're invited to join OneLibro",
"template_key": "invite_code_email",
"target_audience": {
"active_only": true,
"signup_after": "2025-01-01"
},
"status": "sent",
"scheduled_at": null,
"sent_at": "2025-01-20T10:00:00Z",
"total_recipients": 250,
"total_sent": 248,
"total_delivered": 245,
"total_opened": 120,
"total_clicked": 45,
"total_bounced": 3,
"created_by": "admin-uuid",
"created_at": "2025-01-19T15:00:00Z",
"updated_at": "2025-01-20T11:00:00Z",
"created_by_user": {
"id": "admin-uuid",
"email": "admin@example.com",
"full_name": "Admin User"
}
}
]
}

Create Campaign​

Create a new email campaign.

Endpoint: POST /api/admin/campaigns

Authentication: Admin session required

Request Body:

{
"token": "admin-session-token",
"name": "January Newsletter",
"subject": "Your January financial insights are here",
"template_key": "newsletter_template",
"target_audience": {
"active_only": true,
"signup_after": "2024-12-01",
"signup_before": "2025-01-31"
},
"scheduled_at": null
}

Field Details:

  • name: Campaign name (internal use)
  • subject: Email subject line
  • template_key: Foreign key to email_templates table
  • target_audience: JSON object with filter criteria
    • active_only: Boolean (filter by is_active status)
    • signup_after: ISO date (users who signed up after this date)
    • signup_before: ISO date (users who signed up before this date)
  • scheduled_at: ISO timestamp (null = draft, future date = scheduled)

Response (200 OK):

{
"success": true,
"campaign": {
"id": "new-campaign-uuid",
"name": "January Newsletter",
"subject": "Your January financial insights are here",
"template_key": "newsletter_template",
"target_audience": {
"active_only": true,
"signup_after": "2024-12-01",
"signup_before": "2025-01-31"
},
"status": "draft",
"total_recipients": 125,
"created_by": "admin-uuid",
"created_at": "2025-01-24T10:00:00Z"
}
}

Response (400 Bad Request):

{
"error": "Template not found or inactive"
}

Get Campaign Details​

Get detailed information about a specific campaign.

Endpoint: GET /api/admin/campaigns/[id]

Authentication: Admin session required

Response (200 OK):

{
"campaign": {
"id": "campaign-uuid",
"name": "January Newsletter",
"subject": "Your January financial insights are here",
"template_key": "newsletter_template",
"target_audience": {
"active_only": true,
"signup_after": "2024-12-01"
},
"status": "sent",
"sent_at": "2025-01-24T12:00:00Z",
"total_recipients": 125,
"total_sent": 123,
"total_delivered": 120,
"total_bounced": 3,
"created_by": "admin-uuid",
"created_at": "2025-01-24T10:00:00Z",
"created_by_user": {
"id": "admin-uuid",
"email": "admin@example.com",
"full_name": "Admin User"
},
"email_template": {
"template_key": "newsletter_template",
"template_name": "Monthly Newsletter",
"category": "marketing"
}
}
}

Response (404 Not Found):

{
"error": "Campaign not found"
}

Update Campaign​

Update campaign details (only for draft/scheduled campaigns).

Endpoint: PUT /api/admin/campaigns/[id]

Authentication: Admin session required

Request Body:

{
"token": "admin-session-token",
"name": "Updated Campaign Name",
"subject": "Updated subject line",
"target_audience": {
"active_only": true,
"signup_after": "2024-12-15"
},
"scheduled_at": "2025-01-30T09:00:00Z"
}

Response (200 OK):

{
"success": true,
"campaign": {
"id": "campaign-uuid",
"name": "Updated Campaign Name",
"subject": "Updated subject line",
"status": "scheduled",
"scheduled_at": "2025-01-30T09:00:00Z",
"total_recipients": 98,
"updated_at": "2025-01-24T11:00:00Z"
}
}

Response (400 Bad Request):

{
"error": "Cannot update campaign that is already sent or sending"
}

Note: Only campaigns with status draft or scheduled can be updated.


Delete Campaign​

Delete a draft campaign.

Endpoint: DELETE /api/admin/campaigns/[id]

Authentication: Admin session required

Response (200 OK):

{
"success": true
}

Response (400 Bad Request):

{
"error": "Cannot delete campaign that is not in draft status"
}

Note: Only campaigns with status draft can be deleted. Sent campaigns are permanent for audit purposes.


Send Campaign​

Send campaign to all target recipients with batch processing.

Endpoint: POST /api/admin/campaigns/[id]/send

Authentication: Admin session required

Request Body (Production Mode):

{
"token": "admin-session-token",
"test_mode": false
}

Request Body (Test Mode):

{
"token": "admin-session-token",
"test_mode": true,
"test_email": "admin@example.com"
}

Response (200 OK) - Production:

{
"success": true,
"campaign_id": "campaign-uuid",
"total_recipients": 250,
"total_sent": 248,
"total_failed": 2
}

Response (200 OK) - Test:

{
"success": true,
"test_mode": true,
"email_id": "resend-email-id",
"message": "Test email sent to admin@example.com"
}

Response (400 Bad Request):

{
"error": "Campaign is already sent or sending"
}

Implementation Details:

  • Batch size: 50 emails per batch
  • Delay between batches: 1 second
  • Status updates in real-time during sending
  • Failed emails are logged but don't stop the campaign
  • Campaign status changes: draft → sending → sent

Example Usage:

// Send test email
const testResponse = await fetch(`/api/admin/campaigns/${campaignId}/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${sessionToken}`,
},
body: JSON.stringify({
token: sessionToken,
test_mode: true,
test_email: 'admin@example.com',
}),
});

// Send to all recipients
const sendResponse = await fetch(`/api/admin/campaigns/${campaignId}/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${sessionToken}`,
},
body: JSON.stringify({
token: sessionToken,
test_mode: false,
}),
});

Error Handling​

Standard Error Response​

All errors follow this format:

{
"error": "Error message describing what went wrong"
}

HTTP Status Codes​

CodeMeaningWhen Used
200OKRequest successful
400Bad RequestInvalid request body or parameters
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but not authorized
404Not FoundResource doesn't exist
423LockedAccount temporarily locked
500Internal Server ErrorServer error occurred

Error Handling Example​

const response = await fetch('/api/plaid/exchange-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(data),
});

if (!response.ok) {
const error = await response.json();

switch (response.status) {
case 401:
// Redirect to login
router.push('/finance/login');
break;
case 400:
// Show validation error
setError(error.error);
break;
case 500:
// Show generic error
setError('Something went wrong. Please try again.');
break;
}

return;
}

const data = await response.json();
// Handle success

Next Steps: