This document describes every HTTP endpoint exposed by the Plotter backend. All endpoints are hosted under the same domain as the web app (e.g. https://your-app.vercel.app).
https://<your-domain>
- Mobile clients — send an
Authorizationheader with a JWT access token obtained from the login or signup endpoints. - Web clients — session cookies are set automatically by the browser; no header needed.
Authorization: Bearer <access_token>
| Token | Lifetime | How to renew |
|---|---|---|
access_token |
~1 hour | Call POST /api/auth/refresh |
refresh_token |
30 days (Supabase default) | Single-use; returns a new pair |
All error responses share the same structure:
{ "error": "<human-readable message>" }| Status | Meaning |
|---|---|
400 |
Bad request — missing or invalid fields |
401 |
Unauthorized — missing, invalid, or expired token |
403 |
Forbidden — authenticated but not the resource owner |
404 |
Resource not found |
500 |
Internal server error |
Create a new account with email and password.
Request body
{
"email": "user@example.com",
"password": "secret123"
}| Field | Type | Required | Notes |
|---|---|---|---|
email |
string | Yes | Must be a valid email |
password |
string | Yes | Minimum 6 characters |
Response 201
{
"user": {
"id": "uuid",
"email": "user@example.com",
"created_at": "2026-03-08T10:00:00Z"
},
"session": {
"access_token": "eyJ...",
"refresh_token": "abc123...",
"expires_at": 1741600000
},
"message": "Account created and signed in."
}If email confirmation is enabled in your Supabase project,
sessionwill benullandmessagewill ask the user to confirm their email.
Error responses
| Status | Body |
|---|---|
400 |
{ "error": "email and password are required" } |
400 |
{ "error": "password must be at least 6 characters" } |
400 |
{ "error": "User already registered" } (Supabase message) |
Sign in with email and password.
Request body
{
"email": "user@example.com",
"password": "secret123"
}Response 200
{
"user": {
"id": "uuid",
"email": "user@example.com",
"created_at": "2026-03-08T10:00:00Z"
},
"session": {
"access_token": "eyJ...",
"refresh_token": "abc123...",
"expires_at": 1741600000
}
}Error responses
| Status | Body |
|---|---|
400 |
{ "error": "email and password are required" } |
401 |
{ "error": "Invalid email or password" } |
Invalidate the current access token.
Headers (required)
Authorization: Bearer <access_token>
Response 204 — No content.
Error responses
| Status | Body |
|---|---|
401 |
{ "error": "Authorization: Bearer <token> header required" } |
Exchange a refresh token for a new access/refresh token pair.
Request body
{
"refresh_token": "abc123..."
}Response 200
{
"session": {
"access_token": "eyJ...",
"refresh_token": "xyz789...",
"expires_at": 1741603600
}
}Refresh tokens are single-use. Store the new
refresh_tokenafter every refresh.
Error responses
| Status | Body |
|---|---|
400 |
{ "error": "refresh_token is required" } |
401 |
{ "error": "Invalid or expired refresh token" } |
Return the authenticated user's profile.
Headers (required)
Authorization: Bearer <access_token>
Response 200
{
"id": "uuid",
"email": "user@example.com",
"created_at": "2026-03-08T10:00:00Z",
"updated_at": "2026-03-08T10:00:00Z",
"app_metadata": {},
"user_metadata": {}
}Error responses
| Status | Body |
|---|---|
401 |
{ "error": "Unauthorized" } |
{
"id": "uuid",
"title": "Gas leak on Main St",
"description": "Strong smell near the intersection.",
"category": "fire",
"severity": "high",
"status": "active",
"latitude": 37.7749,
"longitude": -122.4194,
"address": "Main St & 1st Ave, San Francisco, CA",
"reported_by": "user-uuid",
"reported_by_email": "user@example.com",
"created_at": "2026-03-08T10:00:00Z",
"updated_at": "2026-03-08T10:05:00Z"
}Enum values
| Field | Allowed values |
|---|---|
category |
accident · fire · medical · crime · weather · other |
severity |
low · medium · high · critical |
status |
active · investigating · resolved |
List all incidents, newest first. Publicly accessible — no auth required.
Query parameters (all optional)
| Parameter | Type | Description |
|---|---|---|
category |
string | Filter by category (exact match) |
severity |
string | Filter by severity (exact match) |
status |
string | Filter by status (exact match) |
search |
string | Case-insensitive substring match on title |
Example
GET /api/incidents?severity=critical&status=active&search=fire
Response 200
[
{ ...incident },
{ ...incident }
]Returns an empty array [] when no results match.
Create a new incident. Authentication required.
Headers
Authorization: Bearer <access_token>
Content-Type: application/json
Request body
{
"title": "Gas leak on Main St",
"description": "Strong smell near the intersection.",
"category": "fire",
"severity": "high",
"latitude": 37.7749,
"longitude": -122.4194,
"address": "Main St & 1st Ave, San Francisco, CA"
}| Field | Type | Required | Notes |
|---|---|---|---|
title |
string | Yes | Max 200 characters |
category |
string | Yes | See enum values above |
severity |
string | Yes | See enum values above |
latitude |
number | Yes | −90 to 90 |
longitude |
number | Yes | −180 to 180 |
description |
string | No | Max 1 000 characters |
address |
string | No | Max 500 characters |
Response 201
{ ...incident }Error responses
| Status | Body |
|---|---|
400 |
{ "error": "Missing required fields: title, category, severity, latitude, longitude" } |
400 |
{ "error": "Invalid category" } |
400 |
{ "error": "Invalid severity" } |
400 |
{ "error": "Invalid coordinates" } |
401 |
{ "error": "Unauthorized" } |
Fetch a single incident by its UUID. Publicly accessible — no auth required.
Path parameter
| Parameter | Type | Description |
|---|---|---|
id |
UUID string | The incident's id |
Response 200
{ ...incident }Error responses
| Status | Body |
|---|---|
404 |
{ "error": "Incident not found" } |
Update an incident's title, description, and/or status. Authentication required. Only the reporter can update.
Headers
Authorization: Bearer <access_token>
Content-Type: application/json
Request body (all fields optional, at least one required)
{
"title": "Updated title",
"description": "Updated description.",
"status": "investigating"
}| Field | Type | Notes |
|---|---|---|
title |
string | Max 200 characters |
description |
string | null | Max 1 000 characters; null clears it |
status |
string | active · investigating · resolved |
Response 200
{ ...incident }Error responses
| Status | Body |
|---|---|
400 |
{ "error": "No valid fields to update" } |
400 |
{ "error": "Invalid status" } |
401 |
{ "error": "Unauthorized" } |
403 |
{ "error": "Forbidden" } |
404 |
{ "error": "Incident not found" } |
Delete an incident. Authentication required. Only the reporter can delete.
Headers
Authorization: Bearer <access_token>
Response 204 — No content.
Error responses
| Status | Body |
|---|---|
401 |
{ "error": "Unauthorized" } |
403 |
{ "error": "Forbidden" } |
404 |
{ "error": "Incident not found" } |
Google sign-in is handled through a browser redirect flow. From a mobile app use a web view or the device browser:
- Redirect the user to:
https://<your-domain>/login - The user taps Sign in with Google → Supabase OAuth → redirects to
/auth/callback?code=.... - The callback exchanges the code for a session (cookie-based for web).
For native mobile OAuth without a web view, use the Supabase Flutter or Supabase Swift/Kotlin SDKs directly with signInWithOAuth.
No custom rate limiting is implemented at the application level. Supabase enforces its own limits on auth endpoints (e.g. signup/login) per project plan.