Appearance
Authentication
Besides login itself, every query and mutation requires authentication.
Authentication is handled with the login mutation. It returns a JWT which you then send on every subsequent request in the authorization header.
1. Request a token
graphql
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
token
user {
id
email
first_name
last_name
plan
signup_finished
artists {
id
name
spotify_id
avatar_url
username
plan
active_plan
}
feature_flags {
feature
enabled
}
}
}
}Variables:
json
{ "email": "user@domain.com", "password": "secure_password" }Response:
json
{
"data": {
"login": {
"token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "8f2c1e7a-...",
"email": "user@domain.com",
"artists": [
{ "id": "3aad8009-a307-4429-a586-8b3dbe39cdda", "name": "Example Artist" }
]
}
}
}
}The user.artists array is the authoritative list of artists this account can act on. Save those IDs — most other operations need one.
2. Send the token
authorization: Bearer eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9...bash
curl https://api.artisthub.io/v1/graphql \
-H 'content-type: application/json' \
-H "authorization: Bearer $TOKEN" \
-d '{"query":"{ artists { id name } }"}'Token format
Tokens are JSON Web Tokens signed with RS512. The payload carries a standard Hasura claims object:
json
{
"https://hasura.io/jwt/claims": {
"x-hasura-allowed-roles": ["manager"],
"x-hasura-default-role": "manager",
"x-hasura-user-id": "8f2c1e7a-..."
}
}You do not need to parse the token — but if you do, x-hasura-user-id is the ID of the authenticated user, and it is what every row-level permission rule is evaluated against.
WARNING
Tokens are currently issued without an expiry claim, so a token stays valid until the account is disabled or its password changes. Treat it as a long-lived credential: store it in a secret manager, never commit it, never ship it in client-side code, and re-issue it by calling login again if you suspect it has leaked.
The refresh_token mutation exists in the schema but is not yet implemented — it returns { "token": null }. Call login again instead.
Roles
Every request runs under a role, which decides both which fields you can see and which rows within them.
| Role | How you get it | What it can do |
|---|---|---|
manager | Issued by login | Full read/write on the artists this user is attached to. This is the role for all API integrations. |
anonymous | No token | Public blog content only. All artist, link, and analytics fields are hidden. |
fan | Fan-facing flows | A fan's own presave records. Not used by API integrations. |
self | Internal | A narrow view of the user's own record. |
As a manager, your visibility is scoped by ownership. Reading links does not return every link in ArtistHub — it returns links whose artist you are attached to. The rule applied is effectively:
json
{ "artist": { "users": { "user_id": { "_eq": "X-Hasura-User-Id" } } } }This is enforced by the server, so you can safely query links with no where clause and receive only your own rows. It also means a query for a link you do not own returns an empty array rather than an error.
Errors
All authentication failures return HTTP 200 with an errors array.
invalid-jwt
The token is malformed, truncated, or not signed by ArtistHub.
json
{
"errors": [
{
"extensions": { "code": "invalid-jwt", "path": "$" },
"message": "Could not verify JWT: JWSError JWSInvalidSignature"
}
]
}Common causes: the Bearer prefix was omitted, the header was truncated, or a token from a different environment was used. Request a fresh token with login.
validation-failed — "field not found"
json
{
"errors": [
{
"extensions": { "code": "validation-failed", "path": "$.selectionSet.artists" },
"message": "field 'artists' not found in type: 'query_root'"
}
]
}This is the error you get when the authorization header is missing entirely. Without a token the request runs as anonymous, and the schema exposed to anonymous does not contain artists — so the field genuinely does not exist for that role. Check that the header is being sent before you go looking for a typo.
Invalid email or password.
json
{
"data": null,
"errors": [
{
"extensions": { "code": "INTERNAL_SERVER_ERROR" },
"message": "Invalid email or password.",
"path": ["login"]
}
]
}Returned by login for both an unknown email and a wrong password — the two cases are deliberately indistinguishable.
This account is disabled.
The account exists but has been deactivated. Contact support; no token will be issued.
Related mutations
| Mutation | Purpose |
|---|---|
reset_password_request(email: String!) | Emails a password-reset link. |
reset_password(token: String!, password: String!) | Completes a reset using the emailed token. |
update_password(current_password: String, new_password: String!, user_id: String) | Changes the password for a signed-in user. |
update_email_request(email: String!) | Starts an email change; sends a confirmation token. |
update_email(email: String!, email_token: String!) | Completes the email change. |
verify_email(email: String!, email_token: String!) | Verifies a newly registered address. |
get_current_user_data (query) | Re-reads the same user payload login returns, without re-authenticating. |
get_current_user_data is the cheapest way to check whether a stored token is still good.