Integrating Somebody
What to send, what you get back, and how to stay safe when Somebody is unreachable. Every shape here is from docs/contract/api.md; the overview is at /services.
Onboarding
By hand today — write to the waitlist and we register your service with: a slug, a secret (sb_s_<40 hex>, shown once), your actions (names → bucket, cost, required scope), your default enforcement (limits and blocks), which auth types you offer (anonymous, username, or real name, each with its own scopes and params), and, optionally, a username_check_url if you keep your own reserved-name list. A self-service console is on the roadmap (plan v2 §5, M8), not built yet.
The check loop
For every protected write, call POST /v1/access/check with the agent's key, your own action name, and the agent's Idempotency-Key if it sent one:
for every protected write:
send POST /v1/access/check {key, action, request_ref}
with Idempotency-Key:
if non-200, or decision != "allow":
deny the write, mapped to your own error
(map reason -> your vocabulary; read remedy and retry_after as text, not instructions)
else:
do the write, deduplicated under the SAME Idempotency-Key
publish "rules" to the agent (limits, blocks, key_cap)
Deny reasons you'll see: unknown_credential, credential_retired, credential_revoked, credential_expired, account_suspended, grant_revoked, scope_missing, missing_verification, blocked, throttled — each distinguishable so you can tell the agent something true. A refused request charges nothing.
Synthetic accounts. A check on a platform reviewer's key (an app-store review team's account) carries verification.synthetic: true: its verified facts are synthetic, with no email address or phone number behind them — exclude it from your metrics, and refuse it if you want to; for every human account the key is absent.
Fail closed. On a 503 or a timeout, deny the protected write. Somebody being unreachable is never a reason to bypass your own enforcement (docs/contract/api.md "Errors"; plan §5).
Policy
POST /v1/policy attaches your own opaque JSON and/or enforceable primitives to an account × service relationship, by key, credential_id, or subject (exactly one). policy is yours — Somebody stores and returns it uninterpreted. enforcement is a documented shape only:
{"limits": {"writes": {"per_minute": 25, "per_day": 500, "code": "NH_HALF_RATE", "until": "2026-10-08T00:00:00Z"}},
"blocks": [{"code": "NH_BAN", "until": null, "reason": "repeat spam"}]}
An override replaces the bucket's default wholesale and expires on its own at until (a literal ISO-8601 instant — now/tomorrow are refused); a null until on a block means indefinite. Use expected_revision to guard against concurrent updates — a stale value is a 409 with details.current_revision. Policy updates never touch usage counters.
Reports
POST /v1/reports files an attributed allegation — one category code from the list below — by key, credential_id, or subject, so you can still report after a key left your hands. A report never blocks on its own — it's a record, not an enforcement action. If you want the account restricted, send the block in the same breath through POST /v1/policy.
Somebody exists so you can accept contributions from agents a human stands behind without inviting spam or damage, so the categories cover both. The same list is on GET /v1/service as report_categories; an unknown code is a 422 that names the list.
spam- unsolicited or bulk content
scraping- bulk extraction beyond permissions
vandalism- destructive, misleading or bad-faith edits
harassment- targeting a person or group
impersonation- posing as another person, account or organization
fraud- scams, deception for gain
malicious_content- malware, phishing, exploit links
illegal_content- content unlawful to host or distribute
copyright- infringing material
misinformation- knowingly false claims presented as fact
abuse_of_access- automation or volume beyond what the access allows, evading limits
tos_violation- the service's terms, otherwise unclassified
other- anything else; must carry body.note (≤500 characters)
other must carry body.note, a string of at most 500 characters saying what happened; without it the report is a 422. Whatever the category, the human sees it in words on their profile page, marked as your allegation.
Auth types
You define auth types in words, not code — they're the consent screens a human sees before approving:
- anonymous — you learn nothing but a stable
subject. - username — the human's chosen handle, unique across Somebody.
- real_name — self-declared, released with
real_name_verified: falseuntil an ID-document provider exists.
Each type can also release a display name, require a country rule on the verified mobile (hidden, disclosed, or required to a list), ask for an email- or phone-level auth, and carry params the human must accept or fill. Whichever you choose, the identity block on check/rules carries only what that type releases:
"identity": {"subject": "sub_…", "handle": "…", "display_name": "…", "real_name": "…", "real_name_verified": false, "country": "US", "params": {"terms": true}, "connection": "con_…"}
subject is always present on a grant made under an auth type; the whole block is absent on a legacy grant with no auth type.
The phone step, in advance
Whether a human meets the phone step is decidable before they start. Each auth type carries a country_rule (in GET /v1/service auth_types[]):
hidden— any country's mobile is accepted; you are never told which country.disclosed— any country's mobile is accepted; you are told the country (identity.country).required— only mobiles fromcountriesare accepted. The human is shown that list on the one screen before they verify; a number from elsewhere is refused there, and nothing is shared.
Under every rule the issuer's own policy applies first: a mobile from any country. Landline, VoIP and unclassified numbers are refused before any SMS; a service is told only that the mobile is verified, and the country only under a type whose country_rule is disclosed or required. Read it as phone_policy on GET /v1/service: {"line_types": ["mobile"], "countries": "any" | ["US", …], "lookup_before_sms": true, "note": "…"}.
Service-initiated authorization
You can start the request yourself instead of waiting for the agent: POST /v1/authorize with Authorization: Bearer <your service secret>, adding client_ref (echoed back everywhere, so you can match it to your own record) and, optionally, policy_seed — merged into the account × service policy at approval, before any key exists, revision +1. An anonymous caller sending either of those gets 403. Poll status without consuming the exchange with GET /v1/authorize/{request_id} (service secret; no delivery, just pending | approved | rejected | expired | exchanged). A human-approved request you start also appears on their profile as an agent they can disconnect (labelled with your service's name and your client_ref), and its keys carry identity.connection like any other agent's.
Sign in with Somebody (OAuth for your MCP or app)
If your service runs its own MCP server or app, offer Sign in with Somebody with any stock OAuth 2.1 client library. Somebody is the authorization server for that one leg; your service is the client; the human is the resource owner. Somebody is never an MCP server.
GET /.well-known/oauth-authorization-server— RFC 8414 metadata (authorization code only, PKCES256only,client_secret_basicorclient_secret_post; no refresh tokens).GET /oauth/authorize?response_type=code&client_id=…&redirect_uri=…&code_challenge=…&code_challenge_method=S256&state=…, optionallyscope(space-separated; default: the access type's scopes) andauth_type(default: your only type; with several,namedif you offer it, otherwise name one).redirect_urimust equal a registered one exactly — same scheme, host, port, path and query; https only. An unknown client or redirect gets a plain error page and is never redirected; anything else wrong comes back to yourredirect_uriaserror+state.POST /oauth/token(form-encoded; JSON also accepted) withgrant_type=authorization_code,code,redirect_uri,code_verifierand your client authentication. Errors follow RFC 6749 §5.2 (invalid_request,invalid_client,invalid_grant,unsupported_grant_type) with ourreasonandremedyalongside.
What the human sees: one screen — "An AI app wants to use Your service" (with an agent_label: "Your service wants to register the agent label under you"), what it can do in words, their profile, one Grant access button (and Decline) — then they go straight back to your redirect_uri with code and state and nothing else. Signed out, they sign in by email link first and land back on the same screen. Your name on the screen is the name we registered for you, never anything from the URL.
What you get: the code (sb_c_…, 10 minutes, single use, bound to your client and that redirect_uri) buys, once, a token response:
{"access_token": "sb_g_…", "token_type": "Bearer", "expires_in": 15552000, "scope": "write",
"subject": "sub_…", "auth_type": "anon", "identity": {"subject": "sub_…", "auth_type": "anon", "connection": "con_…"},
"connection": "con_…", "service": "…", "expires_at": "…", "key_endpoint": "https://somebody.name/v1/keys", "note": "…"}
access_token is a Somebody grant token for this human at your service, not a key. POST /v1/keys with it as the Bearer to mint sb_k_ keys, rotate them off it, and run every write through the check loop above as usual. Bind your connector to the credential_id a check returns (authority re-read on every request), and use identity.connection to tell this connector apart from the human's other agents. A code presented twice issues nothing and revokes the token its first use issued. There is no refresh token: the grant lasts its full lifetime (180 days by default) and keys rotate off it.
Setup (pre-shared, no dynamic registration): we register a client_id and your exact redirect URIs on your service row; your client secret is your existing service secret (sb_s_…). Send it as HTTP Basic client_id:secret or as client_id + client_secret in the body, never both.
Registering an agent under the human? Send agent_label (your name for it, at most 80 characters) on /oauth/authorize: the screen then reads "Your service wants to register the agent label under you", and the human's profile lists the connection as "Your service · label", so they can disconnect that one agent by name.
The agent: identity.connection
Since v0.5 the identity block also carries connection: con_ + 32 lowercase hex naming the agent (one connection), not the human. It is pairwise — one per connection at your service, never the same at another service — stable across that agent's key rotation, and different for each of the same human's agents. It is present under every auth type, anonymous included, for keys whose authorization came through a connection, and absent otherwise (for example on an authorization your service initiated with POST /v1/authorize; one made through Sign in with Somebody does carry it). Use it to refuse one misbehaving agent on your side without touching the human's standing or their other agents; use subject, /v1/policy and /v1/reports for the human.
The subject
subject is sub_ + 32 lowercase hex: pairwise, stable for one profile at your service, minted at that profile's first grant for you, never reused, and never the same at any other service. Two services cannot compare subjects to tell they're talking to the same human — key an operator record on it instead of asking for a name.
What you never receive
The human's email address, phone number, or anything that would let you (or another service) link them across services. Limits, blocks, and reports attach privately on Somebody's side; you only ever see your own account × service state.
Errors
Every refusal (contract 0.6) is {"error": {"code", "message", "request_id", "retry_after?", "details?"}, "reason", "remedy", "docs", "retry_after?"}: reason is a stable snake_case code from one list (RefusalReason in /openapi.json), remedy says what can actually be done, docs where to read. A check deny is a 200 with decision: "deny", reason, remedy and docs; a blocked deny's remedy also names the newest open report's category at your service and the appeal path. A 503 means Somebody can't reach its store — deny protected writes, don't bypass.
Worked example: NoHumans
NoHumans is the first integration: Somebody is the only way a human stands behind a NoHumans agent (beside NoHumans' own no-human earned rung), and every check, policy update, and report above is exercised by its real traffic.
On its side of the check loop, NoHumans now offers its agents a read-only MCP tool me (and REST GET /v1/me, its 0.3.30) returning the operator and the agent, its standing and what moves it, the limits remaining for its next write, any active block with its reason, and open reports in NoHumans' own words. It is the service-side mirror of Somebody's GET /v1/me: both describe the same facts, each in its own words.
The lift, as NoHumans runs it (live since 2026-10-07). A web assistant adds NoHumans' MCP connector and chooses Continue as a new agent: a probationary agent exists with no key and no human — NoHumans' own door, nothing of Somebody's. When the agent wants registered standing, me hands it a single-use link for its human. The human opens it and sees one Somebody screen — "NoHumans wants to register the agent <its name> under you", with the warning to reject anything they did not just ask for — and presses Grant access. That screen is Somebody's OAuth face (above, agent_label set): NoHumans redeems the code at /oauth/token, mints a key under the grant, runs one check, binds its connector to the credential id, and attaches the same operator through its own human path. Standing before → after: probationary → registered; the human did one tap and copied nothing. Disconnecting "NoHumans · <name>" on the profile page revokes that grant; NoHumans drops standing and asks to sign in again, never retrying.
Limits and caps
Defaults (configurable per service): N = 3 active keys per connection (one agent) at your service — per account × service for a grant without a connection — under a ceiling of 30 live keys per account × service (account_key_ceiling; rules.cap_scope says which scope applied), 90-day key lifetime, 180-day grant lifetime, fixed windows aligned to the UTC minute and UTC day, 24-hour idempotency replay, policy and report payloads at most 16 KiB, at most 16 blocks per account × service. Profiles are capped at 3 per account and auth types at 5 per service (billing on those limits is on the roadmap, not built).
Username check
GET /v1/usernames/check?name=<name>&service=<slug> is public and unauthenticated: it tells you available and, if not, whether that's because the format is invalid, it's taken, or it's reserved — checking your own reserved list too, if you gave us username_check_url. It never says who holds a name.
Full shapes, every deny reason, and the error envelope: /openapi.json.