/api/v1/*: the interface the RAID mobile client and any authenticated integration use. 86 operations across six areas.| Environment | Base URL |
|---|---|
| Production | https://user.diveraid.com |
| Staging | https://test.diveraid.com |
/api/v1.v1 is the only version. It is expected to change in place rather than be replaced: breaking changes are announced, not versioned away. Anything described here as decided-but-not-implemented will arrive inside v1.Authorization: Bearer 20|raid_ydVCW8OH9aAkZIKDuWAIOmNA6BFdfZrbAPZtpzFjebcf{id}|raid_{random}. It carries no readable payload: do not attempt to decode a user id or an expiry out of it. It is stored server-side, which is what makes individual revocation possible.POST /api/v1/auth/login with email and password. The response carries token, token_type and expires_in.device_name as well. It is optional, but it is the label the account holder sees on their privacy screen when deciding which device to sign out. Without it the server derives something generic from the User-Agent, which on a mobile client is rarely useful.expires_in is returned at login, around 7776000 seconds; treat it as approximate, it is computed at response time and truncates to the second.* ability. Abilities are in place so that scoped tokens can be introduced without a schema change; no endpoint restricts on them today./api/v1/professional/* require the professional role and return 403 otherwise, regardless of the token.| Event | Effect |
|---|---|
| Login | Issues a new token, leaves others alone |
| Logout | Revokes only the token used for the call |
| Password change, authenticated | Revokes all others, keeps yours valid |
| Password reset, forgot-password flow | Revokes all tokens, including yours |
| Account suspended, banned or deleted | Revokes all tokens |
| Revoke from the privacy screen | Revokes that one device |
POST /api/v1/auth/register, /login, /password/forgot, /password/reset. All four are rate limited.{
"success": true,
"status": "success",
"message": "OK",
"data": { }
}| Key | Notes |
|---|---|
success | Boolean. Always present |
status | "success" or "error". Carries the same information as success; both are kept for compatibility |
message | Human-readable. "OK" on a plain read, descriptive after a write |
data | The payload. Omitted entirely when there is nothing to return, for example after a logout or a delete. Do not expect data: null |
201 Created uses the same shape.| Code | Meaning |
|---|---|
| 401 | No token, malformed token, or expired token |
| 403 | Authenticated, but the role or ownership check refused |
| 404 | Resource not found |
| 422 | Validation failure, or a business rule refusing an otherwise valid request |
| 429 | Rate limit exceeded |
| 500 | Server error. Several endpoints currently return this unconditionally; see section 12 |
{
"success": false,
"status": "error",
"message": "Token expired",
"errors": { }
}errors is present only on validation failures.message alone:message | Meaning |
|---|---|
Unauthenticated | No Authorization header |
Invalid token | Malformed, unknown or already revoked |
Token expired | Past its 90 days |
errors, an object keyed by field name with an array of messages:{
"success": false,
"status": "error",
"message": "Validation failed",
"errors": { "email": ["The email field must be a valid email address."] }
}errors. The request was well-formed; the domain refused it. For example, a professional cannot change dive centre while a renewal is open:{
"success": false,
"status": "error",
"message": "Dive center cannot be changed while a professional renewal is active"
}errors, not by the status code.success and no status:{
"message": "The country field must be 2 characters.",
"errors": { "country": ["The country field must be 2 characters."] }
}Diver\MedicalController, Diver\FreeLearningController, Professional\ClassroomController, Professional\RenewalController, Professional\StoreController, Professional\StudentController, Sync\SyncController, Utility\UtilityController. Their endpoint files say so individually.errors, never by looking for success.PATCH /api/v1/diver/dive-logs/{diveLog} enforces ownership in the Form Request's authorize() rather than in the controller, so its refusal is Laravel's default:{ "message": "This action is unauthorized." }{diveLog}, {quiz}, {exam}, {module}, {certification}) include the looked-up id in the message.{log_code} manually do not.| Scope | Limit |
|---|---|
register, login, password/forgot, password/reset | 10 requests per minute per IP, counting successes and failures alike |
auth/email/resend | 6 per minute per authenticated user |
| All other authenticated endpoints | The default api limiter |
429.GET /api/v1/diver/dive-logs and GET /api/v1/professional/dive-logs/{user}/{log_code}.{
"success": true, "status": "success", "message": "OK",
"data": {
"data": [],
"meta": { "current_page": 1, "last_page": 1, "per_page": 20, "total": 0 }
}
}data.data and the pagination at data.meta. There is no links key. per_page is fixed at 20 and is not yet accepted as a parameter.GET /api/v1/utility/dive-centers returns every active dive centre in one response, currently 836, and GET /api/v1/diver/certifications returns every certification a diver holds, with no cap. Fetch them once and filter client-side rather than calling them per keystroke.per_page, with a default of 20 and a maximum of 100, plus a search filter on the dive centre list, is decided and not yet implemented.| Placeholder | Kind |
|---|---|
{log_code} | Opaque char(32) string identifying a course log or free learning. Not a primary key. Resolved manually by the controller, which performs its own ownership check |
{certification}, {diveLog}, {quiz}, {exam}, {module}, {order}, {classroom} | Numeric primary key, resolved by route-model binding |
{user} | Numeric primary key, on professional endpoints acting on a student |
id in POST /api/v1/utility/dive-center and dive_center_id in PATCH /api/v1/profile/dive-center. Same value, two names.Y-m-d strings: dob, expire_date, renewal_date, gdpr, terms, email_verified_at.created_at, updated_at, for example 2026-09-10T09:21:23.000000Z.timezone field is a display preference. The API does not localise timestamps to it; conversion is the client's responsibility.135, not 13500.currency is USD on store endpoints and is not negotiable: the diver and professional stores price in US dollars only. The currency field on the user profile is derived from their country and is a display preference, not the currency they will be charged in.qualification_cast returns Recreational, status returns Account Active, preferred_system returns Metric.Accept-Language. No endpoint reads that header.PATCH /api/v1/profile accepts gender as the numeric enum value while the response returns the label. What you send is not what you get back.{"value": "active", "label": "Active"}, is decided and not yet implemented. GET /api/v1/utility/countries already returns that shape and is the model for it.dive_center and distributor contain the literal English string Not assigned rather than null.null where not applicable, is decided and not yet implemented.| Endpoint | Failure |
|---|---|
GET /api/v1/diver/courses | 500, unconditionally |
GET /api/v1/diver/courses/expired | 500, unconditionally |
GET /api/v1/diver/courses/{log_code} | 500, unconditionally |
GET /api/v1/diver/free-learnings/{log_code} | 500, unconditionally |
GET /api/v1/diver/store | 500, unconditionally |
GET /api/v1/diver/courses/{log_code}/skills | 500 once the log has skill records |
GET /api/v1/diver/certifications/{certification}/history | 500 once the certification has skill records |
GET /api/v1/diver/certifications/{certification}/history/skills | 500 once the certification has skill records |
POST /api/v1/diver/courses/{log_code}/skills/sign is a special case: the write succeeds and the response then fails with a 500. A client that retries on 500 will re-submit signatures that already landed. The update is idempotent per skill, so no data is corrupted, but the write and the response are not atomic from the client's point of view.| Area | Operations | Path |
|---|---|---|
auth | 7 | /api/v1/auth/* |
profile | 5 | /api/v1/profile* |
utility | 5 | /api/v1/utility/* |
diver | 31 | /api/v1/diver/* |
professional | 35 | /api/v1/professional/* |
sync | 3 | /api/v1/sync/* |
professional endpoints require the professional role in addition to a valid token./api/v1 prefix:POST /api/v1/diver
POST /api/v1/instructor
POST /api/v1/dive_center
GET /api/v1/dive_center/countrytoken header rather than a bearer token, serve the public website, and follow none of the conventions on this page: a different envelope, a different error shape, no pagination. They are documented separately under docs/api/search/ and are scheduled to move to their own prefix.data.data wrapper on the profile endpoint that has never existed.