Skip to content

auth ​

← All modules

19 endpoints.

POST /api/v1/auth/send-verification ​

Send Verification

Admin-triggered: send a verification email for a specific user. Returns success/failure so the grid can show a toast.

Request body

json
{
  "properties": {
    "user_id": {
      "type": "integer",
      "title": "User Id"
    }
  },
  "type": "object",
  "required": [
    "user_id"
  ],
  "title": "SendVerificationRequest"
}

200 — Successful Response

json
{
  "properties": {
    "detail": {
      "type": "string",
      "title": "Detail"
    }
  },
  "type": "object",
  "required": [
    "detail"
  ],
  "title": "MessageResponse"
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

POST /api/v1/auth/resend-verification ​

Resend Verification

Self-serve resend for a user blocked at login by the email-verification gate (login's email_unverified 403) — no auth, since they can't sign in yet. Always returns the same generic message regardless of whether the email is registered/verified, mirroring forgot_password's no-enumeration behaviour.

Request body

json
{
  "properties": {
    "email": {
      "type": "string",
      "title": "Email"
    }
  },
  "type": "object",
  "required": [
    "email"
  ],
  "title": "ResendVerificationRequest"
}

200 — Successful Response

json
{
  "properties": {
    "detail": {
      "type": "string",
      "title": "Detail"
    }
  },
  "type": "object",
  "required": [
    "detail"
  ],
  "title": "MessageResponse"
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

GET /api/v1/auth/verify-email ​

Verify Email

Click-through from the verification email link. Marks the user's email as verified and shows a simple success page the user can close. The token is single-use — subsequent clicks are rejected.

Parameters

NameInRequiredTypeDescription
tokenqueryyesstring

200 — Successful Response

json
{}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

POST /api/v1/auth/signup ​

Signup

Request body

json
{
  "properties": {
    "email": {
      "type": "string",
      "format": "email",
      "title": "Email"
    },
    "password": {
      "type": "string",
      "maxLength": 128,
      "minLength": 8,
      "title": "Password"
    },
    "full_name": {
      "type": "string",
      "maxLength": 255,
      "minLength": 1,
      "title": "Full Name"
    },
    "org_name": {
      "type": "string",
      "maxLength": 255,
      "minLength": 1,
      "title": "Org Name"
    },
    "org_slug": {
      "type": "string",
      "maxLength": 100,
      "minLength": 2,
      "title": "Org Slug"
    },
    "addon_apps": {
      "items": {
        "type": "string"
      },
      "type": "array",
      "title": "Addon Apps"
    },
    "plan": {
      "anyOf": [
        {
          "type": "string",
          "maxLength": 32
        },
        {
          "type": "null"
        }
      ],
      "title": "Plan"
    },
    "billing_period": {
      "type": "string",
      "enum": [
        "monthly",
        "annual"
      ],
      "title": "Billing Period",
      "default": "monthly"
    }
  },
  "type": "object",
  "required": [
    "email",
    "password",
    "full_name",
    "org_name",
    "org_slug"
  ],
  "title": "SignupRequest",
  "description": "Self-serve signup: creates the user, their first organization, and an\nowner membership in one step."
}

201 — Successful Response

json
{
  "properties": {
    "access_token": {
      "type": "string",
      "title": "Access Token"
    },
    "refresh_token": {
      "type": "string",
      "title": "Refresh Token"
    },
    "token_type": {
      "type": "string",
      "title": "Token Type",
      "default": "bearer"
    },
    "handoff_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Url"
    },
    "handoff_site": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Site"
    },
    "handoff_instance": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Site"
    }
  },
  "type": "object",
  "required": [
    "access_token",
    "refresh_token"
  ],
  "title": "TokenResponse"
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

POST /api/v1/auth/launcher-login ​

Launcher Login

Sign in from the launcher and get back a URL that LANDS you signed in.

This is the MOBILE APP's login path. login.lubb.co.za is the app's front door — each bottom tab is one of its products — and no longer the web's: a browser signs in on its own site's /sign-in (owner, 2026-09-02). Keep this endpoint working for the app; do not wire the web SPA back into it.

The launcher is a static page on its own origin. It can post credentials here, but it cannot set this host's cookies — so on its own it could only ever send the customer to our /sign-in to type the same password a second time, inside the app's WebView, which is the one thing the app exists to avoid.

So this returns a one-time SSO link instead of a token pair. Everything about the credential check is the ordinary /login — the same rate limits, the same audit row, the same sign-in alert, the same gateway handoff — because it literally calls it.

Which system you land in is decided here, not by the launcher. Post to the door the customer chose; if their account does not live behind that door, _gateway_handoff finds the site that does admit them and the URL that comes back points THERE. Choosing "Lubb ERP" with only a portal account signs you into Lubb Portal rather than failing.

The reply never distinguishes "wrong password" from "no such account" — it is /login's own 401, unchanged.

Request body

json
{
  "properties": {
    "email": {
      "type": "string",
      "title": "Email"
    },
    "password": {
      "type": "string",
      "title": "Password"
    }
  },
  "type": "object",
  "required": [
    "email",
    "password"
  ],
  "title": "LoginRequest",
  "description": "A credential: an address OR a mobile number, plus a password.\n\n``email`` was ``EmailStr`` until 2026-09-09, which meant a customer whose\nusername IS a mobile number (VehicleDB — owner: \"username = mobile number\")\nwas refused by pydantic with a 422 about an invalid address before the\nhandler could look them up. The field keeps its name because the SPA and\nevery stored client send it; what it carries is decided by\napp.services.user_identity.classify_identifier, which is the one place that\nknows an \"@\" settles it. Validation did not disappear: an identifier that\nis neither resolves to no user and gets the same 401 as a wrong password,\nwhich is also the answer that leaks the least."
}

200 — Successful Response

json
{}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

POST /api/v1/auth/login ​

Login

Request body

json
{
  "properties": {
    "email": {
      "type": "string",
      "title": "Email"
    },
    "password": {
      "type": "string",
      "title": "Password"
    }
  },
  "type": "object",
  "required": [
    "email",
    "password"
  ],
  "title": "LoginRequest",
  "description": "A credential: an address OR a mobile number, plus a password.\n\n``email`` was ``EmailStr`` until 2026-09-09, which meant a customer whose\nusername IS a mobile number (VehicleDB — owner: \"username = mobile number\")\nwas refused by pydantic with a 422 about an invalid address before the\nhandler could look them up. The field keeps its name because the SPA and\nevery stored client send it; what it carries is decided by\napp.services.user_identity.classify_identifier, which is the one place that\nknows an \"@\" settles it. Validation did not disappear: an identifier that\nis neither resolves to no user and gets the same 401 as a wrong password,\nwhich is also the answer that leaks the least."
}

200 — Successful Response

json
{
  "properties": {
    "access_token": {
      "type": "string",
      "title": "Access Token"
    },
    "refresh_token": {
      "type": "string",
      "title": "Refresh Token"
    },
    "token_type": {
      "type": "string",
      "title": "Token Type",
      "default": "bearer"
    },
    "handoff_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Url"
    },
    "handoff_site": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Site"
    },
    "handoff_instance": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Site"
    }
  },
  "type": "object",
  "required": [
    "access_token",
    "refresh_token"
  ],
  "title": "TokenResponse"
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

