sondahub

sondahub / Identity API

Identity: a fake user directory API

A company directory: people, groups and memberships — behind SCIM 2.0, SAML and OpenID Connect.

Three hundred people at a made-up company, Orbit Labs, in forty groups — departments, teams, roles, offices — with managers, titles and employee numbers. The same directory answers SCIM 2.0 at /scim/v2, signs people in over SAML at /saml/sso and fills OpenID Connect userinfo, so the person SCIM lists is the person who logs in. 1,797 records in all, free over REST, GraphQL, gRPC-Web, WebSocket and SSE, and through SCIM 2.0, SAML 2.0 and OpenID Connect — no key, no signup.

Connect

Base URL
https://api.sondahub.com/v1/identity
OpenAPI 3
https://api.sondahub.com/v1/identity/openapi.json
GraphQL
https://api.sondahub.com/v1/identity/graphql
gRPC-Web
https://api.sondahub.com/grpc/sondahub.identity.v1.IdentityService
.proto
https://api.sondahub.com/v1/identity/identity.proto
WebSocket
wss://api.sondahub.com/v1/identity/ws
SSE
https://api.sondahub.com/v1/identity/events
The data
users.json, groups.json, memberships.json

In Sonda: Import → From a URL with the OpenAPI address and the whole API lands as a project, one request per operation with example bodies. No keys, no headers to add. More on each protocol.

Writes stick for whoever carries them. POST, PUT, PATCH and DELETE are validated, run through the real rules and answered as a real server would, and the answer carries X-Sondahub-Session: send that token back and the next request sees your write. The server keeps nothing — without the token the world is the seed again. The examples on this page share one session until you reload. More on sessions.
The API describes itself
curl https://api.sondahub.com/v1/identity

Lists and filters

Every list answers { "data": [...], "meta": { "page", "limit", "total", "pages" } } with X-Total-Count and Link headers (next, prev, first, last). These options work on every collection and every nested route:

OptionMeaningExample
page, limitPaging, 1-based; limit 1–200, default 20. offset works too.?page=3&limit=50
sortComma list of fields, - for descending. Default id here.?sort=-id,id
field=valueEquals. Booleans as true/false, null for missing.?id=1
_ne _gt _gte _lt _lteNot equal and comparisons, on numbers, dates and strings.?id_gt=10
_likeContains, case-insensitive.?user_name_like=an
_inAny of a comma list.?id_in=1,2,3
_nulltrue: missing; false: present.?display_name_null=true
a.b=valueInside a JSON field, dotted.—
qSearch across the text fields.?q=alpine
fieldsOnly these fields back.?fields=id,user_name
expandEmbed related records.?expand=manager,reports
paging=cursor, cursorCursor paging: meta gains has_more, next_cursor and prev_cursor. starting_after / ending_before take an id, Stripe-style.?paging=cursor&limit=50
formatThe same answer as CSV, XML, YAML, NDJSON or MessagePack — or ask with Accept.?format=csv

A name that is not a field answers 400 and lists the fields. Writes answer 422 with one line per problem, 404 for a missing id, 405 with an Allow header for a verb a route does not take. Any request also takes chaos, a rate limit, an Idempotency-Key and problem+json errors, and any write a signed webhook.

users

People. user_name is unique and is the login everywhere (SAML, OIDC password grant: password "probe"); email is [email protected]. 300 records — the file, or the users API page with sample records.

POST and PATCH keep user_name and email unique (409 already_exists, naming the user who has it); display_name defaults to the two names and active to true. The same people sign in through SAML and OIDC and are provisioned over SCIM.
FieldTypeNotes
idread-onlyintAssigned by the server. Seed records keep their ids across restarts; records you create continue after the seed.
created_atread-onlydatetimeWhen the record was created (ISO 8601, UTC).
updated_atread-onlydatetimeWhen the record last changed.
user_namerequiredstringUnique login.
given_namerequiredstring
family_namerequiredstring
display_namestringDefaults to given and family name.
emailrequiredstring
titlestring
departmentstring
employee_numberstring
manager_idint → usersWho this person reports to; null for the CEO.
phonestring
localestring
timezonestring
officestring
activeboolfalse: deprovisioned — cannot sign in.
external_idstringThe id an upstream system (an HR tool, an IdP) knows this person by.
last_login_atdatetime

