Authentication¶
Every protected endpoint of the API expects proof of identity in the request headers. It always relies on a credential obtained beforehand: a session (possibly an OpenID Connect token) or an API key.
One family of endpoints is deliberately outside all of this: the rail-station referential is open data attached to no group, and takes no credential at all.
Obtaining a session¶
1. Discover the group's provider¶
GET /api/v4/auth/provider?group_id=<group_id> tells you how a group authenticates: {"provider": "legacy"} for an email / password login, or {"provider": "auth0", "auth0": {"domain": "...", "connection": "..."}} when the group is delegated to an OpenID Connect provider.
const groupId = process.env.GROUP_ID
const url = new URL("https://api.pysae.com/api/v4/auth/provider")
url.searchParams.set("group_id", groupId)
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Provider lookup failed: ${response.status}`)
}
// { provider: "legacy" } or { provider: "auth0", auth0: { domain: ... } }
console.log(await response.json())
2. Open the session¶
legacy group — POST /api/v4/login. An application/x-www-form-urlencoded body with email and password. The response returns {"session_id": "...", "expires_days": ...}.
const email = process.env.EMAIL
const password = process.env.PASSWORD
const response = await fetch("https://api.pysae.com/api/v4/login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ email, password }),
})
if (!response.ok) {
throw new Error(`Login failed: ${response.status}`)
}
const { session_id: sessionId } = await response.json()
Automation — POST /api/v4/login-scoped. Intended for automation: the scopes and expire query parameters bound the session (scope and expiry date). Same body (email, password) and same response.
const email = process.env.EMAIL
const password = process.env.PASSWORD
const url = new URL("https://api.pysae.com/api/v4/login-scoped")
url.searchParams.append("scopes", "read")
url.searchParams.set("expire", "2026-12-31")
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ email, password }),
})
if (!response.ok) {
throw new Error(`Scoped login failed: ${response.status}`)
}
const { session_id: sessionId } = await response.json()
auth0 group — OpenID Connect token. Obtain a JWT from the OpenID Connect provider returned by auth/provider, then use it as Authorization: Bearer <jwt>. Email / password login is rejected for these groups.
The session_id (or the JWT) is then used on protected requests, see the "Accepted mechanisms" section. GET /api/v4/logout closes the session (a 204 response):
curl --request GET \
--header "Authorization: Bearer <session_id>" \
"https://api.pysae.com/api/v4/logout"
A session also stops being valid as soon as the user account holding it is deleted: its sessions are revoked with it, without waiting for their expiry date.
The legacy GET /api/v2/logout and GET /auth/logout routes accept next and redirect, respectively, to return to a local page. An external destination is replaced with /.
Obtaining an API key¶
An API key is a persistent, revocable credential bound to a group and to a principal (a user, a device or a role). It avoids handling a password in an automated system.
Creating one goes through an API call authenticated by a session: you must first open a session (above) holding the admin role. POST /api/v4/groups/{group_id}/api-keys creates the key and returns it in the value field. The body declares exactly one principal: user_id, device_id or role (the latter together with group_ids); expire restricts how long it stays valid. Give it a label (1 to 100 characters) naming the integration that will hold it.
Leave scopes out. It narrows a key down to the few routes that declare a matching scope, and is meant for internal service credentials; a key carrying a scope no route declares is refused at creation, and one carrying an unrelated scope reaches nothing. Without it, the key reaches everything its role allows.
The key is handed over once
The value field appears only in the response to this creation: store it on receipt. The API keeps nothing but a digest of it, and no other endpoint returns it. A mislaid key cannot be recovered — it is revoked and recreated.
const groupId = process.env.GROUP_ID
const sessionId = process.env.SESSION_ID
const userId = process.env.USER_ID
const response = await fetch(
`https://api.pysae.com/api/v4/groups/${groupId}/api-keys`,
{
method: "POST",
headers: {
Authorization: `Bearer ${sessionId}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ user_id: userId }),
},
)
if (!response.ok) {
throw new Error(`API key creation failed: ${response.status}`)
}
const { value: apiKey } = await response.json()
A group's keys are listed via GET /api/v4/groups/{group_id}/api-keys and deleted via DELETE /api/v4/groups/{group_id}/api-keys/{api_key_id}. A key spanning several groups, or a superuser key, is created via POST /api/v4/api-keys. A key bound to no group (a superuser key, or a user_id or device_id key created there, which never carries groups) appears in no group's list: a superuser lists it via GET /api/v4/api-keys and deletes it via DELETE /api/v4/api-keys/{api_key_id}.
Those read endpoints return value_masked rather than value: the secret's first four characters, ***, then its last four (cD5D***Ifi8). Enough to recognise which key a given integration holds, before revoking it.
They also return the key's label, created_by (the user who created it), created_at, and last_used_at: the date of the latest call the key authenticated, to the minute, empty while it has never served. It tells whether an integration still uses a key, or has moved to a new one, before the old one is revoked.
A key is renamed via PATCH /api/v4/groups/{group_id}/api-keys/{api_key_id}, whose body carries the new label and nothing else: the secret, the principal and the expiry stay as they are, and the integration holding the key keeps authenticating.
Keys of your own account¶
An integrator working for several groups manages its keys without being an administrator of any of them, through /api/v4/users/me/api-keys. Such a key acts as your account: every call it authenticates carries your roles, team restrictions included, and nothing more. A group administrator who withdraws your role on their group cuts all your keys off that group at once.
POST /api/v4/users/me/api-keyscreates one. The body takesgroup_ids,label,expireandscopes; a field naming a principal (user_id,role,superuser,device_id) is refused. Withgroup_ids, holding one group your account holds a role on, the key reaches that group alone and appears in its key list, where its administrators see and can revoke it. A key covers one group at most, so that one client's administrators never learn about your other clients, nor cut them off: create one key per group. Withoutgroup_ids, it reaches every group your account holds a role on. Creating a key takes a session opened by a person, not an API key, and a superuser account cannot use this route. An account holds at most 5 active keys per group, and 5 bound to no group, counting the keys a group administrator created in its name. The secret is returned once, invalue.GET /api/v4/users/me/api-keyslists the keys acting as your account, without their secret.PATCH /api/v4/users/me/api-keys/{api_key_id}renames one,DELETE /api/v4/users/me/api-keys/{api_key_id}revokes it; a key may revoke itself. A key that is not yours answers 404.
A user_id key created on a group, by its administrator or by you, reaches that group alone, even if the account holds roles on other groups.
Accepted mechanisms¶
| Mechanism | Header | Usage |
|---|---|---|
| Session as bearer | Authorization: Bearer <session_id> |
Programmatic client reusing a session |
| API key | Authorization: Api-Key <api_key> or x-api-key: <api_key> |
Machine-to-machine (recommended) |
| JWT (OpenID Connect) | Authorization: Bearer <jwt> |
Groups delegated to an OpenID Connect provider |
| Session cookie | Cookie: session_id=<session_id> | Browser — set automatically after login |
| WebSocket | Authorization/Cookie header, or first message {"session_id": "<session_id>"} | Real-time streams |
Authorization header¶
The mechanisms based on the Authorization header — session as bearer, API key and OpenID Connect token — are used identically: you place the exact header value in the request. In the example below, PYSAE_AUTH holds, depending on the case, Bearer <session_id>, Api-Key <api_key> or Bearer <jwt>.
const groupId = process.env.GROUP_ID
const authorization = process.env.PYSAE_AUTH
const url = new URL(
`https://api.pysae.com/api/v4/groups/${groupId}/export/trips`,
)
url.searchParams.set("date", "20260131")
const response = await fetch(url, { headers: { Authorization: authorization } })
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
The API key also accepts the dedicated header x-api-key: <api_key>, equivalent to Authorization: Api-Key <api_key>:
const groupId = process.env.GROUP_ID
const apiKey = process.env.PYSAE_API_KEY
const url = new URL(
`https://api.pysae.com/api/v4/groups/${groupId}/export/trips`,
)
url.searchParams.set("date", "20260131")
const response = await fetch(url, { headers: { "x-api-key": apiKey } })
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
Session cookie¶
After a successful login, the API returns the session_id in a Set-Cookie cookie (httponly, secure, samesite=lax). A browser sends it back on every request; a manual client attaches it explicitly:
curl --cookie "session_id=<session_id>" \
"https://api.pysae.com/api/v4/groups/<group_id>/export/trips"
WebSocket¶
A WebSocket connection reuses the session_id. A client that can set headers sends it via Authorization: Bearer <session_id> or the Cookie, as over HTTP. A client that cannot (browser WebSocket) sends it in the first JSON message of the connection:
const groupId = process.env.GROUP_ID
const sessionId = process.env.SESSION_ID
const socket = new WebSocket(
`wss://api.pysae.com/api/v4/groups/${groupId}/events/op/subscribe`,
)
socket.addEventListener("open", () => {
socket.send(JSON.stringify({ session_id: sessionId }))
})
socket.addEventListener("message", (event) => {
console.log(event.data)
})
Machine-to-machine usage¶
For an automated integration:
-
API key (recommended) — persistent, revocable, scoped by group and by role, with no password in the calling code.
-
login-scoped— a session bounded byscopesandexpire, when a short-lived credential is preferable to a permanent key.
The POST /api/v4/login flow targets human usage (the web interface) and is not recommended for automation.
Versioning (v2–v5, v4 by default) and the public / internal / datahub split are described on the home page.