POST /api/v1/auth/request-otp ​

Request Otp

Text a one-time sign-in code to a customer's mobile number.

Request body

json
{
  "properties": {
    "mobile_number": {
      "type": "string",
      "maxLength": 32,
      "minLength": 3,
      "title": "Mobile Number"
    }
  },
  "type": "object",
  "required": [
    "mobile_number"
  ],
  "title": "MobileOtpRequest",
  "description": "Ask for a sign-in code by SMS (customer portals — see\napp.services.mobile_signin). A plain string, not a validated number: the\nnormaliser owns what a usable ZA mobile is, and a pydantic pattern here\nwould reject \"+27 82 123 4567\" before it ever reached it."
}

200 — Successful Response

json
{
  "properties": {
    "detail": {
      "type": "string",
      "title": "Detail"
    }
  },
  "type": "object",
  "required": [
    "detail"
  ],
  "title": "MessageResponse"
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

POST /api/v1/auth/verify-otp ​

Verify Otp

Exchange a texted code for an ordinary portal session.

Request body

json
{
  "properties": {
    "mobile_number": {
      "type": "string",
      "maxLength": 32,
      "minLength": 3,
      "title": "Mobile Number"
    },
    "code": {
      "type": "string",
      "maxLength": 12,
      "minLength": 4,
      "title": "Code"
    }
  },
  "type": "object",
  "required": [
    "mobile_number",
    "code"
  ],
  "title": "MobileOtpVerify"
}

200 — Successful Response

json
{
  "properties": {
    "access_token": {
      "type": "string",
      "title": "Access Token"
    },
    "refresh_token": {
      "type": "string",
      "title": "Refresh Token"
    },
    "token_type": {
      "type": "string",
      "title": "Token Type",
      "default": "bearer"
    },
    "handoff_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Url"
    },
    "handoff_site": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Site"
    },
    "handoff_instance": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Site"
    }
  },
  "type": "object",
  "required": [
    "access_token",
    "refresh_token"
  ],
  "title": "TokenResponse"
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