Relations: manager → one user through manager_id; reports → the users whose manager_id is this user; memberships → the memberships whose user_id is this user. Use ?expand=manager,reports,memberships to embed them, or the nested routes.

Endpoints

GET/v1/identity/usersA page, with every filter, sort, search, field and expand option.
POST/v1/identity/usersCreate one: 201 with the record, its id, a Location header and the session token that keeps it; 422 names each field that is wrong.
GET/v1/identity/users/{id}One record, with an ETag; If-None-Match earns a 304.
PATCH/v1/identity/users/{id}Change the fields you send.
PUT/v1/identity/users/{id}Replace the record; required fields must all be there.
DELETE/v1/identity/users/{id}Answers 200 with what was removed (a real server’s 204 lives at /v1/utils/status/204); gone for the session.
GET/v1/identity/users/{id}/managerThe user this record points at.
GET/v1/identity/users/{id}/reportsIts users, as a page with all the list options.
GET/v1/identity/users/{id}/membershipsIts memberships, as a page with all the list options.

Try it

List with a filter
curl "https://api.sondahub.com/v1/identity/users?expand=manager&limit=3"
One record
curl https://api.sondahub.com/v1/identity/users/1?expand=manager
Its reports
curl "https://api.sondahub.com/v1/identity/users/1/reports?limit=5"
Create
curl -i -X POST https://api.sondahub.com/v1/identity/users \
  -H "Content-Type: application/json" \
  -d '{"user_name":"ada.lovelace","given_name":"Ada","family_name":"Lovelace","email":"[email protected]","title":"Staff Engineer","department":"Engineering","employee_number":"E1042","locale":"en-US","timezone":"America/New_York","office":"New York"}'
Read it back: newest first (send the session token)
curl "https://api.sondahub.com/v1/identity/users?sort=-id&limit=3" \
  -H "X-Sondahub-Session: THE_TOKEN_FROM_THE_CREATE"
Change one field
curl -X PATCH https://api.sondahub.com/v1/identity/users/1 \
  -H "Content-Type: application/json" \
  -d '{"user_name":"Changed user name"}'
Delete the one you created
curl -X DELETE https://api.sondahub.com/v1/identity/users/301 \
  -H "X-Sondahub-Session: THE_TOKEN_FROM_THE_CREATE"

groups

Departments, teams, roles and offices. member_count follows the memberships. 40 records — the file, or the groups API page with sample records.

FieldTypeNotes
idread-onlyintAssigned by the server. Seed records keep their ids across restarts; records you create continue after the seed.
created_atread-onlydatetimeWhen the record was created (ISO 8601, UTC).
updated_atread-onlydatetimeWhen the record last changed.
display_namerequiredstring
slugstring
kindenumdepartment team role office list
descriptiontext
external_idstring
member_countread-onlyint

Relations: memberships → the memberships whose group_id is this group. Use ?expand=memberships to embed them, or the nested routes.

Endpoints

GET/v1/identity/groupsA page, with every filter, sort, search, field and expand option.
POST/v1/identity/groupsCreate one: 201 with the record, its id, a Location header and the session token that keeps it; 422 names each field that is wrong.
GET/v1/identity/groups/{id}One record, with an ETag; If-None-Match earns a 304.
PATCH/v1/identity/groups/{id}Change the fields you send.
PUT/v1/identity/groups/{id}Replace the record; required fields must all be there.
DELETE/v1/identity/groups/{id}Answers 200 with what was removed (a real server’s 204 lives at /v1/utils/status/204); gone for the session.
GET/v1/identity/groups/{id}/membershipsIts memberships, as a page with all the list options.

Try it

