API Reference

All HTTP endpoints in the SONAN DIGITAL CRM follow a consistent set of conventions. This page documents every route, its authentication requirements, and selected request/response examples.


Global Conventions

ℹ️
Edge Runtime

Every route file exports export const runtime = 'edge'. This is enforced in CI via a lint rule. Do not remove it.

ℹ️
Admin Route Authentication

Every /api/admin/* route begins with the following guard. Any route missing this guard is a security defect.

```typescript
import { requireAdminWithTenant } from '@/lib/admin-auth'

export async function GET(req: Request) {
  const caller = await requireAdminWithTenant()
  if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  // caller.userId  — UUID of the authenticated admin user
  // caller.tenantId — UUID of the active tenant
}
```

Base URL (production): https://app.sonandigital.com

Content-Type: All request bodies are application/json. All responses are application/json unless noted.

Error shape:

{
  "error": "Human-readable message",
  "code": "MACHINE_CODE"
}

Pagination: Endpoints that return lists accept ?page=1&limit=25 query parameters. Responses include a meta object:

{
  "data": [...],
  "meta": { "page": 1, "limit": 25, "total": 142 }
}

Admin Routes

All admin routes require an authenticated session with role admin or super_admin. The session is established via the /auth/login flow (see Authentication).

Leads

Method Path Auth Description
GET /api/admin/leads Admin List all leads for the active tenant. Supports ?status=, ?source=, ?search= filters.
POST /api/admin/leads Admin Create a new lead. Triggers new_lead notification broadcast.
GET /api/admin/leads/[id] Admin Fetch a single lead by UUID.
PUT /api/admin/leads/[id] Admin Update lead fields (name, email, phone, status, notes, source).
DELETE /api/admin/leads/[id] Admin Soft-delete a lead (sets deleted_at).

POST /api/admin/leads — Request:

{
  "name": "Acme Corp",
  "email": "contact@acme.com",
  "phone": "+1-555-0100",
  "source": "website",
  "notes": "Interested in the Enterprise plan"
}

POST /api/admin/leads — Response (201):

{
  "data": {
    "id": "a1b2c3d4-...",
    "tenant_id": "t1...",
    "name": "Acme Corp",
    "email": "contact@acme.com",
    "phone": "+1-555-0100",
    "source": "website",
    "status": "new",
    "notes": "Interested in the Enterprise plan",
    "created_at": "2025-03-15T10:23:00Z"
  }
}

Clients

Method Path Auth Description
GET /api/admin/clients Admin List all clients. Supports ?search=, ?assigned_manager_id=.
POST /api/admin/clients Admin Create a client record (does not send portal invitation).
GET /api/admin/clients/[id] Admin Fetch client with contacts, active projects, and open invoices.
PUT /api/admin/clients/[id] Admin Update client fields including assigned_manager_id.
DELETE /api/admin/clients/[id] Admin Archive client (sets archived_at).
GET /api/admin/clients/[id]/contacts Admin List all contacts for a client.
POST /api/admin/clients/[id]/contacts Admin Add a contact to a client. First contact is automatically set as is_primary.

Proposals

Method Path Auth Description
GET /api/admin/proposals Admin List proposals. Supports ?client_id=, ?status=.
POST /api/admin/proposals Admin Create proposal with line items.
GET /api/admin/proposals/[id] Admin Fetch proposal with line items and client.
PUT /api/admin/proposals/[id] Admin Update proposal or send to client (sets sent_at, emails client via Resend).
DELETE /api/admin/proposals/[id] Admin Delete draft proposal (cannot delete sent proposals).

POST /api/admin/proposals — Request:

{
  "client_id": "c1b2...",
  "title": "Website Redesign Q2 2025",
  "valid_until": "2025-04-30",
  "line_items": [
    { "description": "UX Design", "quantity": 1, "unit_price_cents": 250000 },
    { "description": "Development (80h)", "quantity": 80, "unit_price_cents": 15000 }
  ]
}

POST /api/admin/proposals — Response (201):

{
  "data": {
    "id": "p1...",
    "tenant_id": "t1...",
    "client_id": "c1b2...",
    "title": "Website Redesign Q2 2025",
    "status": "draft",
    "subtotal_cents": 1450000,
    "valid_until": "2025-04-30",
    "sent_at": null,
    "approved_at": null,
    "declined_at": null,
    "created_at": "2025-03-15T11:00:00Z",
    "line_items": [...]
  }
}

Contracts

Method Path Auth Description
GET /api/admin/contracts Admin List contracts. Supports ?client_id=, ?status=.
POST /api/admin/contracts Admin Create contract with HTML content body.
GET /api/admin/contracts/[id] Admin Fetch contract with full content and signature.
PUT /api/admin/contracts/[id] Admin Update contract or send to client for signature.
DELETE /api/admin/contracts/[id] Admin Delete draft contract.

Invoices

Method Path Auth Description
GET /api/admin/invoices Admin List invoices. Supports ?client_id=, ?status=, ?overdue=true.
POST /api/admin/invoices Admin Create invoice with line items. Supports recurring configuration.
GET /api/admin/invoices/[id] Admin Fetch invoice with line items, client, and Stripe payment intent status.
PUT /api/admin/invoices/[id] Admin Update draft invoice.
DELETE /api/admin/invoices/[id] Admin Delete draft invoice.
POST /api/admin/invoices/[id]/send Admin Email invoice to client via Resend. Sets sent_at.
POST /api/admin/invoices/[id]/mark-paid Admin Manually mark invoice as paid (for offline payments). Sets paid_at, fires invoice_paid notification.

Projects

Method Path Auth Description
GET /api/admin/projects Admin List projects. Supports ?client_id=, ?status=.
POST /api/admin/projects Admin Create project linked to a client.
GET /api/admin/projects/[id] Admin Fetch project with tasks, time logs summary, and deployments.
PUT /api/admin/projects/[id] Admin Update project fields (name, status, dates).
DELETE /api/admin/projects/[id] Admin Archive project.

Time Logs

Method Path Auth Description
GET /api/admin/time-logs Admin List time logs. Supports ?task_id=, ?employee_id=, ?from=, ?to=.
POST /api/admin/time-logs Admin/Employee Log time against a task. Employee can only log against tasks assigned to them.

POST /api/admin/time-logs — Request:

{
  "task_id": "t1...",
  "hours": 3.5,
  "note": "Implemented authentication middleware",
  "logged_date": "2025-03-15"
}

POST /api/admin/time-logs — Response (201):

{
  "data": {
    "id": "tl1...",
    "task_id": "t1...",
    "employee_id": "u1...",
    "hours": 3.5,
    "note": "Implemented authentication middleware",
    "logged_date": "2025-03-15",
    "created_at": "2025-03-15T17:30:00Z"
  }
}

Documents

Method Path Auth Description
GET /api/admin/documents Admin List documents. Supports ?client_id=.
POST /api/admin/documents Admin Upload document metadata after file upload to Supabase Storage.
DELETE /api/admin/documents/[id] Admin Delete document record and remove file from Storage.
ℹ️
Upload flow

Documents use a two-step upload: (1) upload the file directly to Supabase Storage from the client using a signed upload URL, (2) call POST /api/admin/documents to save the metadata record. This avoids routing large file payloads through the edge function.

Reports

Method Path Auth Description
GET /api/admin/reports/revenue Admin Revenue report. Params: ?from=, ?to=, ?group_by=month\|client. Returns subtotal_cents totals grouped by period or client.
GET /api/admin/reports/time-logs Admin Time log report. Params: ?from=, ?to=, ?employee_id=, ?project_id=. Returns hours breakdown.

Team

Method Path Auth Description
POST /api/admin/team/invite Admin Invite a team member. Sends Supabase Auth invite email. Body: { email, full_name, role }. Role must be admin or employee.

Notifications

Method Path Auth Description
GET /api/admin/notifications Admin Fetch notifications for the authenticated user. Returns unread count in meta.
POST /api/admin/notifications/[id]/read Admin Mark a notification as read (sets read_at).

Portal Routes

Portal routes serve logged-in client users via the /portal/* area. Authentication is enforced via getPortalClientId() which returns the client_id associated with the session.

⚠️
Tenant isolation in portal routes

Portal routes must additionally scope every query by client_id — the Supabase RLS policies enforce this at the DB layer, but the application layer must also filter explicitly so that error messages do not leak client UUIDs.

Method Path Auth Description
GET /api/portal/dashboard Client Summary stats: open proposals, unsigned contracts, unpaid invoices, open tickets.
GET /api/portal/proposals Client List proposals addressed to this client.
POST /api/portal/proposals/[id]/approve Client Approve a proposal. Sets approved_at, fires proposal_approved notification.
POST /api/portal/proposals/[id]/decline Client Decline a proposal. Sets declined_at, fires proposal_declined notification.
GET /api/portal/contracts Client List contracts addressed to this client.
POST /api/portal/contracts/[id]/sign Client Sign a contract. Body: { signature: "base64string" }. Sets signed_at and stores signature. Fires contract_signed notification.
GET /api/portal/invoices Client List invoices for this client.
POST /api/portal/invoices/[id]/checkout Client Create a Stripe Checkout session for an invoice. Returns { url: "https://checkout.stripe.com/..." }.
GET /api/portal/support-tickets Client List support tickets for this client.
POST /api/portal/support-tickets Client Open a new support ticket. Body: { subject, body }.
POST /api/portal/support-tickets/[id]/reply Client Add a reply to an existing ticket. Body: { body }. Fires support_reply notification.
GET /api/portal/documents Client List documents shared with this client.

Stripe Webhook

Method Path Auth Description
POST /api/webhooks/stripe Stripe signature Receives Stripe events. Verifies signature using STRIPE_WEBHOOK_SECRET.

Handled events:

Stripe Event CRM Action
payment_intent.succeeded Marks invoice paid, sets paid_at, fires invoice_paid notification
payment_intent.payment_failed Updates invoice status to payment_failed
checkout.session.completed Links Stripe session to invoice, triggers paid flow
🚨
Never skip signature verification

The webhook handler must always call stripe.webhooks.constructEvent(rawBody, sig, STRIPE_WEBHOOK_SECRET) before processing any event. Skipping this allows replay attacks. The raw request body must be read as ArrayBuffer before JSON parsing — once parsed, the signature check fails.


Cron Routes

Cron endpoints are invoked by Vercel Cron on a schedule. They are protected by a shared secret, not by user sessions.

Authentication: All cron routes require the header:

Authorization: Bearer {{ CRON_SECRET }}
Method Path Schedule Description
POST /api/admin/invoices/cron Daily 08:00 UTC Scan for overdue invoices (due_date < today and status = sent). Updates status to overdue, sends reminder email via Resend.
POST /api/admin/appointments/cron Hourly Check for upcoming appointments within 24 hours and send reminder notifications.

Cron handler pattern:

export const runtime = 'edge'

export async function POST(req: Request) {
  const auth = req.headers.get('Authorization')
  if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // ... cron logic ...

  return NextResponse.json({ ok: true, processed: count })
}

Vercel cron configuration (vercel.json):

{
  "crons": [
    {
      "path": "/api/admin/invoices/cron",
      "schedule": "0 8 * * *"
    },
    {
      "path": "/api/admin/appointments/cron",
      "schedule": "0 * * * *"
    }
  ]
}