POST /api/v1/auth/forgot-password ​

Forgot Password

Send a reset link, by email OR by SMS depending on what was typed.

Operator, 2026-08-27: "set up forgot passwords for both email and sms". An account can be identified by either since the 2026-08-16 identity restructure (users.mobile), and a customer whose login IS a mobile number had no way back in at all — this endpoint only ever looked at users.email, so the reset form was a dead end for exactly the accounts least likely to have an address.

Always returns the same generic message, whether or not the identifier is registered and whichever kind it is, so this endpoint can't be used to enumerate accounts — or to tell an email account apart from a mobile one.

Request body

json
{
  "properties": {
    "email": {
      "type": "string",
      "maxLength": 255,
      "minLength": 3,
      "title": "Email"
    }
  },
  "type": "object",
  "required": [
    "email"
  ],
  "title": "ForgotPasswordRequest",
  "description": "Start a password reset by EITHER an email address or a mobile number.\n\nThe field is still called ``email`` — that is what the sign-in SPA has always\nposted and what every existing caller sends — but it is a plain string now,\nnot an ``EmailStr``: a mobile number has to be able to reach the endpoint\nbefore ``user_identity.classify_identifier`` can decide which kind it is. A\nnumber typed into an ``EmailStr`` field is a 422 the customer cannot act on."
}

200 — Successful Response

json
{
  "properties": {
    "detail": {
      "type": "string",
      "title": "Detail"
    }
  },
  "type": "object",
  "required": [
    "detail"
  ],
  "title": "MessageResponse"
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

POST /api/v1/auth/reset-password ​

Reset Password

Request body

json
{
  "properties": {
    "token": {
      "type": "string",
      "title": "Token"
    },
    "password": {
      "type": "string",
      "maxLength": 128,
      "minLength": 8,
      "title": "Password"
    }
  },
  "type": "object",
  "required": [
    "token",
    "password"
  ],
  "title": "ResetPasswordRequest"
}

200 — Successful Response

json
{
  "properties": {
    "detail": {
      "type": "string",
      "title": "Detail"
    }
  },
  "type": "object",
  "required": [
    "detail"
  ],
  "title": "MessageResponse"
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

POST /api/v1/auth/change-password ​

Change Password

Change your own password while signed in.

The signed-in counterpart of :func:reset_password, which proves ownership with an emailed token. This one asks for the current password instead — required even though the caller holds a valid session, because the session may have been opened with a password an administrator issued and mailed, so the bearer token alone says nothing about who is at the keyboard.

It is also the ONE route an account under must_change_password can reach besides /auth/me (see app/api/deps.py's exempt list), so it has to be reachable by a user the gate is otherwise refusing — which is why it depends on get_current_user like any other route rather than carrying its own bespoke auth.

Returns fresh tokens, and that is not a convenience. Setting a password bumps token_version, which revokes every token issued before it — the caller's own included. Without a new pair in the response the user would be signed out by the act of succeeding, land back at the login screen, and have every reason to think the change failed.

Request body

json
{
  "properties": {
    "current_password": {
      "type": "string",
      "maxLength": 128,
      "minLength": 1,
      "title": "Current Password"
    },
    "new_password": {
      "type": "string",
      "maxLength": 128,
      "minLength": 8,
      "title": "New Password"
    }
  },
  "type": "object",
  "required": [
    "current_password",
    "new_password"
  ],
  "title": "ChangePasswordRequest",
  "description": "An authenticated password change — the signed-in counterpart of\nResetPasswordRequest, which proves ownership with an emailed token instead.\n\n``current_password`` is required even though the caller already holds a\nvalid session: the session may have been opened with a password an\nadministrator issued and mailed, so the bearer token alone is not evidence\nthat the person at the keyboard is the account's owner."
}

200 — Successful Response

json
{
  "properties": {
    "access_token": {
      "type": "string",
      "title": "Access Token"
    },
    "refresh_token": {
      "type": "string",
      "title": "Refresh Token"
    },
    "token_type": {
      "type": "string",
      "title": "Token Type",
      "default": "bearer"
    },
    "handoff_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Url"
    },
    "handoff_site": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Site"
    },
    "handoff_instance": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Site"
    }
  },
  "type": "object",
  "required": [
    "access_token",
    "refresh_token"
  ],
  "title": "TokenResponse"
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

POST /api/v1/auth/refresh ​

Refresh

Request body

json
{
  "properties": {
    "refresh_token": {
      "type": "string",
      "title": "Refresh Token"
    }
  },
  "type": "object",
  "required": [
    "refresh_token"
  ],
  "title": "TokenRefresh"
}

200 — Successful Response

json
{
  "properties": {
    "access_token": {
      "type": "string",
      "title": "Access Token"
    },
    "refresh_token": {
      "type": "string",
      "title": "Refresh Token"
    },
    "token_type": {
      "type": "string",
      "title": "Token Type",
      "default": "bearer"
    },
    "handoff_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Url"
    },
    "handoff_site": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Site"
    },
    "handoff_instance": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Site"
    }
  },
  "type": "object",
  "required": [
    "access_token",
    "refresh_token"
  ],
  "title": "TokenResponse"
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