List with a filter
curl "https://api.sondahub.com/v1/identity/groups?kind=team&limit=3"
One record
curl https://api.sondahub.com/v1/identity/groups/1
Its memberships
curl "https://api.sondahub.com/v1/identity/groups/1/memberships?limit=5"
Create
curl -i -X POST https://api.sondahub.com/v1/identity/groups \
  -H "Content-Type: application/json" \
  -d '{"display_name":"Platform Team","slug":"platform-team","kind":"team"}'
Read it back: newest first (send the session token)
curl "https://api.sondahub.com/v1/identity/groups?sort=-id&limit=3" \
  -H "X-Sondahub-Session: THE_TOKEN_FROM_THE_CREATE"
Change one field
curl -X PATCH https://api.sondahub.com/v1/identity/groups/1 \
  -H "Content-Type: application/json" \
  -d '{"kind":"team"}'
Delete the one you created
curl -X DELETE https://api.sondahub.com/v1/identity/groups/41 \
  -H "X-Sondahub-Session: THE_TOKEN_FROM_THE_CREATE"

memberships

A person in a group. POST one to add someone (409 if they are in it already); DELETE it to take them out. 1,457 records — the file, or the memberships API page with sample records.

POST refuses a second membership of the same user in the same group (409 already_member), defaults role to member and bumps the group’s member_count; DELETE lowers it.
FieldTypeNotes
idread-onlyintAssigned by the server. Seed records keep their ids across restarts; records you create continue after the seed.
created_atread-onlydatetimeWhen the record was created (ISO 8601, UTC).
updated_atread-onlydatetimeWhen the record last changed.
user_idrequiredint → users
group_idrequiredint → groups
roleenummember owner

Relations: user → one user through user_id; group → one group through group_id. Use ?expand=user,group to embed them, or the nested routes.

Endpoints

GET/v1/identity/membershipsA page, with every filter, sort, search, field and expand option.
POST/v1/identity/membershipsCreate one: 201 with the record, its id, a Location header and the session token that keeps it; 422 names each field that is wrong.
GET/v1/identity/memberships/{id}One record, with an ETag; If-None-Match earns a 304.
PATCH/v1/identity/memberships/{id}Change the fields you send.
PUT/v1/identity/memberships/{id}Replace the record; required fields must all be there.
DELETE/v1/identity/memberships/{id}Answers 200 with what was removed (a real server’s 204 lives at /v1/utils/status/204); gone for the session.
GET/v1/identity/memberships/{id}/userThe user this record points at.
GET/v1/identity/memberships/{id}/groupThe group this record points at.

Try it

List with a filter
curl "https://api.sondahub.com/v1/identity/memberships?role=owner&expand=user&limit=3"
One record
curl https://api.sondahub.com/v1/identity/memberships/1?expand=user
Create
curl -i -X POST https://api.sondahub.com/v1/identity/memberships \
  -H "Content-Type: application/json" \
  -d '{"user_id":1,"group_id":1,"role":"member"}'
Read it back: newest first (send the session token)
curl "https://api.sondahub.com/v1/identity/memberships?sort=-id&limit=3" \
  -H "X-Sondahub-Session: THE_TOKEN_FROM_THE_CREATE"
Change one field
curl -X PATCH https://api.sondahub.com/v1/identity/memberships/1 \
  -H "Content-Type: application/json" \
  -d '{"role":"owner"}'
Delete the one you created
curl -X DELETE https://api.sondahub.com/v1/identity/memberships/1458 \
  -H "X-Sondahub-Session: THE_TOKEN_FROM_THE_CREATE"

WebSocket and SSE

The same stream two ways: the world's own activity, one tick a second, generated for your connection alone. Both push JSON text messages; SSE names each one with event: and numbers it with id:. ?topics=a,b narrows either.

TopicWhat arrivesHow often
directorySomeone joining, leaving, changing team, or being deactivated or reactivated.~6 s
loginsA sign-in: who, how (SAML, OIDC, password), from where, and whether it worked.2 s
WebSocket
wss://api.sondahub.com/v1/identity/ws?topics=directory

