API Reference
v1 · REST / JSONThe SafiTrack REST API gives you programmatic access to companies, contacts, visits, route plans, opportunities, and webhooks. All requests and responses use JSON. The API follows RESTful conventions and returns standard HTTP status codes.
Introduction
All API calls are made to the base URL below. Every authenticated endpoint requires two headers: your user JWT and the public anon key.
Requests without valid credentials return 401 Unauthorized. If your token has expired, exchange it
using the /auth/v1/token?grant_type=refresh_token endpoint before retrying.
Authentication
SafiTrack uses JWT bearer tokens issued on sign-in. Pass both the Authorization and
apikey headers on every request.
curl https://ndrkncirkekpqjjkasiy.supabase.co/rest/v1/companies \
-H "Authorization: Bearer <USER_JWT>" \
-H "apikey: <ANON_KEY>" \
-H "Content-Type: application/json"
JWTs expire after 1 hour. Refresh tokens using POST /auth/v1/token?grant_type=refresh_token
with {"refresh_token":"<TOKEN>"} in the body. Store and rotate refresh tokens securely.
Rate Limits
The API allows up to 1,000 requests per minute per access token. When the limit is exceeded
the server responds with 429 Too Many Requests and includes a Retry-After header
indicating how many seconds to wait before retrying.
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json
{"error":"rate_limited","message":"Quota exceeded. Retry after 30 s."}
Pagination
List endpoints support offset-based pagination via query parameters and return a Content-Range
header indicating the total record count.
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | 20 | Max records to return (1–100) |
offset |
integer | 0 | Number of records to skip |
order |
string | created_at.desc | Field and direction, e.g. name.asc |
GET /companies?limit=25&offset=50&order=name.asc HTTP/1.1
200 OK
Content-Range: 50-74/312
Errors
All errors return a consistent JSON body with error, message, and status
fields.
{
"error": "not_found",
"message": "No company found with id c_abc123",
"status": 404
}
| Status | Code | Description |
|---|---|---|
| 400 | validation_error |
Request body is missing required fields or has invalid values |
| 401 | unauthorized |
Missing or expired JWT / apikey |
| 403 | forbidden |
Token valid but lacks permission for this resource |
| 404 | not_found |
Resource with the given ID does not exist |
| 409 | conflict |
Duplicate unique field (e.g. company name) |
| 429 | rate_limited |
Request quota exceeded |
| 500 | server_error |
Unexpected server-side failure |
Companies /companies
Represents a business entity your team sells to or services. Every contact, visit, and opportunity is linked to a company.
Query parameters
| Parameter | Type | Description |
|---|---|---|
limit optional |
integer | Max records (default 20, max 100) |
offset optional |
integer | Records to skip (default 0) |
name optional |
string | Filter by company name (case-insensitive, partial match) |
owner_id optional |
uuid | Filter by assigned owner user id |
industry optional |
string | Filter by industry tag |
Response
[
{
"id": "c_01HZ3BXQK5VMA",
"name": "Acme Corp",
"industry": "retail",
"phone": "+254712345678",
"email": "info@acme.co.ke",
"website": "https://acme.co.ke",
"owner_id": "u_01HZ1A...",
"address": "Westlands, Nairobi",
"created_at": "2025-03-15T08:22:11Z",
"updated_at": "2025-06-01T14:05:33Z"
}
]
Body parameters
| Parameter | Type | Description |
|---|---|---|
name required |
string | Company display name (max 200 chars) |
industry optional |
string | Industry tag, e.g. retail, fmcg, pharma |
phone optional |
string | E.164-formatted phone number |
email optional |
string | Primary contact email |
website optional |
string | Full URL including scheme |
address optional |
string | Street / area address |
owner_id optional |
uuid | Assigned team member. Defaults to calling user. |
Example request
curl -X POST https://ndrkncirkekpqjjkasiy.supabase.co/rest/v1/companies \
-H "Authorization: Bearer <USER_JWT>" \
-H "apikey: <ANON_KEY>" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Corp",
"industry": "retail",
"phone": "+254712345678",
"email": "info@acme.co.ke"
}'
Response
{
"id": "c_01HZ3BXQK5VMA",
"name": "Acme Corp",
"industry": "retail",
"phone": "+254712345678",
"email": "info@acme.co.ke",
"owner_id": "u_01HZ1A...",
"created_at": "2025-06-12T09:00:00Z",
"updated_at": "2025-06-12T09:00:00Z"
}
Path parameters
| Parameter | Type | Description |
|---|---|---|
id required |
string | Company ID returned from list or create |
Returns the full company object or 404 if it does not exist or the calling user lacks access.
Send only the fields you want to change. All fields are optional. Returns the updated company object.
curl -X PATCH https://...supabase.co/rest/v1/companies/c_01HZ3BXQK5VMA \
-H "Authorization: Bearer <USER_JWT>" \
-H "apikey: <ANON_KEY>" \
-H "Content-Type: application/json" \
-d '{"phone":"+254799000111","industry":"fmcg"}'
Permanently deletes the company and cascades to its associated contacts, visits, and opportunities. Returns
204 No Content on success.
Deletion cannot be undone. All linked contacts, visits, and opportunities are permanently removed.
Contacts /contacts
Individual people at a company. Contacts can be linked to visits, opportunities, and reminders.
Query parameters
| Parameter | Type | Description |
|---|---|---|
company_id optional |
uuid | Return only contacts for this company |
search optional |
string | Full-text search on name, email, phone |
limit optional |
integer | Max records (default 20) |
offset optional |
integer | Records to skip (default 0) |
Response
[
{
"id": "ct_01HZ4DKQP8WN",
"company_id": "c_01HZ3BXQK5VMA",
"first_name": "Amina",
"last_name": "Waweru",
"email": "amina@acme.co.ke",
"phone": "+254701112233",
"title": "Procurement Manager",
"created_at": "2025-04-02T11:30:00Z"
}
]
Body parameters
| Parameter | Type | Description |
|---|---|---|
company_id required |
uuid | Company this contact belongs to |
first_name required |
string | First name |
last_name optional |
string | Last name |
email optional |
string | Email address |
phone optional |
string | E.164-formatted phone |
title optional |
string | Job title |
Returns the full contact object including their linked company details.
Update any subset of contact fields. Returns the updated contact object.
Visits /sales_visits
Sales and field visits recorded by reps. Each visit captures GPS coordinates, outcome, duration, and notes.
Query parameters
| Parameter | Type | Description |
|---|---|---|
company_id optional |
uuid | Filter by company |
rep_id optional |
uuid | Filter by sales rep |
outcome optional |
string | One of sale, follow_up, no_contact |
from optional |
ISO 8601 | Filter visits on or after this datetime |
to optional |
ISO 8601 | Filter visits before this datetime |
Response
[
{
"id": "v_01HZ8MNZR4KL",
"company_id": "c_01HZ3BXQK5VMA",
"rep_id": "u_01HZ1A...",
"visited_at": "2025-06-10T10:15:00Z",
"outcome": "sale",
"duration_mins": 45,
"latitude": -1.2921,
"longitude": 36.8219,
"notes": "Agreed on 50-unit order. Follow up with invoice.",
"created_at": "2025-06-10T10:15:00Z"
}
]
Body parameters
| Parameter | Type | Description |
|---|---|---|
company_id required |
uuid | Company being visited |
visited_at required |
ISO 8601 | Visit start timestamp |
outcome required |
string | sale · follow_up · no_contact |
latitude optional |
float | GPS latitude |
longitude optional |
float | GPS longitude |
duration_mins optional |
integer | Visit length in minutes |
notes optional |
string | Free-text visit notes |
Update outcome, notes, or duration of an existing visit. Location fields cannot be changed after creation.
Route Plans /route_plans
Ordered collections of stops for a rep's field day. Optionally auto-optimised for shortest-path traversal.
Query parameters
| Parameter | Type | Description |
|---|---|---|
rep_id optional |
uuid | Filter by assigned rep |
date optional |
date (YYYY-MM-DD) | Plans scheduled for this date |
status optional |
string | planned · active · completed |
Body parameters
| Parameter | Type | Description |
|---|---|---|
rep_id required |
uuid | Rep this plan is assigned to |
date required |
date | Scheduled date (YYYY-MM-DD) |
stops required |
array<uuid> | Ordered array of company IDs |
optimise optional |
boolean | Reorder stops for minimum travel distance (default false) |
curl -X POST https://...supabase.co/rest/v1/route_plans \
-H "Authorization: Bearer <USER_JWT>" \
-H "apikey: <ANON_KEY>" \
-H "Content-Type: application/json" \
-d '{
"rep_id": "u_01HZ1A...",
"date": "2025-07-21",
"stops": ["c_01HZ3...", "c_02KL9...", "c_03MN0..."],
"optimise": true
}'
Returns the route plan with its ordered stops array, each resolved to the full company object
with name and coordinates.
Modify stops or status of a route plan. To mark a plan complete, set "status":"completed".
Opportunities /opportunities
Sales pipeline deals. Track value, stage progress, expected close date, and win probability.
Query parameters
| Parameter | Type | Description |
|---|---|---|
company_id optional |
uuid | Filter by company |
stage optional |
string | lead · qualified · proposal · negotiation ·
won · lost |
owner_id optional |
uuid | Filter by owner |
Response
[
{
"id": "opp_01HZ9KLWQ2TR",
"company_id": "c_01HZ3BXQK5VMA",
"title": "Q3 Bulk Order",
"value": 450000,
"currency": "KES",
"stage": "proposal",
"probability": 65,
"close_date": "2025-08-31",
"owner_id": "u_01HZ1A...",
"created_at": "2025-06-05T08:00:00Z"
}
]
Body parameters
| Parameter | Type | Description |
|---|---|---|
company_id required |
uuid | Associated company |
title required |
string | Deal name |
value optional |
number | Monetary value |
currency optional |
string | ISO 4217 currency code (default KES) |
stage optional |
string | Pipeline stage (default lead) |
probability optional |
integer | Win probability 0–100 |
close_date optional |
date | Expected close date (YYYY-MM-DD) |
Move an opportunity through stages or update its value. To close a deal: set "stage":"won" or
"stage":"lost".
Webhooks /webhooks
Register HTTPS endpoints to receive real-time event notifications. SafiTrack signs every delivery with an HMAC-SHA256 signature so you can verify authenticity.
Events
| Event | Triggered when |
|---|---|
company.created |
A new company is added |
company.updated |
A company record is changed |
contact.created |
A new contact is added |
visit.completed |
A visit is logged with an outcome |
opportunity.stage_changed |
A deal moves to a new stage |
opportunity.won |
A deal is marked won |
route_plan.started |
A rep begins their route plan |
Returns all registered webhooks for the authenticated workspace. The secret field is never
returned after creation.
Body parameters
| Parameter | Type | Description |
|---|---|---|
url required |
string | HTTPS endpoint to receive events |
events required |
array<string> | List of event names to subscribe to |
description optional |
string | Internal label for this subscription |
curl -X POST https://...supabase.co/rest/v1/webhooks \
-H "Authorization: Bearer <USER_JWT>" \
-H "apikey: <ANON_KEY>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/hooks/safitrack",
"events": ["visit.completed", "opportunity.won"],
"description": "CRM sync pipeline"
}'
The response returns a one-time secret. Store it immediately — it will not be shown again.
Verifying signatures
Each webhook delivery includes an X-SafiTrack-Signature header. Verify it with HMAC-SHA256:
const crypto = require('crypto');
function verifySignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(`sha256=${expected}`)
);
}
// Express example
app.post('/hooks/safitrack', express.raw({ type: '*/*' }), (req, res) => {
const sig = req.headers['x-safitrack-signature'];
if (!verifySignature(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
console.log('Event:', event.type, event.data);
res.sendStatus(200);
});
Unregisters the webhook. No further events will be delivered to that URL. Returns
204 No Content.