POST /api/v1/auth/impersonate ​

Impersonate

Exchange a one-time impersonation code — minted by a staff superuser's "Log in as" button (see app.services.impersonation) — for portal session tokens. The code IS the authorization: single-use, short-lived, and only ever issued by a superuser, so this endpoint is intentionally unauthenticated (like accepting an invite). Consumed on first use; rejected if used/expired.

Request body

json
{
  "properties": {
    "code": {
      "type": "string",
      "title": "Code"
    }
  },
  "type": "object",
  "required": [
    "code"
  ],
  "title": "_ImpersonateRequest"
}

200 — Successful Response

json
{
  "properties": {
    "access_token": {
      "type": "string",
      "title": "Access Token"
    },
    "refresh_token": {
      "type": "string",
      "title": "Refresh Token"
    },
    "token_type": {
      "type": "string",
      "title": "Token Type",
      "default": "bearer"
    },
    "handoff_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Url"
    },
    "handoff_site": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Site"
    },
    "handoff_instance": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Handoff Site"
    }
  },
  "type": "object",
  "required": [
    "access_token",
    "refresh_token"
  ],
  "title": "TokenResponse"
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

GET /api/v1/auth/me ​

Me

Return the user plus the tenants for their switcher.

Superusers can act on ANY tenant (see get_tenant_context), so we return every active org for them (role "owner") — otherwise a platform admin with no membership would have an empty switcher and couldn't scope any request. Regular users get exactly the tenants they hold an active membership in.

200 — Successful Response