> {"type":"hello","api":"identity","topics":[…],"subscribed":[…]}
> {"type":"event","topic":"directory","api":"identity","ts":"…","data":{…}}
< {"type":"subscribe","topics":["directory"]}   # narrow to some topics
< {"type":"ping"}                            # → {"type":"pong"}
< anything else                             # → echoed back as {"type":"echo"}
Server-Sent Events
curl -N "https://api.sondahub.com/v1/identity/events?topics=directory"

retry: 3000
id: 1
event: directory
data: {"type":"event","topic":"directory",…}

GraphQL

One endpoint, https://api.sondahub.com/v1/identity/graphql: POST {"query", "variables"} or GET ?query=. Introspection is on, so Sonda's GraphQL mode loads the schema; the SDL is a click away. Every collection is a paged query with the same filter, sort and q options as REST (operators as suffixes: price_lt), a by-id query, relation fields both ways, and create, update, replace and delete mutations — simulated like every write, with the note in extensions.

A query
curl https://api.sondahub.com/v1/identity/graphql -H "Content-Type: application/json" -d '{"query": "{ users(limit: 3, sort: \"-id\") { total data { id user_name given_name family_name manager { user_name } reports(limit: 2) { id } } } }"}'
{
  users(limit: 3, sort: "-id") {
    total
    data {
      id user_name given_name family_name
      manager { user_name }
      reports(limit: 2) { id }
    }
  }
}

gRPC-Web and Connect

The API is also the protobuf service sondahub.identity.v1.IdentityService, generated from the same registry as REST, so the wire and the docs cannot disagree. The .proto file is one click away; feed it to protoc, buf or your codegen. Calls go to https://api.sondahub.com/grpc/sondahub.identity.v1.IdentityService/{Method} over gRPC-Web (binary or text) or Connect (JSON or binary; GET for reads). Writes keep to the session like every other write. More on gRPC-Web.

MethodWhat
ListUsersA page of users: page, limit, sort, q and filter (field → value, with the REST operator suffixes: price_lt → 20).
GetUserOne user by id.
CreateUserCreate a user (simulated, kept in your session token). id and the timestamps are set by the server.
UpdateUserChange the fields set in user (simulated).
DeleteUserDelete one (simulated); answers what was removed.
ListGroupsA page of groups: page, limit, sort, q and filter (field → value, with the REST operator suffixes: price_lt → 20).
ListMembershipsA page of memberships: page, limit, sort, q and filter (field → value, with the REST operator suffixes: price_lt → 20).
Watch
server stream
The API's live activity as a server stream, each topic at its own pace: directory (every ~6 s), logins (every 2 s). max_events ends the stream (default 10, at most 120).

Every collection has List, Get, Create, Update and Delete; the table shows them for users and the lists for the rest.

GetUser, Connect over GET
curl "https://api.sondahub.com/grpc/sondahub.identity.v1.IdentityService/GetUser?encoding=json&message=%7B%22id%22%3A1%7D"
ListUsers, Connect JSON
curl -X POST https://api.sondahub.com/grpc/sondahub.identity.v1.IdentityService/ListUsers \
  -H "Content-Type: application/json" \
  -d '{"limit": 3, "sort": "-id"}'

SCIM, SAML and OpenID Connect

These people are not only rows. The same directory stands behind four doors, so a provisioning or sign-in integration can be tested end to end against one company:

SCIM 2.0https://api.sondahub.com/scim/v2Users and Groups with filters, PATCH, Bulk and discovery; Bearer sonda-scim-token.
SAML IdPhttps://api.sondahub.com/saml/metadataSigns any directory user into your service provider; password probe.
SAML test SPhttps://api.sondahub.com/saml/spChecks what your IdP sends, signature by signature.
OpenID Connecthttps://api.sondahub.com/.well-known/openid-configurationSign in as any user (user_name / probe); id tokens carry their groups.
The directory over SCIM
curl -H "Authorization: Bearer sonda-scim-token" \
  "https://api.sondahub.com/scim/v2/Users?filter=userName%20sw%20%22clara%22"