Appearance
API
The API lives under /api/v1. The console and the field app both use it, so anything they do, you can do. The generated contract is the reference for paths and shapes:
shared-contracts/openapi/rangefront-waste.v1.jsonis the OpenAPI file, generated from the routes withnpm run contracts:openapi.- The TypeScript types and client built from it live in
apps/web-aurelia/src/services/api/generatedandapps/mobile-expo/src/services/generated(npm run contracts:types). shared-contracts/api-envelope.schema.jsonis the response envelope.
This page covers what the contract doesn't say well: how to sign in, how tenancy works, errors, and the public and webhook endpoints.
Signing in
Authentication uses Laravel Sanctum bearer tokens.
http
POST /api/v1/auth/login
Content-Type: application/json
{ "email": "you@example.com", "password": "...", "device_name": "Reporting script" }The response holds a token, the user and the organizations the user belongs to (id, name, slug). Send the token on every later call:
http
Authorization: Bearer <token>POST /auth/logout revokes the token in use. Only users whose status is active can sign in. Tokens don't expire unless you set sanctum.expiration.
The console signs in through /sign-in with the same endpoint, sending the header X-Rangefront-Client: console. That asks for a cookie sign-in: the response sets an httpOnly rangefront_console cookie (path /api, SameSite=Lax, Secure over HTTPS) holding the Sanctum token and leaves token out of the body, so no page script can read it. The console keeps only a rangefront.signedIn hint in localStorage, and the organization under rangefront.activeOrganizationId. Requests to public pages (tracking, pay, portal, booking) are sent without either. Without the header the endpoint returns the token in the body as before, which is what the field app and API clients use. "Keep me signed in" asks for a 30-day token, otherwise 12 hours. GET /auth/organizations lists the businesses the user can switch between. Password reset is POST /auth/forgot-password then POST /auth/reset-password, and invitations are accepted with GET /auth/invitations/{token} and POST /auth/invitations/{token}/accept. The public demo build signs in automatically when VITE_FORCE_DEMO_LOGIN=true. The field app has a "Sign in and enroll" screen and stores its token with expo-secure-store.
Cookie sign-in in detail. The ConsoleSessionCookie middleware runs on routes behind auth:sanctum, and when a request has no Authorization header it builds one from the cookie, so Sanctum still checks expiry and revocation. Public routes (tracking, portal, booking, pay links) never read the cookie. Browsers send cookies on cross-site requests, so a cookie-authenticated write (anything but GET, HEAD or OPTIONS) must carry X-Rangefront-Client: console or it gets 419 with code: csrf_header_missing. A form on another site cannot set that header, and a script on another site needs a CORS preflight, which config/cors.php only passes for the console origin (CONSOLE_URL, or APP_URL), the origins in CORS_ALLOWED_ORIGINS and, in local, the dev servers on 5173, 8081 and 19006. Bearer requests need no header. POST /auth/logout revokes the token and clears the cookie, and any 401 on a cookie request clears it too. X-Organization-Id works the same as with a bearer token. In development the Vite server proxies /api, so the console (5173) and API (8000 or 8010) look like one origin and the defaults work.
If the console is served from a different site than the API, set CONSOLE_COOKIE_SAME_SITE=none (this forces Secure), CONSOLE_COOKIE_DOMAIN if they share a parent domain, the console origin in CONSOLE_URL, and VITE_RANGEFRONT_API_BASE_URL to the API address.
A suspended business answers 403 with code: organization_suspended.
Tenant context
Every route under /api/v1 except the public ones needs the organization:
http
X-Organization-Id: <organization uuid>Without it you get 400 Missing organization context. If the user isn't an active member you get 403 Organization access denied. Permissions are per organization, from the user's role there. GET /me returns the user, the organization id and the list of permission keys, which is what the console uses to hide buttons.
Routes then add permission: middleware, so a missing permission is 403 This user does not have permission to perform this action. A route that lists several permissions needs any one of them.
Envelope
Success responses look like this:
json
{
"data": { },
"meta": {
"request_id": "6f1c...",
"pagination": { "current_page": 1, "last_page": 4, "per_page": 25, "total": 92 }
}
}meta.request_id echoes your X-Request-Id header, or is generated. pagination appears on list endpoints. Some endpoints add other keys to meta, such as counts.
Pagination and search
List endpoints take page and per_page. The default is 25 and the maximum is 100 for the generic resources. The schedule allows 200, and the customer message log defaults to 50. Many lists take search. Soft-deleted records are left out.
Errors
Errors are JSON for every /api/* path. They're plain Laravel errors, not the envelope:
| Status | Meaning |
|---|---|
| 400 | Missing organization context, or a bad request |
| 401 | No or invalid token, or a failed signature on a webhook |
| 403 | No permission, not a member, or the read-only demo |
| 404 | Not found. Also returned for a record in another organization |
| 409 | Conflict, such as a replayed webhook |
| 422 | Validation failed |
| 429 | Rate limited |
A validation error looks like:
json
{ "message": "The provided credentials are invalid.", "errors": { "email": ["The provided credentials are invalid."] } }The console turns message and errors into field messages. Some errors also carry a stable code, such as organization_suspended and demo_read_only. The error shape is ErrorEnvelope in shared-contracts/api-envelope.schema.json.
Mutations use Form Requests. An invalid job status move, an edit to an issued report and a PATCH to a sent invoice all return an error and change nothing.
Rate limits
Public and expensive routes have throttles, for example 60 a minute on quote links and tracking pages, 20 a minute on pay checkout, 10 a minute on integration OAuth start and 30 a minute on AI helpers. If you sit behind a proxy, rate limit at the proxy as well, because Laravel sees the proxy's address.
Public links
These need no token. Each is addressed by a long random token whose hash is all the server keeps.
| Endpoint | For |
|---|---|
GET /tracking/{token} | The "on the way" page. Valid for 12 hours, one visit |
GET /pay/{token}, GET /pay/{token}/pdf, POST /pay/{token}/checkout | The invoice pay page and Stripe Checkout |
GET /public/quotes/{token}, .../pdf, POST .../accept, POST .../decline | The quote page |
GET /customer-portal/session/{token}, .../statement.pdf, POST .../booking-requests | The owner portal |
GET /public/booking/{slug}, POST /public/booking/{slug} | The booking form, by organization slug |
GET /branding/{organization}/logo | The logo on public pages |
GET /book/{slug} and /book/{slug}/embed.js (web routes, not under /api) | The form page and embed |
The page versions of the first four are console routes: /track/:token, /pay/:token, /quote/:token and /customer-portal/:token. Each link is built from APP_URL.
Portal links are created with POST /customers/{customer}/portal-invitations (needs customers.write). The body takes optional label, issued_to_email, scopes (summary, invoices, bookings, documents) and expires_at. The default expiry is 30 days. The raw token is returned once.
Webhooks
Inbound
All inbound webhooks are unauthenticated routes that verify a signature or a secret.
| Endpoint | From | How it's checked |
|---|---|---|
POST /webhooks/integrations/{connection} | Your own systems, Zapier, Make, accounting providers | HMAC, below |
POST /webhooks/stripe/connect | Stripe, for connected accounts | Stripe-Signature with STRIPE_CONNECT_WEBHOOK_SECRET |
POST /webhooks/billing/stripe | Stripe, for the product's own subscription | Stripe-Signature with STRIPE_WEBHOOK_SECRET |
POST /webhooks/sms/{provider}/{token?} | Twilio, ClickSend, Telnyx replies | Twilio: X-Twilio-Signature with the business's auth token. Telnyx: telnyx-signature-ed25519 with its public key. ClickSend: the secret token in the address |
POST /webhooks/voice/{token}/incoming, /turn, /status | Twilio voice, for the phone agent | X-Twilio-Signature with the auth token, plus the secret token in the address |
POST /webhooks/monitoring-alerts/twilio-status | Twilio delivery receipts for alert texts | X-Twilio-Signature with the server's TWILIO_AUTH_TOKEN |
GET /integrations/oauth/{provider}/callback | Accounting and calendar providers after sign-in | The state stored when the sign-in started |
Stripe signatures are checked as Stripe documents them: t=<timestamp>,v1=<hmac>, an HMAC-SHA256 over timestamp.payload. A timestamp older than STRIPE_WEBHOOK_TOLERANCE_SECONDS (300) is refused. Stripe events are recorded by id, so a retry changes nothing.
Integration receiver. The connection must be active with direction inbound or bidirectional and have a webhook signing secret. Send:
http
POST /api/v1/webhooks/integrations/{connection}
X-Rangefront-Timestamp: 1767225600
X-Rangefront-Nonce: a-unique-string-up-to-160-chars
X-Rangefront-Signature: sha256=<hex hmac>
Content-Type: application/jsonThe signature is HMAC-SHA256(timestamp + "." + nonce + "." + rawBody, secret). The timestamp must be inside the replay window (300 seconds by default; the connection setting webhook_replay_tolerance_seconds can move it between 60 and 3600), and each nonce is accepted once. The body must be a JSON object. A good request returns 202. Rejections return 400, 401, 403, 409 or 422 and are recorded as rejected events.
Outgoing
Create subscriptions with POST /webhook-subscriptions (needs integrations.manage): a URL, a list of events, a signing secret of at least 16 characters, and optional headers and retry policy. Events:
job.created, job.completed, inspection.completed, report.issued, invoice.sent, invoice.adjustment_recorded, payment.recorded, council_report.accepted.
Each delivery is a JSON POST with these headers:
| Header | Value |
|---|---|
X-Rangefront-Delivery | The delivery id |
X-Rangefront-Event | The event name |
X-Rangefront-Timestamp | Unix seconds |
X-Rangefront-Signature | sha256= and the hex HMAC |
User-Agent | Rangefront-Waste-Webhooks/1.0 |
The signature is HMAC-SHA256(timestamp + "." + deliveryId + "." + rawBody, secret). Verify it, and reject old timestamps. Any 2xx response counts as delivered. Failures retry with backoff. GET /webhook-deliveries shows each attempt, and POST /webhook-deliveries/{id}/retry replays one. Delivery runs on the webhooks queue.
Data import and export
customers, properties and onsite_systems can be imported and exported.
POST /legacy-data/imports/{resource}takes JSON or CSV rows. Use a dry run first. Rows are upserted by natural key and errors come back per row.- Large files go through
POST /legacy-data/import-uploads/{resource}and.../complete-upload. - Imports can be reviewed (
GET /legacy-data/imports/{import}and.../rows), then approved, replayed or rolled back. GET /legacy-data/exports/{resource}exports.
Permissions are the read and write keys of the three record types.
Route optimisation
POST /route-plans/{routePlan}/optimize (needs routes.plan) orders a day's stops. The request can name a provider: the built-in straight-line heuristic, Google Routes, Mapbox or openrouteservice, each with the keys in install. If the provider fails, it falls back to the built-in one and returns a provider_fallback warning. Locked stops keep their place, and the result carries ETAs and warnings for stops without coordinates. The console's Runs page doesn't call this. It makes its own straight-line suggestion in the browser.
Building runs
POST /schedule/run-builder (needs jobs.dispatch, jobs.write or routes.plan) groups the waiting jobs into runs of nearby stops and saves nothing. Waiting means no date, a due draft with no technician (due_within_days, default 28), or a stop left over: status scheduled or dispatched with a scheduled_date before today. Left-over stops seed the first runs and are listed in left_over with the day they were booked for; each run has left_over_stops. It takes max_stops (default 8), day_minutes (default 480), max_gap_km, job_types and branch_id, and returns runs (ordered stops with coordinates, work minutes, distance and travel), unplaced_job_ids for sites with no coordinates, the jobs themselves, technicians and vehicles. Distances are always straight-line, and at most 600 jobs are grouped per call.
POST /schedule/runs (needs jobs.dispatch or jobs.write) schedules one run: job_ids in the order to visit, date, assigned_user_id, and optionally vehicle_id, start_time and branch_id. Every job must still be waiting, or nothing is saved and the call returns 422. A left-over stop keeps its status and takes the new day and technician.
GET /recurring-settings (needs recurring.read or recurring.manage) and PATCH /recurring-settings (needs recurring.manage) read and set due_job_lookahead_days (1 to 120): how far ahead the morning job raises draft visits. Times come from the business's route provider with the straight-line fallback. If the technician already has work that day the run starts after it, and follows_existing is true.
Endpoints without a console screen
These work and have tests, but the console has no screen that calls them. They are part of the public API, so integrations can rely on them.
| Endpoint | Needs | What it does |
|---|---|---|
POST /onsite-systems/{system}/bind-qr-code | systems.write | Binds a printed QR label to a system |
GET /compliance-packs, GET /compliance-packs/{id} | reports.submit_to_council | The regional compliance rule sets |
GET /pdf-render-jobs, GET /pdf-render-jobs/{job}, POST /pdf-render-jobs/{job}/retry | reports.generate or invoices.write | Queued PDFs, and a retry for one that failed |
POST /route-plans/{routePlan}/reorder-stops | routes.plan | Sets the order of a plan's stops by hand |
GET, POST /invoices/{invoice}/adjustments, GET /invoice-adjustments/{adjustment} | invoices.read, and payments.record or invoices.write to record | Credit notes and refunds against an invoice |
/legacy-data/import-uploads/*, .../complete-upload, .../approve, .../replay, /legacy-data/exports/* | The read and write keys of customers, properties and systems | Large file imports and exports. Admin > Import uses the direct import and rollback only. See Data import and export |
Mobile sync
POST /mobile/devices/enroll registers a phone signed in with email and password. Linking with a code: GET /mobile/link-codes/people and POST /mobile/link-codes (user_id) make a one-time code (mobile.manage_devices; the code is returned once, with server, a rangefront-waste://link QR value and expires_at), and DELETE /mobile/link-codes/{id} cancels one. The phone, signed out, calls POST /mobile/link/preview (code) to show the business and person, then POST /mobile/link (code, device_name, platform) to get a token, its user, business and device. Both refuse every bad code with the same 422 link_code_invalid, and 10 bad codes from one address in 10 minutes give 429 link_code_throttled. Each device keeps the id of its token. While the device is lost or revoked, every call with that token gets 403 device_revoked, which makes the app clear its cached records. POST /mobile/sync/pull and /push move changes. Push is accepted from anyone with jobs.complete, inspections.perform or time.own, and extra form fields from plugins travel under extensions.<slug>. A revoked device is refused.
Read-only demo
When DEMO_READ_ONLY=true, every request that isn't a safe method is refused with 403 except a short allowlist in config/demo.php. Leave it off everywhere but a public demo.