API Reference
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
Every route file exports export const runtime = 'edge'. This is enforced in CI via a lint rule. Do not remove it.
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. |
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.
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 |
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 * * * *"
}
]
}