Authentication
All Tether API endpoints (except QR codes and the public branding endpoint) require a Bearer token. This page explains how to obtain and use tokens.
When Tether is running, navigate to https://yourdomain.com/docs for Swagger UI — you can try all API calls directly in the browser with try-it-out functionality.
Obtaining a token
Post credentials to the login endpoint using application/x-www-form-urlencoded encoding (not JSON):
httpPOST /api/auth/login Content-Type: application/x-www-form-urlencoded username=admin%40atechsolutions.org&password=yourpassword
Successful response:
json{{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "bearer" }}
Failed response (wrong credentials):
json{{ "detail": "Incorrect email or password" }}
Using the token
Include the token in the Authorization header of every request:
httpGET /api/assets Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Token expiry
Tokens are valid for 7 days from the time of issue. After expiry, the API
returns 401 Unauthorized. The client must log in again to get a new token.
If SECRET_KEY is rotated in .env and Tether restarted, all
existing tokens are immediately invalidated and all users must log in again.
Tenant scoping
The API determines the active tenant from the HTTP Host header of the request:
| Host header | Tenant scope |
|---|---|
atechsolutions.org | MSP root — MSP staff see all tenants; client users get an error |
acme.atechsolutions.org | Scoped to the Acme tenant — all queries restricted to Acme data |
assets.acme.com (custom domain) | Scoped to whichever tenant has that custom domain registered |
MSP staff can also scope to a specific tenant from the root domain by adding a query parameter:
httpGET /api/assets?tenant=acme Host: atechsolutions.org Authorization: Bearer {msp-token} # Returns assets scoped to the "acme" tenant
Get current user
httpGET /api/auth/me Authorization: Bearer {token} # Response: {{ "id": 1, "name": "Sarah Chen", "email": "sarah@acme.com", "role": "client_admin", "is_msp_staff": false, "tenant_id": 5, "tenant_slug": "acme", "permissions": [ "assets.view", "assets.create", "assets.edit", "assets.delete", "assets.checkout", "assets.checkin", "assets.import", "assets.export", "categories.manage", "locations.manage", "employees.manage", "users.manage", "reports.view", "settings.manage" ] }}
Change password
httpPOST /api/auth/change-password Authorization: Bearer {token} Content-Type: application/json {{ "current_password": "oldpassword", "new_password": "newstrongpassword" }} # Success: 200 OK # Wrong current password: 400 Bad Request
Public endpoints (no auth required)
| Endpoint | Returns |
|---|---|
GET /api/public/branding | Tenant name, logo URL, accent colour — used by the login page |
GET /api/assets/{id}/qr | QR code PNG for the asset tag |
GET /api/invites/validate/{token} | Invite token details for the account setup page |
GET /api/demo/stats | Asset count for the demo page counter (demo mode only) |
Error response format
All API errors use a consistent format:
json{{ "detail": "Human-readable error message" }}
| HTTP status | Meaning |
|---|---|
200 OK | Success |
400 Bad Request | Validation error — check the request body |
401 Unauthorized | Missing or expired token |
403 Forbidden | Valid token but insufficient permissions |
404 Not Found | Resource does not exist (or belongs to a different tenant) |
402 Payment Required | Asset limit reached (SaaS mode only) |
422 Unprocessable Entity | Request body failed Pydantic validation |
500 Internal Server Error | Unexpected error — check server logs |