json
{
  "properties": {
    "id": {
      "type": "string",
      "title": "Id"
    },
    "email": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Email"
    },
    "mobile": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Mobile"
    },
    "full_name": {
      "type": "string",
      "title": "Full Name"
    },
    "is_superuser": {
      "type": "boolean",
      "title": "Is Superuser"
    },
    "onboarding_completed_at": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Onboarding Completed At"
    },
    "management_read_only": {
      "type": "boolean",
      "title": "Management Read Only",
      "default": false
    },
    "must_change_password": {
      "type": "boolean",
      "title": "Must Change Password",
      "default": false
    },
    "is_internal": {
      "type": "boolean",
      "title": "Is Internal",
      "default": false
    },
    "customer_facing": {
      "type": "boolean",
      "title": "Customer Facing",
      "default": false
    },
    "is_demo": {
      "type": "boolean",
      "title": "Is Demo",
      "default": false
    },
    "custom_role_tiers": {
      "additionalProperties": {
        "type": "string"
      },
      "type": "object",
      "title": "Custom Role Tiers",
      "default": {}
    },
    "is_portal": {
      "type": "boolean",
      "title": "Is Portal",
      "default": false
    },
    "is_reseller": {
      "type": "boolean",
      "title": "Is Reseller",
      "default": false
    },
    "is_reseller_user": {
      "type": "boolean",
      "title": "Is Reseller User",
      "default": false
    },
    "tenants": {
      "items": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id"
          },
          "name": {
            "type": "string",
            "title": "Name"
          },
          "slug": {
            "type": "string",
            "title": "Slug"
          },
          "plan_tier": {
            "type": "string",
            "title": "Plan Tier"
          },
          "status": {
            "type": "string",
            "title": "Status"
          },
          "role": {
            "type": "string",
            "title": "Role"
          },
          "icon_url": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Icon Url"
          },
          "domain_name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Domain Name"
          },
          "domain_names": {
            "items": {
              "type": "string"
            },
            "type": "array",
            "title": "Domain Names",
            "default": []
          },
          "parent_tenant_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Parent Tenant Id"
          },
          "parent_name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Parent Name"
          },
          "is_reseller": {
            "type": "boolean",
            "title": "Is Reseller",
            "default": false
          },
          "site": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Site"
          },
          "site_label": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Site Label"
          },
          "site_code": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Site Code"
          },
          "contact_name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Contact Name"
          },
          "instance": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Site"
          },
          "instance_label": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Site Label"
          },
          "instance_code": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Site Code"
          }
        },
        "type": "object",
        "required": [
          "id",
          "name",
          "slug",
          "plan_tier",
          "status",
          "role"
        ],
        "title": "TenantSummary",
        "description": "A tenant the current user belongs to, plus their role in it."
      },
      "type": "array",
      "title": "Tenants"
    },
    "hub_consoles": {
      "items": {
        "properties": {
          "site": {
            "type": "string",
            "title": "Site"
          },
          "label": {
            "type": "string",
            "title": "Label"
          },
          "apps": {
            "items": {
              "type": "string"
            },
            "type": "array",
            "title": "Apps",
            "default": []
          },
          "instance": {
            "type": "string",
            "title": "Site"
          }
        },
        "type": "object",
        "required": [
          "site",
          "label"
        ],
        "title": "HubConsole",
        "description": "One brand portal the hub opens consoles on (see MeResponse.hub_consoles)."
      },
      "type": "array",
      "title": "Hub Consoles",
      "default": []
    },
    "hub_console_apps": {
      "items": {
        "type": "string"
      },
      "type": "array",
      "title": "Hub Console Apps",
      "default": []
    },
    "hub_console_per_door": {
      "items": {
        "type": "string"
      },
      "type": "array",
      "title": "Hub Console Per Door",
      "default": []
    },
    "hub_home_apps": {
      "items": {
        "type": "string"
      },
      "type": "array",
      "title": "Hub Home Apps",
      "default": []
    },
    "hub_home_label": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Hub Home Label"
    },
    "hub_home_page_doors": {
      "additionalProperties": {
        "type": "string"
      },
      "type": "object",
      "title": "Hub Home Page Doors",
      "default": {}
    }
  },
  "type": "object",
  "required": [
    "id",
    "full_name",
    "is_superuser",
    "tenants"
  ],
  "title": "MeResponse"
}

PUT /api/v1/auth/me ​

Update Me

Self-service profile update — change own name, email and/or password. Bumps token_version when the password changes so existing sessions are revoked (same mechanism as the reset-password flow).

Request body

json
{
  "properties": {
    "full_name": {
      "anyOf": [
        {
          "type": "string",
          "maxLength": 255,
          "minLength": 1
        },
        {
          "type": "null"
        }
      ],
      "title": "Full Name"
    },
    "email": {
      "anyOf": [
        {
          "type": "string",
          "format": "email"
        },
        {
          "type": "null"
        }
      ],
      "title": "Email"
    },
    "password": {
      "anyOf": [
        {
          "type": "string",
          "maxLength": 128,
          "minLength": 8
        },
        {
          "type": "null"
        }
      ],
      "title": "Password"
    }
  },
  "type": "object",
  "title": "UpdateProfileRequest",
  "description": "Self-service profile edit — the user can change their own name, email,\nand/or password.  Only supplied fields are updated (all optional)."
}

200 — Successful Response

json
{
  "properties": {
    "id": {
      "type": "string",
      "title": "Id"
    },
    "email": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Email"
    },
    "mobile": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Mobile"
    },
    "full_name": {
      "type": "string",
      "title": "Full Name"
    },
    "is_superuser": {
      "type": "boolean",
      "title": "Is Superuser"
    },
    "onboarding_completed_at": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Onboarding Completed At"
    },
    "management_read_only": {
      "type": "boolean",
      "title": "Management Read Only",
      "default": false
    },
    "must_change_password": {
      "type": "boolean",
      "title": "Must Change Password",
      "default": false
    },
    "is_internal": {
      "type": "boolean",
      "title": "Is Internal",
      "default": false
    },
    "customer_facing": {
      "type": "boolean",
      "title": "Customer Facing",
      "default": false
    },
    "is_demo": {
      "type": "boolean",
      "title": "Is Demo",
      "default": false
    },
    "custom_role_tiers": {
      "additionalProperties": {
        "type": "string"
      },
      "type": "object",
      "title": "Custom Role Tiers",
      "default": {}
    },
    "is_portal": {
      "type": "boolean",
      "title": "Is Portal",
      "default": false
    },
    "is_reseller": {
      "type": "boolean",
      "title": "Is Reseller",
      "default": false
    },
    "is_reseller_user": {
      "type": "boolean",
      "title": "Is Reseller User",
      "default": false
    },
    "tenants": {
      "items": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id"
          },
          "name": {
            "type": "string",
            "title": "Name"
          },
          "slug": {
            "type": "string",
            "title": "Slug"
          },
          "plan_tier": {
            "type": "string",
            "title": "Plan Tier"
          },
          "status": {
            "type": "string",
            "title": "Status"
          },
          "role": {
            "type": "string",
            "title": "Role"
          },
          "icon_url": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Icon Url"
          },
          "domain_name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Domain Name"
          },
          "domain_names": {
            "items": {
              "type": "string"
            },
            "type": "array",
            "title": "Domain Names",
            "default": []
          },
          "parent_tenant_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Parent Tenant Id"
          },
          "parent_name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Parent Name"
          },
          "is_reseller": {
            "type": "boolean",
            "title": "Is Reseller",
            "default": false
          },
          "site": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Site"
          },
          "site_label": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Site Label"
          },
          "site_code": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Site Code"
          },
          "contact_name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Contact Name"
          },
          "instance": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Site"
          },
          "instance_label": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Site Label"
          },
          "instance_code": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Site Code"
          }
        },
        "type": "object",
        "required": [
          "id",
          "name",
          "slug",
          "plan_tier",
          "status",
          "role"
        ],
        "title": "TenantSummary",
        "description": "A tenant the current user belongs to, plus their role in it."
      },
      "type": "array",
      "title": "Tenants"
    },
    "hub_consoles": {
      "items": {
        "properties": {
          "site": {
            "type": "string",
            "title": "Site"
          },
          "label": {
            "type": "string",
            "title": "Label"
          },
          "apps": {
            "items": {
              "type": "string"
            },
            "type": "array",
            "title": "Apps",
            "default": []
          },
          "instance": {
            "type": "string",
            "title": "Site"
          }
        },
        "type": "object",
        "required": [
          "site",
          "label"
        ],
        "title": "HubConsole",
        "description": "One brand portal the hub opens consoles on (see MeResponse.hub_consoles)."
      },
      "type": "array",
      "title": "Hub Consoles",
      "default": []
    },
    "hub_console_apps": {
      "items": {
        "type": "string"
      },
      "type": "array",
      "title": "Hub Console Apps",
      "default": []
    },
    "hub_console_per_door": {
      "items": {
        "type": "string"
      },
      "type": "array",
      "title": "Hub Console Per Door",
      "default": []
    },
    "hub_home_apps": {
      "items": {
        "type": "string"
      },
      "type": "array",
      "title": "Hub Home Apps",
      "default": []
    },
    "hub_home_label": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "title": "Hub Home Label"
    },
    "hub_home_page_doors": {
      "additionalProperties": {
        "type": "string"
      },
      "type": "object",
      "title": "Hub Home Page Doors",
      "default": {}
    }
  },
  "type": "object",
  "required": [
    "id",
    "full_name",
    "is_superuser",
    "tenants"
  ],
  "title": "MeResponse"
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

GET /api/v1/auth/me/notifications ​

Get Notification Preferences

No row yet ⇒ the dialog's own defaults (nothing persisted until Save).

200 — Successful Response

json
{
  "properties": {
    "low_balance_airtime_reminder": {
      "type": "boolean",
      "title": "Low Balance Airtime Reminder",
      "default": true
    },
    "marketing_emails": {
      "type": "boolean",
      "title": "Marketing Emails",
      "default": true
    }
  },
  "type": "object",
  "title": "NotificationPreferences",
  "description": "My Notifications dialog state. Security emails (sign-in alerts) aren't\nhere — they're mandatory, no opt-out (see app.services.sign_in_alert)."
}

PUT /api/v1/auth/me/notifications ​

Update Notification Preferences

Request body

json
{
  "properties": {
    "low_balance_airtime_reminder": {
      "type": "boolean",
      "title": "Low Balance Airtime Reminder",
      "default": true
    },
    "marketing_emails": {
      "type": "boolean",
      "title": "Marketing Emails",
      "default": true
    }
  },
  "type": "object",
  "title": "NotificationPreferences",
  "description": "My Notifications dialog state. Security emails (sign-in alerts) aren't\nhere — they're mandatory, no opt-out (see app.services.sign_in_alert)."
}

200 — Successful Response

json
{
  "properties": {
    "low_balance_airtime_reminder": {
      "type": "boolean",
      "title": "Low Balance Airtime Reminder",
      "default": true
    },
    "marketing_emails": {
      "type": "boolean",
      "title": "Marketing Emails",
      "default": true
    }
  },
  "type": "object",
  "title": "NotificationPreferences",
  "description": "My Notifications dialog state. Security emails (sign-in alerts) aren't\nhere — they're mandatory, no opt-out (see app.services.sign_in_alert)."
}

422 — Validation Error

json
{
  "properties": {
    "detail": {
      "items": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "type": "array",
      "title": "Detail"
    }
  },
  "type": "object",
  "title": "HTTPValidationError"
}

GET /api/v1/auth/family-orgs ​

Family Orgs

Orgs this person holds on OTHER brands, for the switcher's second section.

Separate from :func:me on purpose. /auth/me runs on every page load; this walks up to four other schemas, so it is fetched once when the switcher is opened rather than folded into the hot path.

Identity is the email, which is what makes this safe to federate without a shared users table: the credential is already identical across brands, so matching on the address reaches the same person and nothing else. Crucially the per-brand user ROW stays put, and with it is_superuser / is_internal / api_scopes — a Telecloud superadmin does NOT become a VehicleDB one. That privilege leak is the whole reason users was not moved into a shared schema.

Same front-door kind as the caller: a portal session lists other brands' portals, an ERP session lists their ERPs. Mixing them would offer a customer a door they hold no membership behind.

Each entry carries the target host; the client turns one into a session with POST /sso/launch?targets=<host>, which is fail-closed on that site's own sso_links — so listing a brand here still cannot open it unless the site separately declares the link.

THIS site is in the list too, first and flagged current (2026-08-31, owner: "keep the selected site in the menu, highlighted"). The section is "Sites", not "Other sites", so it has to show where you are standing as well as where else you could go — a list of everywhere-but-here reads as a bug once it carries that name. The client renders that row highlighted and inert; it is not a hop target and no SSO link is ever minted for it.

200 — Successful Response

json
{}

POST /api/v1/auth/complete-onboarding ​

Complete Onboarding

Marks the first-login setup wizard done. Idempotent — a second call is a no-op (keeps the original completion time).

200 — Successful Response

json
{
  "properties": {
    "detail": {
      "type": "string",
      "title": "Detail"
    }
  },
  "type": "object",
  "required": [
    "detail"
  ],
  "title": "MessageResponse"
}

Lubb One Documentation