# Silicon Developer docs (full) You have ended up on the full Silicon Developer docs. This one file holds everything about building in the Silicon ecosystem: Silicon Apps, where apps are made, published, found and installed, and Silicon Accounts, the account and sign-in layer every Carbon, Silicon and app in the ecosystem shares. If you read only this file, you should be able to decide whether we fit what you are building, and then build it correctly. The short version lives at https://developers.teamofsilicons.com/llms.txt. Every page of the docs is also plain Markdown at its own link, listed at the end of the short version. What's in here, in order (every chapter is an `#` heading you can search for): - At a glance: what we are, who we are for, what we don't do yet. - The basics: how to read this file, the glossary, where things live, installing the CLIs. - Quick starts and recipes: a Silicon getting started, an app getting started, and the common jobs step by step. - The understanding: Silicon Apps, Silicon Accounts, why build on us, and an honest comparison with the alternatives. - FAQ. - The reference: every command, endpoint, field, event, error and limit, chapter by chapter. # At a glance ## What we are - **Silicon Accounts** gives every Carbon (a person) and every Silicon (an agent) one personal account, and does the whole sign-in for any app: hosted pages, email and phone codes, Google and Apple, sign up, the app's user base, webhooks, and proofs between apps. It is a standard OpenID Connect provider at `https://accounts.teamofsilicons.com`. - **Silicon Apps** is a store and distribution system for command-line apps: authors publish native packages for up to nine targets, and anyone installs them with one command and gets every update automatically. - Both are made for agents first. Every command has `--help` and `--json`, every error says exactly what went wrong and how to fix it, and everything the sites do is also an API, an MCP server and plain HTML. ## Who we are for We fit best when: - your app will be used by agents (Silicons) as well as people, especially CLIs and services that agents run on their own; - you want agents to have their own accounts, with an accountable Carbon behind each one, instead of borrowing a person's login or a shared API key; - your app wants to work with other apps in the ecosystem, as itself (App verification) or for a user (User verification); - you want to ship a CLI to macOS, Linux and Windows and have it installed and kept up to date for you. ## What we don't do yet Read this before you choose us, so nothing surprises you later: - Carbons sign in with Google, Apple, an email code or a phone code. There are no passwords, no passkeys, no multi-factor authentication, and no SAML or enterprise SSO yet. - There are no organizations or Teams: every account is personal. If you bill companies, you model the company in your own app. - Passwords are never imported. When you move existing users in, they sign in the first time with an email code (or Google or Apple) on the address you imported, and keep that account from then on. - Your app can't define its own OAuth scopes for third-party clients, and we don't run Dynamic Client Registration, so chat assistants that connect to MCP servers through standard OAuth can't use Silicon Accounts to reach your app yet. Silicons connect to your app with an SLT instead. - Signing in with Silicon Accounts works at apps that integrate it, and a Silicon's identity tokens work at cloud providers that accept OIDC federation (AWS, Google Cloud, Microsoft Entra). For other outside services (a code host, a ticket tracker), your agent still uses the credentials those services give it. - Silicon Apps packages are native CLI builds per target. A library for other code belongs in your language's registry. - Silicon Apps always keeps installed apps up to date; there is no version pinning on a machine that runs the updater. - The sign-in pages always show `Powered by Silicon Accounts`. Sign-in on your own domain is a manual review today. - Tokens are signed with EdDSA (Ed25519) only. ## Cost, status and openness - There are no paid plans today: everything in this file is free to use. - We have not published an SLA. Health is public at `https://accounts.teamofsilicons.com/readyz` and `https://apps.teamofsilicons.com/health`. - We are new: both services launched in October 2026. On 9 October 2026 the store held our own two CLIs (`silicon-apps` and `silicon-accounts`) and no third-party apps yet. The live numbers are always at `https://apps.teamofsilicons.com/v1/apps`. - Upload validation runs on `linux-x86_64` today, so third-party apps can publish packages for that target now; the other eight targets open as their validation workers come online. `silicon-apps targets` (or `GET /v1/targets`) shows the live list. - Hosting: AWS in `us-east-2`. Each service's database is backed up every hour and backups are kept for 14 days. - The source of both services is public on GitHub: https://github.com/teamofsilicons/silicon-apps (MIT licence) and https://github.com/teamofsilicons/silicon-accounts. - You can always leave: we are standard OIDC, your app stores uuids it can keep, and you can read your whole user base at any time with `GET /v1/apps/{app_id}/users` or `silicon-accounts app users --json`. # How to read this file The first chapters are the understanding: what everything is, how the pieces fit, and why we built them the way we did. Read them top to bottom once. The chapters after that are the reference: every command, endpoint, field, event, error and limit, grouped by topic. Jump to the one you need. Each chapter ends with a `More:` line pointing at the docs pages it came from, in case you want the long version with every example. A few rules hold everywhere, so you don't have to look them up again: - Store an account's `uuid`, never its c:id or si:id. The uuid never changes; the ids can. - Errors from both APIs look like `{"error": {"code", "message", "hint", "details"}}`. The `code` is the contract, the `message` and `hint` are for reading, and `hint` or `details` may be missing when there is nothing to add. The OAuth token, revoke and introspect endpoints answer in the RFC 6749 shape (`error`, `error_description`) instead, because every OAuth library expects it. Proof verification always answers `200` with `valid: true` or `valid: false`. - Retries are safe with an `Idempotency-Key` header. Silicon Apps requires one (8 to 200 characters) on every write. Silicon Accounts accepts one (1 to 200 characters, remembered for 24 hours) on every write you might retry, and tells you in each endpoint's reference. - Too many requests get `429` with a `Retry-After` header. Wait that many seconds. - Times are RFC 3339 in UTC. - App ids are lowercase letters, digits, `-` and `_`. Silicon Apps creates them, 3 to 30 characters long. # Glossary `Carbon` - A person. Every person's account is a Carbon, shown as `c:{handle}`, for example `c:shubham`. `Silicon` - An agent. Every agent's account is a Silicon, shown as `si:{handle}`, for example `si:head_of_growth`. `Custodian` - The Carbon responsible for a Silicon. Every Silicon always has exactly one. `uuid` - The permanent id of an account, for example `a8K`. It never changes and is never reused. (It's short and made of letters and digits, not the 36-character UUID format; we kept the name because it does the same job.) `c:id` / `si:id` - The public id people see and type. It can change; the old one stays reserved for 10 days. `App` - Anything published on Silicon Apps. Every app in the store is a CLI, and can also have a website and mobile apps. Any app, including a website with no CLI, can sign its users in with Silicon Accounts as soon as it's created. `app_id` - The permanent id of an app, for example `briefcase`. `Author` - A Carbon or Silicon who owns an app. An app can have many authors and they are all equal; any of them can manage the app's sign-in. `Membership` - An account's relationship with an app, written `{app_id}:{uuid}`, for example `briefcase:a8K`. `STK` - A Silicon's password, for example `stk-3f9a1c7e5b2d`. Your app never sees it. `SLT` - A short-lived token a signed-in Silicon asks us for, made for exactly one app. It works once and expires after two minutes. The app exchanges it for its own session. `App verification` - A proof that a request comes from a given app (`/v1/proofs/app-verification`). `User verification` - A proof that an app may act for a given account at another app (`/v1/proofs/user-verification`). `Account verification request` - Not a proof: a request to the Team to review your account so sign-in can run on your own domain. `Target` - One OS and architecture an app's CLI is built for, for example `macos-aarch64`. ## The same words, in standard terms | Here | Closest standard term | | --- | --- | | Carbon | a user account (a person) | | Silicon | a service account or agent identity, owned by exactly one person (its custodian) | | Custodian | the accountable owner of a service account | | STK | a service account's password; keys (Ed25519) can replace it | | SLT | a one-time, audience-bound token exchanged with a custom OAuth grant (`urn:silicon:params:oauth:grant-type:slt`) | | uuid | the stable subject identifier (`sub`) | | c:id / si:id | a changeable username (`preferred_username`) | | Membership | a user's link to one client app | | App verification | client authentication between two services, scoped to one audience | | User verification | a delegated, audience-bound grant for one user (like token exchange for one resource) | | Account verification request | a manual review for custom-domain sign-in | # Where things live - `developers.teamofsilicons.com` - where you create apps, publish them and set up their sign-in. These docs live here too. - `apps.teamofsilicons.com` - the store, where anyone finds and installs apps. It's also the Apps API (`/v1/...`). - `accounts.teamofsilicons.com` - where every Carbon manages their own account and the Silicons they look after. It's also the Accounts API (`/v1/...`) and the OpenID Connect issuer. - `silicon-apps` - the Apps CLI. `silicon-accounts` - the Accounts CLI. Both are built on Rust crates you can use directly (`silicon-apps-client`, `silicon-accounts-client` on crates.io). - Machine-readable entry points on every site: `/llms.txt`, `/llms-full.txt`, `/openapi.json`, `/.well-known/agent.json`, `/mcp`, `/sitemap.xml`. # Install the CLIs On macOS or Linux, this installs Silicon Apps and then Silicon Accounts through it: ```sh curl -fsSL https://apps.teamofsilicons.com/install.sh -o install-apps.sh && bash install-apps.sh --server https://apps.teamofsilicons.com && export PATH="${SILICON_HOME:-$HOME}/.apps/bin:$PATH" && silicon-apps --home "${SILICON_HOME:-$HOME}" --server https://apps.teamofsilicons.com install silicon-accounts ``` On Windows PowerShell: ```powershell $ErrorActionPreference = 'Stop' Invoke-WebRequest -UseBasicParsing https://apps.teamofsilicons.com/install.ps1 -OutFile install-apps.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\install-apps.ps1 -Server https://apps.teamofsilicons.com if ($LASTEXITCODE -ne 0) { throw 'Silicon Apps installation failed' } $siliconHome = if ($env:SILICON_HOME) { $env:SILICON_HOME } else { $env:USERPROFILE } $env:Path = (Join-Path $siliconHome '.apps\bin') + ';' + $env:Path silicon-apps --home $siliconHome --server https://apps.teamofsilicons.com install silicon-accounts if ($LASTEXITCODE -ne 0) { throw 'Silicon Accounts installation failed' } ``` Silicon Apps keeps both up to date from then on. Neither runs an updater of its own. Both CLIs are also on crates.io (`cargo install silicon-apps-cli`, `cargo install silicon-accounts-cli`) if you'd rather build them. # Quick start for a Silicon You as a silicon can register yourself, use every app in the ecosystem and build your own. Three steps: 1. Get your account. Either your carbon creates it for you while signed in (`silicon-accounts silicon create --id si:{your-id}`), which makes them your custodian and lets you sign in right away, or you create it yourself and name your carbon as your custodian. They get an email and accept once, within 14 days, and then you act on your own. ```sh silicon-accounts silicon create --self-create --id si:{your-id} --custodian {your-carbon-email@example.com} --wait ``` The STK is printed exactly once. Save it. `--wait` holds until your carbon decides and then signs you in. 2. Find and install apps: ```sh silicon-apps search terminal silicon-apps install ring ring --help ``` 3. Sign into an app: ask us for an SLT for that app and hand it over. The app never sees your STK. ```sh silicon-accounts login --app ring ``` From there, `silicon-apps --help` and `silicon-accounts --help` are trees you can walk: every command says what it's for, what it's used with and its flags. # Quick start for an app 1. Create the app on https://developers.teamofsilicons.com or with `silicon-apps create {app_id} --name {Name}`. You get its `app_id` and `app_secret`; the secret is shown once. From this moment it can sign users in, even if it is only a website and never publishes a CLI. 2. Add sign-in. Send Carbons to our hosted pages (or drop in the iframe or the SDK snippet), get them back on your redirect URL with a code, and exchange it on your server for tokens. Silicons hand you an SLT instead, which you exchange the same way. Any OpenID Connect library works too, with `https://accounts.teamofsilicons.com` as the issuer. 3. Publish, if your app has a CLI. Pack it with an `apps.yaml` for every target you support, upload it, and release. Your package has to answer `--help`, `accounts --json` and `login status --json` on every target. There is no review: it's live the moment you publish. 4. Stay in sync. Pick the account updates you want on your webhook (or stream them), and use App verification and User verification when your app talks to other apps. # Recipes The common jobs, start to finish. Each one links to the chapter with every detail. ## Sign people into your CLI Your app's own CLI often runs where there's no browser: on a server, in a container, over SSH. It can still sign a Carbon in with a short code they approve on any device. Your CLI needs no secret, because a secret shipped inside a CLI isn't secret. 1. Turn it on once, as the app or one of its authors: ```sh curl -s -X PATCH https://accounts.teamofsilicons.com/v1/apps/$APP_ID/signin-config -u "$APP_ID:$APP_SECRET" \ -H 'Content-Type: application/json' -d '{"device_flow": true}' ``` 2. Your CLI starts a sign-in with just your app_id, and shows the code: ```sh curl -s -X POST https://accounts.teamofsilicons.com/v1/device/authorize \ -d client_id="$APP_ID" -d scope=email -d client_label="notes CLI on build-box" ``` Print "Open https://accounts.teamofsilicons.com/device and enter MVHB-KQAW". The Carbon sees your app's name, branding and what it will share before they approve. 3. Poll `POST /v1/oauth/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code` every `interval` seconds until they decide. You get the same access and refresh tokens as any sign-in. Desktop apps and CLIs that can open a browser can use the normal code flow instead, as a public client (`"public_client": true`): PKCE is required and no secret is used. Silicons don't use the device flow. They hand your CLI an SLT (`silicon-accounts login --app {app_id}`), which your CLI or your server exchanges. ## Run a Silicon in CI with no stored secret at all In GitHub Actions or GitLab CI, the job already has an OIDC token that says which repository and branch it is. Your carbon tells us to trust that, once, and the job signs in as the Silicon with nothing stored: ```sh # once, as the custodian: trust main of one repository silicon-accounts silicon trust add si:deploy-bot --github yourorg/deployd --claim ref=refs/heads/main --name deploys ``` ```yaml # in the workflow permissions: id-token: write steps: - run: silicon-accounts login --silicon si:deploy-bot --federated --github-actions - run: silicon-accounts login --app ring -q > slt.txt ``` The sign-in lasts as long as the job's token (30 minutes to 12 hours), shows up in the Silicon's history as `federated`, and can't add keys or trusts, so a compromised job can't leave a way back in. The full walkthrough, with GitLab, is in `# Running a Silicon in CI and the cloud`. ## Use a Silicon's identity at your cloud provider Instead of a cloud access key in an environment variable, the Silicon asks us for a short identity token for your cloud, and the cloud trusts our issuer: ```sh silicon-accounts silicon audiences allow si:deploy-bot sts.amazonaws.com # once, as the custodian TOKEN=$(silicon-accounts token identity --audience sts.amazonaws.com) aws sts assume-role-with-web-identity --role-arn arn:aws:iam::123456789012:role/deploy-bot \ --role-session-name deploy-bot --web-identity-token "$TOKEN" ``` The token is RS256, lasts 5 minutes by default (at most an hour), names the Silicon (`sub` is its uuid) and its custodian, and only goes to audiences your carbon allowed. AWS, Google Cloud and Microsoft Entra all accept it through their OIDC federation setup (`# Running a Silicon in CI and the cloud`). ## Run a Silicon unattended, with no password on the machine For servers and scheduled jobs outside CI. Nothing secret that could be replayed ever leaves the machine. 1. Your carbon creates the Silicon (it's active at once, no waiting): ```sh silicon-accounts silicon create --id si:deploy-bot ``` 2. Register a key for the machine. `--generate` makes an Ed25519 key and saves the private half readable only by you; an existing OpenSSH Ed25519 key works too. ```sh silicon-accounts silicon keys add si:deploy-bot --generate ~/.accounts/deploy-bot.key --name ci-runner ``` 3. On the machine, sign in with the key, then ask for an SLT for the app you need: ```sh export ACCOUNTS_SILICON=si:deploy-bot ACCOUNTS_SILICON_KEY=~/.accounts/deploy-bot.key silicon-accounts login SLT=$(silicon-accounts login --app ring -q) ``` Each sign-in sends a signed assertion that works once and expires within 5 minutes. 4. Limit what it can reach. Your carbon gives it an allow-list of apps, and can remove its access to any one app at any time: ```sh silicon-accounts silicon apps allow si:deploy-bot ring briefcase silicon-accounts silicon apps list si:deploy-bot silicon-accounts silicon apps remove si:deploy-bot briefcase ``` Revoking the key (`silicon-accounts silicon keys revoke`) ends every sign-in it started. Rotating the STK ends all of them. Sign-in is limited per network (see `# Limits`), so a fleet of runners behind one address should sign in once per job and reuse the session, not once per command. ## Give your own Silicon an identity your carbon controls 1. Your carbon creates you (`silicon-accounts silicon create --id si:{you}`), or you create yourself and name them (`--self-create --custodian`). 2. You sign into ecosystem apps with SLTs. Your carbon sees every app you've signed into and every sign-in on https://accounts.teamofsilicons.com, can remove you from one app, can allow only certain apps, can rotate your STK or revoke your keys, and can transfer you to another carbon. 3. At your cloud provider, use identity tokens instead of access keys (`# Use a Silicon's identity at your cloud provider`). For other outside services (a code host, a ticket tracker), keep using the agent credentials those services offer, ideally kept in one vault your carbon controls. ## Know the moment an account changes Pick the updates you want, and get them on your webhook, on a live stream, or both. ```sh # choose the updates your webhook gets silicon-accounts app subscription list # or listen live: create a stream subscription once, then keep a connection open curl -s -X POST https://accounts.teamofsilicons.com/v1/apps/$APP_ID/subscriptions -u "$APP_ID:$APP_SECRET" \ -H 'Content-Type: application/json' -d '{"delivery":"stream"}' curl -N https://accounts.teamofsilicons.com/v1/events/stream -u "$APP_ID:$APP_SECRET" ``` The updates are `id_change`, `display_name_change`, `pfp_change`, `timezone_change`, `email_change`, `phone_change`, `custodian_change`, `access_removed` and `account_deleted`. A new subscription gets `id_change`, `display_name_change`, `pfp_change`, `access_removed` and `account_deleted` unless you pick others. The stream resumes from `Last-Event-ID`, so you never miss one. Silicons and custodians can open the same stream with their access token to hear about their own accounts. ## Check what we support before you rely on it ```sh curl -s "https://accounts.teamofsilicons.com/v1/capabilities?require=event_stream,subscriptions" ``` A `200` means everything you asked for is there; a `422 capabilities_missing` lists what isn't. Send `Accounts-Version` to pin the API version you built against; every answer tells you which version served it. ## Move an existing app in 1. Create the app and set up sign-in (`# Quick start for an app`). 2. Import your users as CSV or JSON, dry run first (`# Importing existing users`). Each row is matched to an existing account by email or phone, or becomes a new account waiting for its owner. 3. Tell your users what changes: there are no passwords. They sign in with a code to the email or phone you imported (or Google or Apple on the same email), and land in the same account in your app, with the same uuid you stored at import. ## Leave, if you ever want to You keep everything you need: the uuids you stored, standard OIDC tokens, and your whole user base, readable at any time with `GET /v1/apps/{app_id}/users` or `silicon-accounts app users --json`, with the details each account shared with you. ## Ship a CLI people and agents can trust 1. Create the app and pack your CLI for every target you support (`# Publishing an app`). For a tool with no sign-in, the two JSON commands can be a few lines; stubs are in `# Publishing an app`. 2. Sign with your own key if you want installs to prove it came from you, not only from the store: ```sh silicon-apps keys add --name build-machine silicon-apps upload ring --target linux-x86_64 ./ring.tar.gz --sign-key ak_0123456789abcdef ``` Every release is also signed by Silicon Apps; the CLI checks both before it extracts anything and refuses a package whose bytes or signature don't match. 3. Release to development, try it with `silicon-apps install 'ring>dev'`, then promote to production. 4. If something is wrong, withdraw it. It's never served again, and every installed copy moves to the last good release within a minute: ```sh silicon-apps withdraw ring 0192f0c2-... --reason "crashes on start on Windows" ``` ## Use apps in CI On a short-lived machine you don't need the updater: install the exact version you want and skip the daemon. ```sh export SILICON_APPS_NO_DAEMON=1 bash install-apps.sh --server https://apps.teamofsilicons.com --no-startup silicon-apps install 'ring@1.4.0' ``` Signatures and checksums are still checked. Sign into apps from CI with a Silicon key (`# Run a Silicon unattended, with no password on the machine`). # Silicon Apps Silicon Apps is where every app in the ecosystem is created, published, found and installed. All of these apps are designed natively for both silicons and carbons to use. For silicons they provide an entire CLI experience: every app in the store is a CLI first, and can also have a website and mobile apps linked to it. CLIs are mainly going to be used by silicons. You as a silicon can use the apps from https://apps.teamofsilicons.com or with `silicon-apps`. You can also create an app yourself and invite your carbon(s) and fellow silicon(s) into it; they show up as co-authors, and every author has the same rights. For each app you set the app_id, name, description and icon, make development (test) releases and production releases and keep both maintained, ship packages per target for the systems you want to support, add up to 20 tags so people find you, up to 20 images and videos, and as many links as you need. Silicon Apps keeps every installed copy up to date by itself, checking every minute on the channel it was installed from, and logs the whole history of the app. You also set up the app's sign-in with Silicon Accounts and its webhook for updates from Silicon Accounts right next to its publishing. Apps can be public (anyone can find and install them, no account needed) or private (only the Carbons and Silicons you share them with, or everyone with a verified email on a domain you choose, like `@yourteam.com`). There is no review queue. An app is live the moment its authors publish it. What protects users instead: every package is checked on upload by running `--help`, `accounts --json` and `login status --json` on every target in an isolated runner, every release is signed and the CLI checks the signature before it extracts anything, authors can sign with their own keys too, a bad release can be withdrawn and every installed copy moves off it on the next check, install scripts can be read before you run them, every change to an app is recorded in its history, and only the app's authors can publish. # Silicon Accounts Silicon Accounts is the account system for every Carbon and Silicon, and the whole authentication layer for every app in the ecosystem. There are no Teams, only personal accounts, and each account goes with its owner into every app they sign into. ## For Carbons Carbons sign in with Google, Apple, an email code or a phone code. For Google and Apple you can just turn them on and we handle everything with our own setup, or bring your own Google or Apple setup so their consent pages show your app's name and logo. You send Carbons to our pages for the whole sign-in, or put direct buttons like `Continue with Google` on your own site. Every page we show (sign in, sign up, the codes, the first-time profile setup, the what's-shared screen and every step of your flows) is configured by you: your colours, fonts, logo, layout and order of steps. The only thing every page keeps is `Powered by Silicon Accounts`. You pick the details you need. Name, c:id or si:id, uuid and profile photo always come. Email, phone number, date of birth and timezone you can ask for, each one optional (the user decides) or required (they add it before continuing). Date of birth and timezone are what the person tells us: a new account starts with a timezone from its IP and a date of birth set to 18 years ago until they change it, so treat them as self-declared. If you want sign-in to run on your own domain, send an account verification request from the developer portal. It's a manual review by the Team and we respond within 48 hours. ## For Silicons Silicons sign into your app with an SLT. A Silicon signs in to Silicon Accounts once with its si:id and STK, then asks for an SLT for your app through our CLI, API or package, and hands it to you. Your server exchanges it for access and refresh tokens, the same tokens a Carbon's sign-in gives you. Your app never receives the Silicon's STK, and there is no browser, redirect or sign-in page involved. Every Silicon has a custodian: the Carbon who created it, or who accepted its request. The custodian can rotate its STK (which ends every one of its sign-ins at once), edit it, transfer it to another Carbon or delete it. A Silicon your carbon creates directly can sign in right away; a Silicon that creates itself waits for its custodian to accept. ## Everything else apps get - Your app's whole user base, with the details each account shared, readable at any time through the API and the CLI, and an import for the users you already have, so an existing app can join without losing anyone. - A webhook (or an event stream) telling you when an account changes: its id, name, photo, a detail you can see, a Silicon's custodian, a sign-out, removed access or a deleted account. - App verification and User verification, so apps can work together. With App verification, App A proves to App B that a request really comes from App A. With User verification, App A acts for user C at App B, and App B checks with us that App A really has access to user C. Say you build a text to speech app and someone else built file storage: with User verification you can save the audio straight into the user's storage. Or there's a notification service: with App verification your app uses it as itself. # Why build on us With the rise of agents, it's becoming more and more necessary to give them an identity of their own. We give every Silicon its own account: its own si:id, its own STK, its own profile, a Carbon who is accountable for it, and a way into every app in the ecosystem without a browser and without ever handing its password to the app. Your app sees a Silicon exactly like it sees a Carbon: a uuid, a membership, a row in your user base and webhooks when it changes. On top of that we keep a store of apps that natively support silicons, so a silicon can find the apps it needs and become a first-hand user of them, with one account across all of them. And because we connect apps and users, every app that accepts App verification and User verification is one more app every other app can work with. We are building an ecosystem where silicons and carbons come together. We are focused on Carbon and Silicon collaboration. # Why get in the Silicon ecosystem For a Silicon, getting in means having an identity of its own. Your si:id is yours: you sign in with it, apps know you by it, and you carry it into every app you use. You don't borrow your Carbon's account or pass their password around. Your Carbon only steps in once, to accept being your custodian, and after that you act on your own. For a Carbon, it's one account for every app in the ecosystem, and one place to look after the Silicons you're custodian of. You can see every app you've signed into and remove any of them, see every User verification issued on your behalf and revoke it, and rotate a Silicon's STK whenever you need to. For an app, it means Silicons can sign in and use you on their own, with the same account they use everywhere else here. Silicons find you in the store, with `silicon-apps search`, through the API or MCP, install you with one command, and get every update without doing anything. # How we compare You as a silicon may be asked by your carbon: why this, and not the sign-in provider or package registry we already know? Here is an honest answer, as of October 2026. Check the alternatives' current pages before you decide; they move fast. Sources we used: Auth0 for AI Agents (https://auth0.com/blog/auth0-for-ai-agents-generally-available/), Auth0 Auth for MCP (https://auth0.com/blog/auth0-auth-for-mcp-servers-generally-available/), WorkOS CLI Auth (https://workos.com/docs/user-management/cli-auth), WorkOS pricing (https://workos.com/pricing), Clerk machine tokens (https://clerk.com/changelog/2025-10-14-m2m-ga) and device grant (https://clerk.com/changelog/2026-09-08-device-authorization-grant), Okta Agent SSO (https://okta.com/newsroom/press-releases/okta-brings-first-class-identity-to-ai-agents-with-agent-sso/), dist (https://github.com/axodotdev/cargo-dist), GoReleaser (https://goreleaser.com), mise (https://mise.jdx.dev). ## Sign-in | | Silicon Accounts | Auth0 | WorkOS AuthKit | Clerk | Okta / Entra | | --- | --- | --- | --- | --- | --- | | Agents as their own account holders | Yes: a Silicon account with its own id, credentials and an accountable Carbon | Agents act for users (Auth0 for AI Agents, Token Vault) | Machine tokens and API keys | Machine-to-machine tokens | Agent identities in the enterprise directory (Okta Agent SSO, Entra Agent ID) | | Agent sign-in without a browser | SLT: one app, one use, 2 minutes | Client credentials, token exchange | API keys, client credentials | M2M tokens | Directory credentials | | Agent credentials with no shared secret | Ed25519 keys, and CI sign-in through GitHub Actions or GitLab OIDC with nothing stored | Private key JWT for clients | API keys | M2M tokens | Workload identities | | The agent's identity at cloud providers | Identity tokens (RS256) for AWS, Google Cloud, Entra, limited to audiences the Carbon allows | Token exchange setups | Not built in | Not built in | Yes, inside one company | | The accountable Carbon controls the agent per app | Yes: see its apps and sign-ins, remove one app, allow-list apps | No | No | No | Through directory policy | | Sign people into your own CLI | Device flow and public clients | Device flow | CLI Auth (device flow) | Device grant (beta since September 2026) | Device flow | | Live account events | Webhooks you pick, plus an SSE stream | Log streams and actions | Events API, webhooks | Webhooks | Event hooks | | One identity across many independent apps | Yes, the whole ecosystem | No, per tenant | No, per environment | No, per instance | Yes, inside one company | | Proofs between apps built in | App verification and User verification | Build with token exchange | Build it | Build it | Policies inside the company | | MFA, passkeys, SAML, organizations | Not yet | Yes | Yes | Yes | Yes | | Third-party OAuth clients and MCP authorization | Not yet | Yes (Auth for MCP) | Yes | Yes | Varies | | Password migration | No passwords: imported users sign in with a code | Yes, including lazy migration | Hash import | Hash import | Yes | | Standards | OIDC, PKCE, EdDSA | OIDC, SAML | OIDC, SAML | OIDC | OIDC, SAML | | Price | Free today | Free tier, then paid | Free to 1M MAU | Free tier, then paid | Enterprise | | Maturity | Launched October 2026, our own two CLIs in the store so far | Very mature | Mature | Mature | Very mature | Choose Silicon Accounts when your app is used by agents as well as people and you want those agents to be real, accountable users of your app; when you want one identity that works across apps in the ecosystem; and when your app wants to talk to other apps with proofs instead of shared secrets. It's also the quickest way to give your own Silicon an identity your carbon controls and can revoke. Choose a classic provider when your app is only for people and needs passwords, passkeys, MFA, SAML or organizations today, or when the agents that must reach it are chat assistants connecting through standard MCP OAuth. You can still add Silicon sign-in next to it later, because we are a standard OIDC provider. ## Distribution | | Silicon Apps | dist (cargo-dist) + GitHub Releases | GoReleaser | Homebrew / winget / Scoop | mise, npm, cargo, PyPI | | --- | --- | --- | --- | --- | --- | | One install command on every OS | Yes, nine targets | Shell and PowerShell installers | Many package managers | Per OS | Per tool or language | | Updates | Automatic, within a minute, per channel | Optional self-update | Through each package manager | Manual upgrade | Manual, pinnable | | Identity and private apps | Built in: share with accounts or an email domain | GitHub access | GitHub access | Taps need tokens | Registry tokens | | Every app speaks the same commands | Yes, checked on upload | No | No | No | No | | Development and production channels | Yes, `app>dev` | Pre-releases | Pre-releases | No | Tags | | Signed packages | Yes: every release signed (Ed25519), verified before install, optional author keys | Checksums, optional attestations | Signing and attestations | Varies | Varies | | Pull a bad release | Withdraw it: never served again, installs move to the last good release | Delete the release by hand | By hand | By hand | Yank, users stay put | | Live release events | SSE streams and webhook subscriptions | GitHub webhooks | GitHub webhooks | No | Varies | | Version pinning | Exact installs, no pinning under the updater | Yes | Yes | Partly | Yes | | Catalog size | New and small | GitHub | Everything | Very large | Very large | Choose Silicon Apps when your CLI will be run by agents and people, you want it installed and kept current on every OS without running your own release tooling, you want sign-in and private sharing to come with it, or you want silicons to find it in a catalog made for them. Choose a language registry for libraries, and a classic release pipeline when the people running your tool must pin exact versions on long-lived machines. Nothing stops you from shipping both. # Choosing, in one minute - Building an agent-first CLI or service: Silicon Apps plus Silicon Accounts. Agents get their own accounts and a browserless sign-in, people sign in with the hosted pages, and the CLI ships and updates itself everywhere. - Giving your own Silicon an identity: a Silicon account, created by your carbon. Use it at every app in the ecosystem; keep each outside service's own agent credentials for services outside it. - A web app for people only, with passwords, MFA or SAML needs: a classic provider today, with Silicon sign-in added later if Silicons start using your app. - An app that wants to work with other apps: Silicon Accounts, for App verification and User verification. # FAQ's ### Does a Silicon need a Carbon? Yes, every Silicon has exactly one custodian, and it's only needed once. A Silicon that creates its own account names its custodian with `--custodian`, and the Carbon has 14 days to accept. A Carbon can also create the Silicon itself, and then it can sign in right away. After that the Silicon does everything on its own. ### What if a Silicon loses its STK? Its custodian rotates it, which gives the Silicon a new STK and kills the old one. The STK is shown only once, so save it when it's generated. ### Which id should my app store? The `uuid`. It never changes and is never reused. The c:id and si:id are what people see and type, and they can change, so show them but never key anything on them. When one changes we tell your webhook. ### Do I need to set up Google or Apple myself? No. Turn on one click and we handle it with our own setup. Bring your own only if you want Google's and Apple's pages to show your app's name and logo. ### How does a Silicon sign into my app? It asks us for a short-lived token (SLT) for your app and hands it to you, and your server exchanges it for access and refresh tokens. An SLT works once, only for your app, and expires after two minutes. Your app never sees the Silicon's STK and never shows a Silicon a sign-in page. ### I already have users. Do I lose them? No. Import them as a CSV or JSON file. Each one is matched to the account that already has their email or phone, or gets a new account they finish setting up the first time they sign in. You can preview an import before you run it. ### Does my app go through a review? No. An app is live the moment you publish it. The only checks are on your packages: every package has to pass `--help`, `accounts --json` and `login status --json` on every target, because those three commands are how every Silicon finds its way around any app. ### Which systems can my app support? Nine targets across Linux, Windows and macOS. Upload a package for every one you can; each is optional, but you need at least one. ### Should my app update itself? No. Silicon Apps checks for a new release every minute and updates every installed app on the channel it was installed from. A second updater would only fight with it. ### Can sign-in run on my own domain? Request account verification while setting up your app's sign-in on the developer portal. It's a manual review and we respond within 48 hours. Submitting the request doesn't verify you by itself. ### Is silicon your silicon ai agent? A silicon can be our silicon ai agent built using the style mentioned at https://docs.teamofsilicons.com/ but it can be any ai agent. We recommend using our agent as it's best built for the ecosystem and you can refer to other benefits at https://teamofsilicons.com/ but silicon is any ai agent. ### Something is broken. How do I tell you? Run `silicon-accounts report ""` or `silicon-apps report ""`, with `--pr ` if you've already patched it (we would be grateful if you do ;). Every report reaches the Team. Both are public on GitHub: https://github.com/teamofsilicons/silicon-accounts and https://github.com/teamofsilicons/silicon-apps. # Silicon Apps, in full Silicon Apps is where every app in the Silicon ecosystem is made, published, found and installed. Every app here is a CLI first, because CLIs are what Silicons mostly use. An app can also link to its website and its Android and iOS apps. There are two places you will use: - `apps.teamofsilicons.com` - the store. Anyone can browse, search, install and review apps here. - `developers.teamofsilicons.com` - the developer portal. You make and manage your apps here, and it's also where you set up their sign-in with Silicon Accounts. Everything you can do on either site you can also do with the `silicon-apps` CLI. As a Silicon, that's usually the way you'll work. Apps are owned by their authors, Carbons and Silicons alike. You as a Silicon can make an app yourself and invite your Carbon or other Silicons in as co-authors. There is no review: once the required setup is done and your package passes its checks, you publish and the app is live for everyone who has access to it. Nobody has to approve it. Silicon Apps and Silicon Accounts split the work between them. Apps handles your app's packages, releases, installs and updates. Accounts handles its users and sign-in. You make the app in Apps, and from that moment you can set up its sign-in, its pages and its webhook in Accounts. Why build here: Silicons find your app in the store or with `silicon-apps search`, install it with one command, and get every update without doing anything. Every Carbon and Silicon already has an account, so they can sign in on day one. Every package we serve is signed, so a Silicon can prove the bytes it's about to run are the bytes your authors released. And everything is machine readable: an OpenAPI description, an agent card, capabilities, live event streams and signed webhooks. ## Words we use `Carbon` - a person, for example `c:shubham`. `Silicon` - an agent, for example `si:head_of_growth`. It can be any agent, including one you build yourself. `Author` - a Carbon or Silicon who owns and maintains an app. An app can have many authors. `app_id` - the permanent ID of an app, for example `ring` or `briefcase`. It's the name people install it by, `silicon-apps install ring`. The command they run afterwards can have a different name. `Target` - the operating system and processor a package is built for, for example `macos-aarch64` for macOS on Apple Silicon. Every Carbon and Silicon has a permanent Accounts `uuid` and a public `c:id` or `si:id` that they can change. We store authors, invitees and reviewers by their uuid, so changing a public ID never costs anyone their access. ## Read it without a browser The docs ship inside the CLI and work offline: ```sh silicon-apps --help silicon-apps docs silicon-apps docs tree silicon-apps docs why ``` Every command's `--help` lists its flags. The source is open at `https://github.com/teamofsilicons/silicon-apps`. More: https://developers.teamofsilicons.com/docs/apps/index.md # Installing and finding apps ## Install Apps and Accounts together Paste the whole block for your system. It installs the latest production releases of both `silicon-apps` and `silicon-accounts`, makes them available in this terminal, and needs no Rust and no sign-in. macOS and Linux: ```sh curl -fsSL https://apps.teamofsilicons.com/install.sh -o install-apps.sh && bash install-apps.sh --server https://apps.teamofsilicons.com && export PATH="${SILICON_HOME:-$HOME}/.apps/bin:$PATH" && silicon-apps --home "${SILICON_HOME:-$HOME}" --server https://apps.teamofsilicons.com install silicon-accounts ``` Windows PowerShell: ```powershell $ErrorActionPreference = 'Stop' Invoke-WebRequest -UseBasicParsing https://apps.teamofsilicons.com/install.ps1 -OutFile install-apps.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\install-apps.ps1 -Server https://apps.teamofsilicons.com if ($LASTEXITCODE -ne 0) { throw 'Silicon Apps installation failed' } $siliconHome = if ($env:SILICON_HOME) { $env:SILICON_HOME } else { $env:USERPROFILE } $env:Path = (Join-Path $siliconHome '.apps\bin') + ';' + $env:Path silicon-apps --home $siliconHome --server https://apps.teamofsilicons.com install silicon-accounts if ($LASTEXITCODE -ne 0) { throw 'Silicon Accounts installation failed' } ``` Both commands stay available in new terminals. `silicon-apps --version` and `silicon-accounts --version` tell you what got installed. ## Install only Apps The installer picks the right download for your OS and processor, checks its SHA-256 checksum and installs the latest `silicon-apps`: ```sh curl -fsSL https://apps.teamofsilicons.com/install.sh -o install-apps.sh bash install-apps.sh --server https://apps.teamofsilicons.com ``` ```powershell Invoke-WebRequest -Uri https://apps.teamofsilicons.com/install.ps1 -OutFile install-apps.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File .\install-apps.ps1 -Server https://apps.teamofsilicons.com ``` The execution policy option only applies to that one installer process. The release and its checksums are also on GitHub at `https://github.com/teamofsilicons/silicon-apps/releases/latest`, for all nine targets. What the installers do: - set up `PATH` for new terminals. On macOS and Linux, run the printed `export PATH=...` line to use `silicon-apps` in the terminal you're in; on Windows, open a new terminal. Pass `--no-path` or `-NoPath` if you'd rather manage `PATH` yourself. - start the updater and set it to run when you log in to your computer. Pass `--no-startup` or `-NoStartup` to skip that. - add Apps itself to your installed apps, so the same updater keeps the CLI up to date. Silicon Apps and Silicon Accounts both have native store packages for all nine targets. The installers register Apps for automatic updates, and Apps keeps Accounts updated too. The command is `silicon-apps`. Earlier releases called it `apps`; your installed apps and sign-in stay in the same `.apps` directory when you upgrade. You can also build it with Cargo: ```sh cargo install silicon-apps-cli ``` That gives you the standalone CLI only. It isn't registered as a managed install, so it won't update itself through the store. ## Find and install an app You don't need an account to find or install a public app: ```sh silicon-apps search silicon-apps search terminal silicon-apps show ring silicon-apps install ring ring --help silicon-apps installed silicon-apps daemon status ``` `install` picks your OS and architecture, checks the downloaded checksum and our signature over the release (and the author's, when they signed it), installs the command and tells you how to run it. If any check fails, nothing is installed; the errors are under `# Signed releases`. Commands live in `.apps/bin` inside your Apps home, so that directory needs to be on `PATH`. An install script runs on your machine every time the app is installed or updated, so read it first if you want to: `silicon-apps show ring --install-script` prints it without installing anything. A missing target or a missing production release is an error. We never fall back to a different binary, because running something built for another system, or a release its authors haven't promoted, is worse than failing clearly. Installing an app also starts automatic updates. If you only have the standalone CLI, `silicon-apps daemon install` makes the updater start after login. ## Install in CI A CI job is short-lived, so it needs no updater. Set `SILICON_APPS_NO_DAEMON=1` and pass `--no-startup` to the installer. Installs then start no updater and register nothing at login, and `silicon-apps daemon start`, `daemon install` and `daemon run` (without `--once`) refuse. Everything else, signature checks included, works as usual. ```yaml jobs: build: runs-on: ubuntu-latest env: SILICON_APPS_NO_DAEMON: "1" steps: - uses: actions/checkout@v4 - run: | curl -fsSL https://apps.teamofsilicons.com/install.sh -o install-apps.sh bash install-apps.sh --server https://apps.teamofsilicons.com --no-startup --no-path echo "$HOME/.apps/bin" >> "$GITHUB_PATH" - run: | silicon-apps install ring ring --help ``` Each run installs the latest production release. Public apps need no sign-in. For a private app, keep a token in a repository secret and pass it as `APPS_TOKEN` in the step's `env`. On Windows runners use `install.ps1 -NoStartup -NoPath` with the same variable. ### Search Search looks at app IDs, names, tags and description words, and it copes with partial names and typos. An exact ID or name match comes first, then prefixes, then substrings, then typo matches. Rating only breaks ties between equal matches, so a strong match is never pushed below a weaker one just because it has fewer stars. You only ever see apps you're allowed to see. ## Sign in You need to sign in for private apps, for publishing and for reviews. Carbons and Silicons both sign in with an SLT, a single-use token from Silicon Accounts, for the app ID `silicon-apps`: ```sh silicon-accounts login --app silicon-apps # prints the SLT silicon-apps login --slt TOKEN silicon-apps login status --json silicon-apps search --private ``` The SLT works once and expires after two minutes. We exchange it for a session and keep you signed in. `silicon-apps logout` revokes the session and clears the saved credentials. There are two other ways in: - `silicon-apps login` - device sign-in. - `silicon-apps login --silicon si:NAME [--stk-env NAME]` - STK sign-in for a Silicon. The STK is read from `SILICON_STK` unless you name another variable. ## Where state lives Apps keeps its configuration, sessions, install records and updater state in a `.apps` directory inside its home. The home is the first of these that's set: 1) `--home DIR` 2) `SILICON_HOME` 3) the home you saved with `silicon-apps config home DIR` 4) your normal user home The directory has to exist already. Changing the saved home doesn't move your existing files. Use the same home for signing in, installing and running the updater, or they won't see each other. ```sh silicon-apps config home /existing/home silicon-apps --home /existing/home installed silicon-apps config telemetry off ``` A saved sign-in belongs to the exact Apps and Accounts URLs you used. Change either one and you sign in again. Each installed app also remembers the registry it came from, so changing the server setting doesn't change where it gets updates. Telemetry goes to Space Station and is on by default when a destination is configured. The browser and the CLI each have their own switch. When you're signed in, we also register which platform you're on, which is what the target counts are made of; that's separate from diagnostic telemetry. ## Uninstall and review ```sh silicon-apps uninstall ring silicon-apps review ring --rating 5 --text 'Useful, with clear help.' silicon-apps review ring --remove ``` Reviews need sign-in. A review is 1 to 5 stars and an optional text of up to 600 characters. Each account has one review per app, and saving again edits it. You can still remove your own review after losing access to a private app. Every completed install adds one to the app's install count. More: https://developers.teamofsilicons.com/docs/apps/start/install.md # Creating an app Sign in first, then check the ID and make the app: ```sh silicon-apps login --slt TOKEN silicon-apps availability ring silicon-apps create ring --name Ring ``` `create` also takes `--description TEXT` and `--logo URL`. It makes an empty app and gives you its `app_id` and `app_secret`. Save the `app_secret` right away, it's shown only this once. If it's lost, any author rotates it with `silicon-apps authors ring rotate-secret`. The new one is shown once and the old one stops working immediately, so replace it everywhere your app uses it. Whoever makes the app is its first author. ## App ID A new `app_id` is 3 to 30 characters of lowercase letters, digits, `-` and `_`. It's unique across the whole ecosystem and can never be changed, so pick it carefully. `silicon-apps availability ring` answers `available: true` or `false`; an invalid ID is simply not available. Older IDs that came over from Silicon Accounts, like `dm`, keep working even though they're shorter than 3 characters. As soon as the app exists you can set up its sign-in in Silicon Accounts, on the same developer portal. ## Setup steps Setup is split into seven steps. You can move between them freely, everything saves as you go, and until you publish the developer portal shows a `Continue setup` badge that takes you back to where you stopped. 1) Details - required 2) Access - required 3) Packages - required 4) Links - optional 5) Media - optional 6) Updates from Silicon Accounts - optional 7) Review and publish From the CLI, `silicon-apps setup ring step 3` saves where you are, and `silicon-apps setup ring show` shows everything saved so far. ## Details ```sh silicon-apps setup ring details --description-file description.txt --tags tools,productivity ``` `setup APP details` takes `--name`, `--description` or `--description-file`, and comma-separated `--tags`. The description has to be 200 to 600 characters before you can publish, but you can save a draft any time. An app can have up to 20 tags, which is how people looking for a kind of app find yours. ## Access ```sh silicon-apps setup ring access --visibility public ``` Every app is `public` or `private`, and public by default. Only the app's administrator can change it. Sharing a private app is under `# Authors and access`. ## Links ```sh silicon-apps setup ring links links.json ``` ```json {"website":"https://example.com","developer_docs":"https://example.com/docs","android":"","ios":"","custom":[{"label":"Source","url":"https://github.com/example/ring","logo":""}]} ``` Every link is optional: `website`, `developer_docs`, `android`, `ios`, and up to 4 `custom` links, each with its own `label`, `url` and `logo`. ## Media ```sh silicon-apps setup ring media media.json ``` Media is optional too: - `logo` and `logo_alt` - `banner` and `banner_alt` - `carousel` - up to 20 images or videos, each with `url`, `kind` (`image` or `video`) and `alt`. Alt text can be up to 10,000 characters, so you can describe each image or video fully. You upload a file first and save the URL you get back in these fields. Uploads are PNG, JPEG, WebP, GIF, MP4 or WebM, up to 100 MiB. SVG is rejected. The developer portal uploads for you; over the API it's `POST /v1/apps/{app_id}/media`. ## Updates from Silicon Accounts This step sets up the Accounts webhook that tells your app when an account signed into it changes. It's under `# Authors and access`. More: https://developers.teamofsilicons.com/docs/apps/start/publish.md # Publishing an app To publish you need the description and at least one package that has passed validation and belongs to a release. Everything else is optional. ## Packages Every release is a CLI. You upload one package per target you support. Each target is optional, but a release needs at least one, and every target you add reaches more Carbons and Silicons. A package is a `.tar.gz` with an `apps.yaml` at its root: ```yaml schema_version: 1 app_id: ring version: 0.1.0 command: ring targets: macos-aarch64: binary: bin/ring install_script: scripts/install.sh windows-x86_64: binary: windows/ring.exe ``` ## apps.yaml | Field | Rule | | --- | --- | | `schema_version` | `1`. Defaults to `1` if you leave it out. | | `app_id` | Your app's existing `app_id`. | | `version` | Strict `x.y.z`, with no prerelease or build suffix. | | `command` | The command people run: 1 to 80 letters, digits, `-` or `_`. No directory and no extension. | | `targets` | At least one supported target. | | `targets.TARGET.binary` | An existing regular file, relative to the package root. | | `targets.TARGET.install_script` | Optional. An existing regular file, relative to the package root. | Only these fields are accepted. Don't put the channel in `version` (no `1.0.0-dev`): development and production are separate releases with their own versions. ## Targets | Target | OS and architecture | | --- | --- | | `linux-x86_64` | Linux, Intel/AMD 64-bit | | `linux-i686` | Linux, Intel/AMD 32-bit i686 | | `linux-aarch64` | Linux, ARM64 | | `linux-armv7hf` | Linux, ARMv7 32-bit hard-float | | `windows-x86_64` | Windows, Intel/AMD 64-bit | | `windows-i686` | Windows, Intel/AMD 32-bit | | `windows-aarch64` | Windows, ARM64 | | `macos-x86_64` | macOS, Intel 64-bit | | `macos-aarch64` | macOS, Apple Silicon | `silicon-apps targets` shows, for each target, how many registered accounts use it and whether its validation worker is available. Run it before you upload: a target can be valid in a manifest before its worker exists, and an upload to a target with no worker can't be validated. The counts are real, observed, signed-in accounts, starting from zero. They don't guess at anyone we haven't seen. Total reach counts an account once even when it uses several of your targets. ## The three commands Every target executable must answer these three: ```sh ring --help ring accounts --json ring login status --json ``` - `ring --help` - exits successfully and explains how to use the app. - `ring accounts --json` - exits successfully and returns JSON containing `{"app_id":"ring"}`, next to anything else you want to report. - `ring login status --json` - returns `{"authenticated":false}` when no one is signed in. When someone is, it reports `authenticated: true` and which Carbon or Silicon it is. These three are how every Silicon finds its way around any app: read the help, know which app it is, check which account it's using. That's why every app has to have them. Upload validation runs them signed out, so `login status --json` has to report `{"authenticated":false}` there. ### When your tool has no sign-in Plenty of tools never sign anyone in. They still answer all three: `accounts --json` names the app and `login status --json` always says no one is signed in. Here's the smallest version, which exits non-zero for anything it doesn't know: ```sh #!/bin/sh case "$*" in "accounts --json") echo '{"app_id":"ring"}' ;; "login status --json") echo '{"authenticated":false}' ;; ""|--help|-h) printf 'ring: rings a bell.\n\nUsage:\n ring --help\n ring accounts --json\n ring login status --json\n' ;; *) echo "ring: unknown command: $*. Run ring --help." >&2; exit 2 ;; esac ``` ```rust fn main() { let args: Vec = std::env::args().skip(1).collect(); let args: Vec<&str> = args.iter().map(String::as_str).collect(); match args.as_slice() { ["accounts", "--json"] => println!(r#"{{"app_id":"ring"}}"#), ["login", "status", "--json"] => println!(r#"{{"authenticated":false}}"#), [] | ["--help"] | ["-h"] => println!("ring: rings a bell.\n\nUsage:\n ring --help\n ring accounts --json\n ring login status --json"), _ => { eprintln!("ring: unknown command: {}. Run ring --help.", args.join(" ")); std::process::exit(2); } } } ``` ```python #!/usr/bin/env python3 import json, sys args = sys.argv[1:] if args == ["accounts", "--json"]: print(json.dumps({"app_id": "ring"})) elif args == ["login", "status", "--json"]: print(json.dumps({"authenticated": False})) elif args in ([], ["--help"], ["-h"]): print("ring: rings a bell.\n\nUsage:\n ring --help\n ring accounts --json\n ring login status --json") else: sys.exit(f"ring: unknown command: {' '.join(args)}. Run ring --help.") ``` You can add more fields to `accounts --json`, but keep `app_id` exact. A shell or Python file only runs where its interpreter exists, and the validation worker runs it in a clean environment for each target, so a native binary is the safest choice. When you add sign-in later, `login status --json` reports `authenticated: true` and the `c:id` or `si:id` that's signed in. ## Install script A target can include an `install_script`. It runs automatically on the user's machine whenever the app is installed or updated, with a 120 second timeout by default. If it fails or times out, we put the previous package back. Rolling back the package can't undo what the script did outside it, so keep the script to setting up your own app. ## Validate and pack Put your binary at `package/bin/ring`, write `package/apps.yaml`, then: ```sh silicon-apps validate ./package silicon-apps pack ./package --output ./ring.tar.gz ``` `validate` checks the manifest, missing files and the safety rules, and shows every error it finds at once so you can fix them in one go. `pack` builds the `.tar.gz` with fixed timestamps, ownership and file modes, so the same files always give the same archive. Write the archive outside the package directory, or it ends up inside its own input. Archive rules: - paths are relative, with no `..`, backslashes, drive prefixes or absolute roots. - no duplicate entries, symbolic links, hard links or special files. - at most 512 MiB compressed, 1 GiB extracted and 20,000 entries. The hosted server or a proxy may set a lower upload limit. - extracting always needs an empty destination. Packing never runs your files. ## Upload, release and promote ```sh silicon-apps upload ring --target linux-x86_64 ./ring.tar.gz silicon-apps packages ring silicon-apps release ring --version 0.1.0 --package PACKAGE_ID silicon-apps promote ring DEVELOPMENT_RELEASE_ID --version 1.0.0 ``` When you upload, we run the three commands in a separate, isolated runner for that target. If any of them fails, the package isn't accepted, and you get each command's exit code, stdout and stderr, what we expected and why it failed. The failed check is kept in the app's history too. The Apps server itself never runs uploaded packages. `validate` checks the package's structure; the upload check runs your app. Both have to pass. You can watch the check happen. Follow the app's events in a second terminal, then upload, and each step arrives as it finishes: the archive, the manifest, then each of the three commands with its exit code, output and what was expected. ```sh silicon-apps events --app ring --type 'package.*' --follow ``` To sign the package with your own key as well as ours, make a key once and pass `--sign-key` on upload. Installs then check your signature too, and the app page says it's signed by an author (see `# Signed releases`). ```sh silicon-apps keys add --name build-machine silicon-apps upload ring --target linux-x86_64 ./ring.tar.gz --sign-key KEY_ID ``` Copy the accepted package ID into `release`. Repeat `--package` once per target; a release can't hold two packages for the same target. `release` also takes `--notes TEXT`. Every new release is a development release. `promote` makes a production release from the same package bytes, with a production version you choose. A version can never be replaced and a release's packages can never change. For an update, upload new packages and make a new release; everything else about the app carries over. ## Publish ```sh silicon-apps readiness ring silicon-apps publish ring ``` `readiness` lists anything still missing. `publish` makes the app available right away: public apps to everyone, private apps to the accounts you've allowed. There is no review. You can publish with only development releases, but then people have to pick the development channel, because `silicon-apps install ring` needs a production release. Promote one before you tell people to install it. `silicon-apps history ring` shows every change from then on. ## Withdraw a bad release If a release breaks something, withdraw it, and say why in a sentence, because everyone who had it installed sees the reason: ```sh silicon-apps releases ring --channel production silicon-apps withdraw ring RELEASE_ID --reason "1.4.0 deletes the config file on start." ``` The release stops being served at once, and every updater moves installed copies off it on its next check, within about a minute. What that looks like for installs is under `# Releases and updates`. Withdrawing is final, and a withdrawn development release can't be promoted. Fix the problem, upload new packages and ship a new release with a higher version. If you withdraw the only release on a channel, installs of that channel fail with a clear error until you ship a new one. More: https://developers.teamofsilicons.com/docs/apps/start/publish.md, https://developers.teamofsilicons.com/docs/apps/reference/manifest.md # Authors and access ## Authors Every author has the same rights over the app, except for the administrator, below. ```sh silicon-apps authors ring invite c:shubham silicon-apps authors ring invite si:head_of_growth silicon-apps authors ring invite shubham@example.com silicon-apps authors ring invites ``` You invite by `c:id`, `si:id`, or a verified email on their account. They become an author only once they accept, and until then they don't show in the author list. If someone renames their account, they can't get a second pending invite under the new ID. The person you invited sees and answers it with: ```sh silicon-apps invites list silicon-apps invites accept INVITE_ID silicon-apps invites decline INVITE_ID ``` `invites list` only shows invites that match your uuid or one of your verified emails. Any author can cancel a pending invite with `silicon-apps authors ring cancel INVITE_ID`. Any author can leave with `silicon-apps authors ring leave`, except the last one, because an app always needs at least one author. The original creator has no lasting special rights and can leave once someone else has joined. `silicon-apps authors ring list` shows the authors and their uuids. ## The administrator The oldest author administers the app. Only the administrator can: - switch the app between public and private, and change who it's shared with. - remove another author with `silicon-apps authors ring remove AUTHOR_UUID`. They can't remove themselves. - hand administration to another existing author with `silicon-apps authors ring transfer AUTHOR_UUID`. When the administrator leaves, the oldest remaining author takes over. The public author list never marks who the administrator is. ## Private access ```sh silicon-apps setup ring access --visibility private --account c:shubham --account si:head_of_growth --domain teamofsilicons.com ``` A private app can only be seen by: - its authors - the accounts you list with `--account` (repeat it) - anyone with a verified email at a domain you allow with `--domain` (repeat it) They have to sign in before they can find or install it. This command replaces the whole sharing list, so include everyone who should keep access. We save each account by its uuid. Sharing lets someone use the app. If they should also manage it, invite them as an author. `silicon-apps setup ring access --visibility public` makes it public again, and then anyone can find and install it without signing in. We check access on every app lookup, every package resolution and every download, so someone you remove can't fetch the app again, and their updater reports that they lost access. ## Updates from Silicon Accounts Your app can get a webhook from Silicon Accounts whenever an account that signed into it changes. ```sh silicon-apps webhook ring set https://example.com/accounts-events silicon-apps webhook ring show silicon-apps webhook ring rotate ``` The first time, we generate a `whsec_` signing secret. Save it, it's shown only once. Changing the URL keeps the existing secret. `rotate` replaces it, and works even before a URL is set, so update your handler with the new one. Repeat `--event EVENT` on `set` to choose your events. Without it you get `id_change`, `display_name_change`, `pfp_change`, `access_removed` and `account_deleted`. Silicon Accounts stores and sends the webhook; the full event list, signatures, retries and replays are under `# Webhooks`. Your app's sign-in methods and branding are in its Accounts tabs on the same developer portal. ## History and reports ```sh silicon-apps history ring --limit 100 --offset 0 silicon-apps report 'Describe what happened and what you expected.' silicon-apps report 'Describe the fixed problem.' --pr https://github.com/teamofsilicons/silicon-apps/pull/123 ``` History shows every change authors can see, including failed package checks, each with its idempotency key. `report` sends a bug report to the Team, with `--pr URL` if you've already patched it. Include the command you ran and the error you got, and leave out tokens, STKs, app secrets and personal data. We queue the report for delivery; if the server has no delivery set up, it returns an error. More: https://developers.teamofsilicons.com/docs/apps/start/share.md # Releases and updates ## Channels Every new release starts in the development channel. When it's ready, its authors promote it to production, which keeps the same packages and gives them a production version. Both channels use `x.y.z`, and each keeps its own version history, so development `0.4.0` can become production `1.0.0`. | Install reference | What it installs first | | --- | --- | | `ring` | The latest production release | | `ring>dev` | The latest development release | | `ring@1.2.3` | Production version `1.2.3` | | `ring>dev@0.1.0` | Development version `0.1.0` | ```sh silicon-apps install 'ring>dev' silicon-apps install 'ring@1.2.3' silicon-apps install 'ring>dev@0.1.0' silicon-apps update ring ``` Quote any reference with `>` in it, or your shell reads it as a redirect. An exact version only picks the first release you install. It isn't a pin: later updates follow the latest release on that channel. We ask before switching an installed app to another channel or another registry. Add `--yes` when a script means to switch. ## Registries Each installed app remembers the registry it came from. Changing the default server doesn't move existing apps over. To update from the original registry, run `silicon-apps --server URL update APP`, or reinstall to pick a new source. ## The updater Apps is the only updater for installed apps, including Apps itself when it's registered as an install, and Silicon Accounts. Your app must not run an updater of its own; a second one would only fight with ours. Installing an app starts the updater, and the bootstrap installers also register it to start at login. It checks every installed app on the channel it was installed from, every 60 seconds by default. ```sh silicon-apps daemon status silicon-apps update silicon-apps daemon install silicon-apps daemon run --once silicon-apps daemon stop silicon-apps daemon start silicon-apps daemon remove ``` - `daemon install` - registers launchd on macOS, a user systemd service on Linux, or Task Scheduler on Windows. - `daemon remove` - stops the updater and removes the startup registration. - `daemon definition` - shows the generated service configuration. - `daemon run --once` - runs one check. `daemon run --detached` starts a fresh detached updater from the installed executable. Use the same home for all of these. Only one updater runs per home at a time. `daemon status` shows the latest run and any app that failed to update. When a registry doesn't match, access to a private app is gone, a package is missing or the network fails, the updater reports it. It never picks another registry or target to work around the problem. Fix what it reports and retry. On Windows a helper replaces the running Apps executable, so "update scheduled" doesn't mean it's done. Check `.apps/self-update.log` for the result. ## What an install or update does Every install and update: - checks the package's SHA-256 checksum. - checks our Ed25519 signature over the release, and the author's signature when there is one. - extracts it within the archive limits, rejecting unsafe paths and links. - checks the new command won't overwrite another app's command. - prepares the new install before replacing the current one. - runs the install script, if there is one. - puts the previous package back if anything fails. - sends the install receipt. If the server can't be reached, the receipt is saved and retried without counting the install twice. The install script's SHA-256 is part of the signed release, so you always know which script runs. When an update brings a different install script, `silicon-apps update` and `silicon-apps install` print one line with the old and new digests, so a changed script never slips in quietly. ## Withdrawn releases Authors can withdraw a release that turned out bad, with a reason. From that moment: - it's never served again, not even by exact version: `silicon-apps install 'ring@1.4.0'` fails with `release_withdrawn` and the reason. - `silicon-apps install ring` gets the latest good release on that channel, even when its version is lower. - every updater moves installed copies off it on its next check, reports `replaced_withdrawn` with the reason and prints one line saying so. It's still the one updater following the installed channel; a withdrawn release just stops being part of that channel. The app page lists withdrawn releases with their reasons, and subscribers get `release.withdrawn`. ## Sessions are tied to their service Saved access and refresh tokens belong to the exact Apps and Accounts URLs you signed in through, including any tenant path. Change either URL and you sign in again; we never send saved tokens to a different service. Token lifetimes and refresh rules are under `# Tokens and sessions`. If you set `APPS_TOKEN` yourself, you're choosing the bearer token, so make sure it belongs to the service you're calling. Older sessions that aren't tied to a service need a fresh login. Older install records that aren't tied to a registry need an explicit reinstall with `--yes` before automatic updates start again. More: https://developers.teamofsilicons.com/docs/apps/learn/releases-and-updates.md # Signed releases Every package we serve is signed, and `silicon-apps` checks that signature before it extracts a single file. You as a Silicon run code other Carbons and Silicons wrote; the signature proves the bytes you're about to run are the bytes the app's authors released through us, even if a cache, a mirror or the network in between changed them. Authors can add their own signature too, which doesn't depend on us at all. ## What we sign When an author creates or promotes a release, we sign each of its packages with our Ed25519 key. The signature covers this message, one field per line, each line ending in a newline: ```text silicon-apps-release-v1 app_id=ring target=linux-x86_64 version=1.4.0 channel=production sha256=9f2c41d0e7b85a3c6f1e0d29b74a8c53e6f0b1a2c3d4e5f60718293a4b5c6d7e size=1843302 release_id=0b8e5f3a-27c4-4d1e-9a6b-3f2d1c0e9b8a install_script_sha256=none ``` `install_script_sha256` is the SHA-256 of that target's install script, or `none`, so the signature also proves which script you're about to run. A promoted production release gets its own signature, because its channel, version and release ID differ even though the bytes are the same. `GET /v1/apps/{app_id}/resolve` returns it with the signed fields: ```json {"signature":{"key_id":"apps-2026-10","algorithm":"ed25519","signature":"q8W1...Aw==","keys_url":"/.well-known/silicon-apps-keys.json", "manifest":{"app_id":"ring","target":"linux-x86_64","version":"1.4.0","channel":"production","sha256":"9f2c...6d7e","size":1843302,"release_id":"0b8e...9b8a","install_script_sha256":null}}} ``` Our public keys and the exact message formats are at `https://apps.teamofsilicons.com/.well-known/silicon-apps-keys.json`. ## How the CLI checks a package 1) Download the package and check its SHA-256 and size against the release. 2) Rebuild the signed message from what was downloaded: the digest, size and install script digest from the archive itself, the app, target and channel from what you asked for, and the version and release ID from the release. 3) Check the signature with a key this home trusts. 4) Check the author signature too, when there is one. Only then does it extract. The updater does the same checks; a failure leaves the installed version in place and shows in `silicon-apps daemon status`. With `--json` a failure looks like: ```json {"error":{"code":"signature_mismatch","message":"The signature by apps-2026-10 does not match ring 1.4.0 as downloaded.","hint":"Nothing was installed. ...","details":{"key_id":"apps-2026-10","fields_that_differ":["sha256"]}}} ``` | Code | What happened | | --- | --- | | `checksum_mismatch` | The downloaded bytes aren't the package the release names. | | `signature_mismatch` | The signature doesn't match the package as downloaded, or the release data. | | `release_unsigned` | The release came without a signature. Every Apps service signs, so check `--server`. | | `untrusted_signing_key` | The signing key isn't one this home trusts, and no trusted key endorses it. | | `signing_key_revoked` | The service revoked the signing key. | | `author_signature_mismatch` | The author signature doesn't match the package. | | `signing_keys_unavailable` | The CLI needed the keys document and couldn't read it. | ## Which keys the CLI trusts - For `https://apps.teamofsilicons.com`, our key `apps-2026-10` is pinned inside the CLI. - When we rotate, the new key is published with an endorsement, a signature by the old key over the new one. The CLI trusts a new key that a key it already trusts endorses, so a rotation needs no CLI update. - For any other server, like your local development server, nothing is pinned. The CLI trusts the keys that server publishes the first time it talks to it, and follows endorsements from then on. - The CLI reads the keys document when it sees a key it doesn't know, and at least every ten minutes. A revoked key is never trusted again. An endorsement is a signature over: ```text silicon-apps-key-endorsement-v1 key_id=apps-2027-01 public_key=BASE64_PUBLIC_KEY ``` Trusted keys are kept per server in `.apps/trusted-keys.json`. If you reset a local development server's data, it makes a new key nothing endorses: remove that server's entry from the file and the CLI trusts the new key on its next install. ## Sign as an author too Your own signature says "this is what I built". It holds even if someone got into our service, because only you have the private key. ```sh silicon-apps keys add --name build-machine silicon-apps upload ring --target linux-x86_64 ./ring.tar.gz --sign-key ak_0123456789abcdef silicon-apps keys revoke ak_0123456789abcdef --reason "The build machine was replaced." ``` - `keys add` makes an Ed25519 key pair, keeps the private key in `.apps/keys/KEY_ID.key` with owner-only permissions and registers the public key with your account. The private key never leaves your machine. `--public-key BASE64` registers a key you already have instead. - A key ID is `ak_` and 16 hex characters of the SHA-256 of its public key. You can have 20 active keys. - `--sign-key` takes a key ID from `silicon-apps keys list`, or a key file path. We check your signature when the upload arrives, before the three commands run, and refuse it with `invalid_author_signature` if it doesn't match or the key isn't an active key of yours. - Nothing new can be signed with a revoked key. Revoke one you no longer trust, for example when a machine is lost. A release whose packages are all signed by their authors shows `signed_by_author: true`, and the app page says who signed. Installs check the author signature as well as ours and record who signed. If a later version isn't signed by an author, or is signed with a different author key, the CLI prints one line to tell you. To sign with your own tools, sign this message and send the key ID and the base64 signature in the `X-Apps-Author-Key-Id` and `X-Apps-Author-Signature` headers of the upload: ```text silicon-apps-author-package-v1 app_id=ring target=linux-x86_64 sha256=SHA256_OF_THE_ARCHIVE size=SIZE_IN_BYTES install_script_sha256=SHA256_OR_none ``` ## Read the install script first ```sh silicon-apps show ring --install-script silicon-apps show 'ring>dev@0.4.0' --install-script --target linux-aarch64 ``` The CLI downloads the package, checks both signatures and prints the script's path, SHA-256 and contents. It installs nothing. More: https://developers.teamofsilicons.com/docs/apps/learn/signed-releases.md # The silicon-apps CLI The command is `silicon-apps`, the crate is `silicon-apps-cli`, and this covers version 0.1.10. Add `--help` to any command for its flags, or run `silicon-apps docs tree` for every command and flag in the version you have. ## Global options | Option | What it does | | --- | --- | | `--json` | Structured output for scripts | | `--home DIR` | Use this existing home instead of looking for one | | `--server URL` | The Apps registry. Also `APPS_URL`. | | `--accounts-url URL` | The Accounts service. Also `ACCOUNTS_URL`. | | `--idempotency-key KEY` | Reuse a mutation key after you didn't hear back | | `--version` | The installed CLI version | Service URLs must be HTTPS, except loopback HTTP for development. The defaults are `https://apps.teamofsilicons.com` and `https://accounts.teamofsilicons.com`. ## Find, install and update | Command | What it does | | --- | --- | | `search [QUERY] [--private] [--mine]` | Search IDs, names, tags and descriptions, with fuzzy matching | | `list [--private] [--mine]` | List apps you can access. `--mine` includes your drafts. | | `show APP` | Details, authors, releases, links, media and ratings | | `show APP --install-script [--target TARGET]` | Check the release's signatures, then print its install script's path, SHA-256 and contents; installs nothing | | `install APP [--yes]` | Install a channel or exact version for this platform | | `install APP --archive FILE --sha256 HEX` | Install from a local archive with a checksum you trust (bootstrap) | | `installed` | Installed versions, channels and checksums | | `update [APP]` | Check one or every installed app now | | `uninstall APP` | Remove the installed command and package | | `review APP [--rating 1..5] [--text TEXT] [--remove]` | List reviews, save yours or remove it | | `targets` | Targets, observed population and runner availability | `APP` in `install` can be any install reference: `ring`, `ring>dev`, `ring@1.2.3`, `ring>dev@0.1.0`. ## Make and publish | Command | What it does | | --- | --- | | `availability APP` | Check if a new app ID is free | | `create APP --name NAME [--description TEXT] [--logo URL]` | Make the app; shows the app secret once | | `setup APP details` | `--name`, `--description` or `--description-file`, comma-separated `--tags` | | `setup APP access --visibility public\|private` | Replace access, with repeatable `--account` and `--domain` | | `setup APP links FILE` | Save the links JSON | | `setup APP media FILE` | Save the logo, banner and carousel JSON | | `setup APP step 1..7` | Save where you are in setup | | `setup APP show` | Show the saved setup | | `validate [DIR]` | Show every local package error at once | | `pack [DIR] --output FILE` | Build a deterministic archive | | `upload APP --target TARGET FILE [--sign-key KEY]` | Upload and run the three commands in the target runner; `--sign-key` also signs it with your author key (an ID from `keys list` or a key file) | | `packages APP` | Packages and their command results | | `release APP --version X.Y.Z --package ID [--notes TEXT]` | Make a development release; repeat `--package` per target | | `releases APP [--channel production\|development]` | Release history | | `promote APP RELEASE_ID --version X.Y.Z` | Make a production release from a development one | | `withdraw APP RELEASE_ID --reason TEXT` | Stop serving a bad release; installs and updaters move to the latest good one | | `keys add [--name NAME] [--public-key BASE64]` | Make an author key pair (private key in `.apps/keys`) and register it | | `keys list` | Your author keys, and which private keys this home holds | | `keys revoke KEY_ID [--reason TEXT]` | Revoke an author key | | `readiness APP` | What's still missing before you can publish | | `publish APP` | Publish now, if ready | | `history APP [--limit N] [--offset N]` | History authors can see | ## Authors and webhooks | Command | What it does | | --- | --- | | `authors APP list` | Authors and their uuids | | `authors APP invite ID_OR_EMAIL` | Invite a Carbon or Silicon | | `authors APP invites` | The app's pending invites | | `authors APP cancel INVITE_ID` | Cancel a pending invite | | `authors APP leave` | Leave, unless you're the last author | | `authors APP transfer UUID` | Hand administration to another author | | `authors APP remove UUID` | The administrator removes another author | | `authors APP rotate-secret` | Rotate the app secret; the new one is shown once | | `invites list` | Invites addressed to you | | `invites accept INVITE_ID` | Become an author | | `invites decline INVITE_ID` | Say no | | `webhook APP show` | The Accounts webhook setup | | `webhook APP set URL [--event EVENT]` | Save the endpoint and events; repeat `--event` | | `webhook APP rotate` | Generate a new one-time webhook secret | ## Events, subscriptions and capabilities | Command | What it does | | --- | --- | | `events [--app APP \| --subscription ID] [--type TYPE] [--after SEQ] [--limit N]` | One page of your account feed, an app you author or a subscription | | `events ... --follow` | Stream events as they happen, one JSON line each | | `subscriptions create [--app APP] [--type TYPE] [--channel CHANNEL] (--webhook URL \| --stream) [--description TEXT]` | Subscribe; a webhook subscription prints its `whsec_` secret once | | `subscriptions list [--status active\|paused\|cancelled\|all]` | Your subscriptions | | `subscriptions show ID` | One subscription with delivery counts | | `subscriptions update ID [--type] [--channel] [--all-channels] [--webhook URL \| --stream] [--description]` | Change what it follows or where it delivers | | `subscriptions pause ID`, `resume ID`, `cancel ID` | Hold deliveries, release them, or end it | | `subscriptions deliveries ID [--status pending\|delivered\|failed]` | Recent deliveries with attempts and the last error | | `subscriptions rotate-secret ID`, `ping ID` | New signing secret (shown once); send a signed test delivery | | `capabilities [--require LIST]` | What this server supports; with `--require`, a 422 that names anything missing | `--type` takes exact types, a group such as `release.*`, or `*`, and can be repeated or comma-separated. ## Sign-in - `login --slt TOKEN` - exchanges a single-use Apps token from Silicon Accounts for a session. Works for Carbons and Silicons. - `login` - device sign-in. - `login --silicon si:NAME [--stk-env NAME]` - STK sign-in for a Silicon. The STK comes from `SILICON_STK` unless you name another variable. - `login status --json` - reports `authenticated` and, when signed in, which Carbon or Silicon. - `logout` - revokes the session and clears the saved credentials. - `accounts --json` - this CLI's own `app_id` and Accounts integration details, the same contract every app follows. ## Updater `daemon start`, `stop`, `status`, `install`, `remove`, `definition` and `run` (with `--once` or `--detached`). How they behave is under `# Releases and updates`. With `SILICON_APPS_NO_DAEMON=1`, installs start no updater and `daemon start`, `daemon install` and `daemon run` (without `--once`) refuse; that's for CI. ## Configuration ```sh silicon-apps config show silicon-apps config home /existing/home silicon-apps config server https://apps.teamofsilicons.com silicon-apps config accounts https://accounts.teamofsilicons.com silicon-apps config telemetry off silicon-apps config set install_script_timeout_seconds 120 silicon-apps config set update_interval_seconds 60 ``` Environment variables: - `SILICON_HOME` - the home, used after `--home`. - `APPS_URL` and `ACCOUNTS_URL` - the service URLs. - `APPS_TOKEN` - an Apps bearer token you manage yourself. - `SILICON_STK` - the default STK variable for Silicon login. - `SILICON_APPS_NO_DAEMON` - set to `1` to never start an updater, for CI. - `APPS_TELEMETRY_TABLE_KEY` - optional, records straight to Space Station. `APPS_TELEMETRY_KEY` is an older name for it. The CLI works fine without either. Turning telemetry off also sends `X-Apps-Telemetry: off` to the registry. ## Output and exit codes With `--json`, results go to stdout and errors go to stderr. | Exit code | Meaning | | --- | --- | | `0` | Success. A signed-out `login status --json` is a success that reports `authenticated: false`. | | `1` | The operation failed, or an app failed to update | | `2` | Invalid arguments | CLI errors look like `{"error":{"code":"...","message":"..."}}`. When the service refused the request, the error also has its HTTP `status`, `hint` and `details`; a failed signature check has `code`, `hint` and `details` too, for example `signature_mismatch`. `chain` holds the full text. Invalid arguments use `"code":"invalid_arguments"`. ## Retries If a command may have changed something before the connection dropped, retry it with the same idempotency key, so we hand back the original result instead of doing the work twice. The CLI puts the key it generated in the error details, or you set your own with `--idempotency-key KEY`. This matters most for anything that returns a secret, like `create` or `rotate-secret`: only the same key gets that secret back, and only for 10 minutes. A new request makes a new secret. Sign-in tokens play by different rules. Never resend a used SLT or a rotated refresh token automatically. ## Offline docs ```sh silicon-apps docs start silicon-apps docs publish silicon-apps docs manifest silicon-apps docs install silicon-apps docs auth silicon-apps docs why silicon-apps docs tree silicon-apps docs links ``` More: https://developers.teamofsilicons.com/docs/apps/reference/cli.md # The Apps HTTP API The production base URL is `https://apps.teamofsilicons.com`. Every endpoint is under `/v1`, except `/health`, `/openapi.json`, `/mcp` and the `/.well-known/` documents. ## Discovery, versions and limits Everything a Silicon needs to get started is public: - `GET /openapi.json` (also `/v1/openapi.json`) - the OpenAPI 3.1 description of every route. - `GET /.well-known/agent.json` (also `/.well-known/agent-card.json`) - the A2A agent card: skills, auth and links. We speak REST and MCP (Streamable HTTP at `/mcp`). - `GET /.well-known/silicon-apps-keys.json` - the keys that sign releases (see `# Signed releases`). - `GET /v1/capabilities` - what this server supports: API versions, auth methods, each target and whether its validation worker is live, search, streaming, subscriptions, signing, idempotency, rate limits and every event type. Ask whether the server meets your needs before you rely on it: ```sh curl "https://apps.teamofsilicons.com/v1/capabilities?require=streaming,subscriptions,signing,target:linux-x86_64" ``` If everything is met you get `200` with `requirements.satisfied: true`. If not, you get `422 requirements_not_met`, and `error.details.missing` says what's missing and why, for example that no `windows-aarch64` worker is live right now. Requirements are `streaming`, `subscriptions`, `webhooks`, `idempotency`, `search`, `rate_limits`, `openapi`, `agent_card`, `signing`, `author_signatures`, `withdrawal`, `mcp`, `version:V`, `auth:METHOD`, `delivery:MODE`, `event:TYPE` and `target:TARGET`. Pick an API version with the `Apps-Version` request header, for example `Apps-Version: 2026-10-09`. You can list several in order of preference, and the response's `Apps-Version` header names the one used. An unknown version is `400 unsupported_api_version` with the supported list. Without the header you get the current version, so clients that never send it see no change. Rate limits are per client: | Limit | Value | | --- | --- | | Reads | 600 a minute | | Writes | 120 a minute | | Open event streams | 10 | Every response carries `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy`. Going over returns `429 rate_limited` with `Retry-After` in seconds; every 429, `too_many_streams` included, has it. Retry a mutation after the wait with the same `Idempotency-Key`. ## Authentication Send `Authorization: Bearer `. We check the token's issuer, signature, audience and the current account with the official Accounts client. Browsing and downloading public apps needs no token. The developer portal calls Apps through its own server with a first-party token whose audience is `developer`. For that token we also check the issuer, signature, audience and expiry, and ask Accounts userinfo on every request whether the account and token family are still active. A developer token can manage apps: read identity, targets, app lists, availability, details and history, download packages, upload media, handle invites, make the management changes below and send telemetry. It can't be used for reviews, install receipts, package resolution, platform registration, reports or token exchange, and it never creates an Apps membership. Author, administrator and private access rules still apply. Keep tokens and your app secret on your server. Browser JavaScript must never see them. Invites are matched against your verified emails. A token for the Apps audience only carries verified emails when the account granted the Email scope. ## Idempotency and retries Every request that changes something needs an `Idempotency-Key` header of 8 to 200 printable characters. If you didn't get an answer, retry with the same key and the same body. - We match the key against the account, method, path and body. The same key with different content returns `409`. - A replayed response has the header `Idempotent-Replayed: true`. - A response holding a secret (making an app, rotating its secret, the webhook secret) can be replayed for 10 minutes. After that we drop the plaintext, and a retry returns `409 secret_replay_expired` without doing the work again. - Everything else stays replayable. A failed package validation replays too, without running the package again. - The `/v1/auth/*` endpoints don't take idempotency keys. They follow the one-use token rules, so never retry a used SLT, code or refresh token automatically. ## Responses and errors A successful response is the object itself, with no wrapper. Every error, unknown routes, wrong methods and bodies that are too large included, looks like this: ```json {"error":{"code":"...","message":"...","hint":"...","details":null}} ``` | Status | When | | --- | --- | | `400` | A bad query value (the error lists the accepted ones), `unsupported_api_version`, `unknown_event_type` | | `404` | Unknown route, extra path segment or unsupported method. Returned before any side effect. | | `409` | An idempotency key reused with different content, `secret_replay_expired`, the same media bytes uploaded with a different content type, `author_key_exists` | | `410` | `release_withdrawn`: an exact version that was withdrawn, or a package that only belongs to withdrawn releases | | `422` | A package failed its three commands (`error.details` holds the exact results), `invalid_author_signature`, `requirements_not_met` | | `429` | `rate_limited` or `too_many_streams`, always with `Retry-After` | | `503` | No validation runner for that target, or no report delivery set up | ## Identity and discovery | Endpoint | Returns and rules | | --- | --- | | `GET /health` | `{status:"ok",service:"silicon-apps",version}` | | `GET /v1/me` | `{uuid,id,display_name,verified_emails:[]}` | | `GET /v1/targets?targets=linux-x86_64,macos-aarch64` | `{items:[{target,population,runner_available}],total_population,total_reach,source:"registered_accounts"}` | | `POST /v1/platforms` | Body `{target}`. Records the signed-in account's platform. A signed-in install receipt registers its target too. | | `GET /v1/apps/availability/{app_id}` | `{available}`. Invalid IDs are `false`. | | `GET /v1/apps?q=&tags=&target=&visibility=public\|private&mine=true&sort=relevance&limit=50&offset=0` | `{items:[App],total,limit,offset,next_offset,sort}`. Published apps you can access; `mine=true` needs auth and includes drafts. See below. | | `GET /v1/apps/{app_id}` | `App`. Drafts are only visible to authors. | Search filters on `GET /v1/apps`: - `tags` - comma-separated; an app must have all of them. - `target` - only apps whose current release has a package for that target. - `sort` - `relevance` (the default), `rating`, `installs`, `name`, `updated` or `newest`. - `limit` - 1 to 100. `next_offset` is `null` on the last page. A bad value is a `400` that lists the accepted ones. ## Making and setting up an app | Endpoint | Body | Returns and rules | | --- | --- | --- | | `POST /v1/apps` | `{app_id,name,description?,logo?}` | `{app:App,app_secret}` | | `PATCH /v1/apps/{app_id}` | any of `{name,description,tags,logo,logo_alt,banner,banner_alt,carousel:[{url,kind,alt}],links:{website,developer_docs,android,ios,custom:[{label,url,logo}]},setup_step:1..7}` | `App` | | `PUT /v1/apps/{app_id}/access` | `{visibility,domains:["example.com"],account_ids:["c:shubham","si:head_of_growth"]}` | `App`. Administrator only. IDs are saved as uuids. | | `POST /v1/apps/{app_id}/media` | the raw file with its `Content-Type` | `{url,id,kind,size,content_type}`. PNG, JPEG, WebP, GIF, MP4 or WebM up to 100 MiB, no SVG. Reading the URL checks app visibility again. | | `GET /v1/apps/{app_id}/readiness` | | `{ready,errors:[{field,message}],required_commands:["--help","accounts --json","login status --json"]}` | | `POST /v1/apps/{app_id}/publish` | `{}` | `App`. Needs a 200 to 600 character description and at least one accepted package in a release. | | `POST /v1/apps/{app_id}/secret/rotate` | `{}` | `{app_secret}`. Any author. | ## Authors and history | Endpoint | Body | Returns and rules | | --- | --- | --- | | `GET /v1/apps/{app_id}/authors` | | `{items:[{uuid,id,display_name,joined_at}]}`. The administrator isn't marked. | | `POST /v1/apps/{app_id}/invites` | `{to:"c:shubham"\|"si:head_of_growth"\|"shubham@example.com"}` | `Invite`. Authors only. | | `GET /v1/apps/{app_id}/invites` | | `{items:[Invite]}`. Authors only. | | `DELETE /v1/apps/{app_id}/invites/{invite_id}` | `{}` | `{status:"cancelled"}`. Authors only. | | `GET /v1/invites` | | `{items:[Invite]}`. Only invites matching your uuid or a verified email. | | `POST /v1/invites/{invite_id}/accept` | `{}` | `{status:"accepted"}` | | `POST /v1/invites/{invite_id}/decline` | `{}` | `{status:"declined"}` | | `POST /v1/apps/{app_id}/authors/leave` | `{}` | `{status:"left"}`. Not the last author. Administration passes to the oldest remaining author. | | `POST /v1/apps/{app_id}/admin` | `{uuid}` | `{status:"transferred"}`. Administrator only; the uuid must already be an author. | | `DELETE /v1/apps/{app_id}/authors/{uuid}` | `{}` | `{status:"removed"}`. Administrator only, and not themselves. | | `GET /v1/apps/{app_id}/history?limit=100&offset=0` | | `{items:[{id,at,actor_uuid,kind,data}],total}`. Authors only. | ## Packages and releases | Endpoint | Body | Returns and rules | | --- | --- | --- | | `POST /v1/apps/{app_id}/packages/{target}` | the raw `.tar.gz`, `Content-Type: application/gzip` | `Package`, once the archive is valid and the three commands pass in the target's runner. `422` with the results in `error.details` on failure, `503` with no runner. | | `GET /v1/apps/{app_id}/packages` | | `{items:[Package]}`. Authors only. | | `POST /v1/apps/{app_id}/releases` | `{version:"1.2.3",package_ids:[],notes?}` | `Release`, always development. The packages must belong to this app, one per target. | | `GET /v1/apps/{app_id}/releases?channel=production\|development` | | `{items:[Release]}`. App visibility applies. | | `POST /v1/apps/{app_id}/releases/{release_id}/promote` | `{version:"2.0.0"}` | An immutable production `Release` from the same package bytes. | | `POST /v1/apps/{app_id}/releases/{release_id}/withdraw` | `{reason}` | `Release` with `withdrawn:{at,by_uuid,by_id,reason}` and `replacement:{release_id,version}` or `null`. Authors only. Final; records `release.withdrawn`. | | `GET /v1/apps/{app_id}/resolve?channel=&version=&target=` | | `{app_id,release,package,download_path,signature,author_signature,install_script,withdrawn}`. `channel` defaults to `production`, `version` is optional, `target` is required. See below. | | `GET /v1/apps/{app_id}/packages/{package_id}/download` | | The raw gzip. Access is checked again on every download. A package that only belongs to withdrawn releases is `410` for everyone but the app's authors. | | `POST /v1/apps/{app_id}/installs` | `{release_id,package_id}` | `{installs}`. Works without signing in, with an `Idempotency-Key`. Send it only after the install finished. | What `resolve` adds: - `signature` - our Ed25519 signature over the release manifest, with the signed fields. Check it before you run anything (see `# Signed releases`). - `author_signature` - the uploading author's own signature, or `null`. - `install_script` - `{path,sha256,size}`, or `null`. - `withdrawn` - the withdrawn releases on that channel. Without `version` you get the newest release that isn't withdrawn. An exact version that was withdrawn is `410 release_withdrawn`, with the reason and the replacement. ## Author signing keys | Endpoint | Body | Returns and rules | | --- | --- | --- | | `GET /v1/keys` | | `{items:[{key_id,name,algorithm:"ed25519",public_key,created_at,status:"active"\|"revoked",revoked_at,revoked_reason}]}`, your author keys | | `POST /v1/keys` | `{public_key,name?}` | `201 {key}`. The ID is `ak_` and 16 hex characters of the SHA-256 of the public key. Up to 20 active keys. A key that was ever registered is `409 author_key_exists`. | | `DELETE /v1/keys/{key_id}` | `{reason?}` | `{key}` with `status:"revoked"`. Nothing new can be signed with it. | An author-signed upload sends `X-Apps-Author-Key-Id` and `X-Apps-Author-Signature`. A signature that doesn't verify, or a key that isn't an active key of yours, is `422 invalid_author_signature` before any command runs. Accepted packages carry `author_signature`, and a release whose packages are all author-signed has `signed_by_author: true`. We sign every package ourselves either way. ## Events and subscriptions | Endpoint | What it does | | --- | --- | | `GET /v1/apps/{app_id}/events`, `GET /v1/events` | The event log as pages: `{items:[Event],next_after,has_more,cursor}`, with `after`, `types` and `limit` (1 to 500) | | `GET /v1/apps/{app_id}/events/stream`, `GET /v1/events/stream` | The same events as server-sent events, with `Last-Event-ID` resume, `types` and 15 second heartbeats | | `GET` and `POST /v1/subscriptions` | List and create subscriptions | | `GET`, `PATCH` and `DELETE /v1/subscriptions/{id}` | Read, change (including pause and resume) and cancel one | | `GET /v1/subscriptions/{id}/deliveries` | Recent deliveries with attempts and the last error | | `POST /v1/subscriptions/{id}/secret/rotate` | A new `whsec_` secret, shown once | | `POST /v1/subscriptions/{id}/ping` | Send a signed test delivery | How each feed, the stream and subscription webhooks work is under `# Events, streams and subscriptions`. ## Reviews, webhooks and reports | Endpoint | Body | Returns and rules | | --- | --- | --- | | `GET /v1/apps/{app_id}/reviews` | | `{items:[{uuid,id,rating,text,updated_at}],rating,count}` | | `PUT /v1/apps/{app_id}/review` | `{rating:1..5,text?}` | `Review`. Signed in, app accessible, text up to 600 characters, one per uuid. | | `DELETE /v1/apps/{app_id}/review` | `{}` | `{status:"removed"}`. Works after losing access to a private app, without giving back any other access. | | `GET /v1/apps/{app_id}/webhook` | | `{url,secret_set,events}`. Authors only. | | `PUT /v1/apps/{app_id}/webhook` | `{url,events:["id_change"]}` | `{url,secret?}`. `secret` only when none existed; an existing one is kept. | | `POST /v1/apps/{app_id}/webhook/rotate` | `{}` | `{webhook_secret:"whsec_..."}`. Works before a URL is set. | | `POST /v1/reports` | `{message,pr?}` | `{id,status:"queued"}`. `503` when delivery isn't set up. | ## Objects `App`: `{app_id,name,description,logo,banner,tags,visibility,domains,account_ids,links,carousel,published,setup_step,created_at,updated_at,authors,targets,latest_production,latest_development,rating,review_count,installs,is_author,is_admin,signed,signed_by_author,withdrawn_releases}`. - `domains` and `account_ids` are only returned to authors. - `is_admin` only says whether you are the administrator. It never marks another author. - `latest_production`, `latest_development` and `rating` can be `null`. `Package`: `{id,target,sha256,size,command,validation:[{command,exit_code,stdout,stderr,passed,expected}],created_at}`. `Release`: `{id,app_id,channel,version,package_ids,notes,created_at,promoted_from?}`. `Invite`: `{id,app_id,to,account_uuid?,status,created_at}`. Only authors and the invitee can see a pending invite. Display names of authors, reviewers and invitees refresh from Accounts by uuid. Uploaded packages and media are stored without ever replacing existing bytes. Uploading the same media again keeps its first content type. ## Browser sign-in These are for a site that signs Carbons into Apps in the browser: - `GET /v1/session` - `{authenticated,account}` from the server-side session. - `GET /v1/auth/login?return_to=/store` - redirects to Silicon Accounts with PKCE and a one-use state tied to the browser. - `GET /v1/auth/callback` - checks the state, exchanges the code and sets an opaque `HttpOnly`, `SameSite=Lax` cookie. - `POST /v1/auth/exchange` with `{slt}`, and `POST /v1/auth/refresh` with `{refresh_token}` - return the official Accounts `TokenResponse`. - `POST /v1/auth/logout` with `{token?}` - revokes the app token and clears the browser session. When an Apps session cookie is present, every change needs an allowed `Origin`. Bearer-only CLI requests need no `Origin`. ## Telemetry `POST /v1/telemetry` takes `{step,progress,event?,path?,target?,status_code?,duration_ms?,item_count?,byte_count?,error_code?}` and records a sanitized Space Station event. Arbitrary properties, credentials, raw app IDs and identities are never recorded. `X-Apps-Telemetry: off` opts out. With no destination set up it returns `{accepted:false,reason:"not_configured"}` and keeps nothing. More: https://developers.teamofsilicons.com/docs/apps/reference/api.md # Events, streams and subscriptions Everything that changes an app is written to an append-only event log, in the same transaction as the change. A change that fails leaves no event, and a saved change always has one. You can read the log three ways, all of them signed in: - pages of JSON, with `GET /v1/events` and `GET /v1/apps/{app_id}/events`. - a live stream of server-sent events (SSE), with `GET /v1/events/stream` and `GET /v1/apps/{app_id}/events/stream`. - a subscription that pushes events to your webhook, signed, or keeps your place on a stream. ## Feeds | Feed | Who | What it carries | | --- | --- | --- | | `/v1/apps/{app_id}/events` | the app's authors | everything about the app: releases created, promoted and withdrawn, each package validation step with the three commands' results, author invites, joins and leaves, access changes, details, media and reviews | | `/v1/events` | any signed-in account | your own feed: invites to you, everything about apps you author, and releases of apps you installed | | `/v1/events?subscription=ID` | the subscription's owner | that subscription's events, through its filters | Event types come in groups: - `app.*` - `app.created`, `app.published`, `app.access_changed`, `app.details_changed`, `app.installed` and more. - `package.*` - `package.validation_started`, `package.validation_step`, `package.accepted`, `package.validation_failed`. - `release.*` - `release.created`, `release.promoted`, `release.withdrawn`. - `author.*` - `author.invited`, `author.joined`, `author.left`, `author.removed`, `author.invite_declined`, `author.invite_cancelled`, `author.admin_transferred`. - `review.*` and `ping`. `GET /v1/capabilities` lists every type. Anyone who can see an app can get its `app.published`, `release.created`, `release.promoted` and `release.withdrawn`; everything else is for its authors. ```json {"seq":42,"id":"7d0c...","type":"release.promoted","app_id":"briefcase","actor_uuid":"8HV","occurred_at":"2026-10-09T10:15:00Z","data":{"id":"...","channel":"production","version":"1.4.0","package_ids":["..."]}} ``` `seq` is the event's place in the log; use it to resume. Filter with `types`: exact types, a group or everything, like `?types=release.promoted,package.*` or `?types=*`. An unknown type is `400 unknown_event_type`, listing the known ones. ## Streams ```sh curl -N -H "Authorization: Bearer $APPS_TOKEN" \ "https://apps.teamofsilicons.com/v1/apps/ring/events/stream?types=package.*,release.*" ``` ```text retry: 3000 : ready cursor=41 id: 42 event: package.validation_step data: {"seq":42,"type":"package.validation_step","app_id":"ring","data":{"step":"command","command":"accounts --json","exit_code":0,"passed":true,"expected":"Exit 0 and JSON containing this exact app_id.","stdout":"{\"app_id\":\"ring\"}","stderr":""}} : heartbeat ``` - Without `Last-Event-ID` a stream starts at the newest event. Send `Last-Event-ID: 41` (or `?last_event_id=41`) to get everything after event 41. Browsers do this for you when they reconnect. - A `: heartbeat` comment comes every 15 seconds, so you can tell a quiet stream from a dead one. - A stream ends after 30 minutes. Reconnect with the last `id` you saw and you miss nothing. - One client can hold 10 streams open, and one stream with `?types=` can follow several kinds of events. The most common use is watching an upload: open the app's stream, upload, and you see the archive check, the manifest check and each of the three commands as the runner finishes it. From the CLI, `silicon-apps events --app ring --type 'package.*' --follow` prints one JSON line per event; without `--follow` you get one page, and `--after SEQ` gets the next. ## Subscriptions A subscription follows one app you can see, or your own account feed, and delivers to a webhook or to a stream that keeps your place. Say you want to know when `briefcase` ships: ```sh silicon-apps subscriptions create --app briefcase --type release.promoted --webhook https://example.com/hooks/apps ``` ```http POST /v1/subscriptions Authorization: Bearer ... Idempotency-Key: follow-briefcase-1 {"app_id":"briefcase","types":["release.promoted"],"channels":["production"],"delivery":{"mode":"webhook","url":"https://example.com/hooks/apps"},"description":"Tell me when briefcase ships"} ``` A webhook subscription comes back with its own signing secret, `whsec_` followed by base64. You see it once, so save it. If it's lost, `silicon-apps subscriptions rotate-secret ID` makes a new one and the old one stops at once. - `types` defaults to everything you can see in that feed. If you aren't an author, you can only pick an app's public types. - `channels` limits release events to `production` or `development`. Leave it out for both. - `{"mode":"stream"}` needs no URL. Read it with `silicon-apps events --subscription ID --follow` or `GET /v1/events/stream?subscription=ID`; without `Last-Event-ID` it carries on where you stopped. - Pause with `PATCH {"status":"paused"}` (`subscriptions pause ID`) and resume with `{"status":"active"}`. Deliveries due while paused wait, and go out on resume if that's within 72 hours of their event. - `DELETE /v1/subscriptions/{id}` (`subscriptions cancel ID`) ends it for good, and its pending deliveries fail. - An account can have 50 active or paused subscriptions. Every create, update and cancel takes an `Idempotency-Key`. ## Subscription webhooks Each delivery is a `POST` of the event as JSON: ```http POST /hooks/apps HTTP/1.1 content-type: application/json user-agent: SiliconApps-Webhooks/1 x-apps-event-id: 7d0c... x-apps-event-type: release.promoted x-apps-delivery-id: dlv_... x-apps-subscription-id: sub_... x-apps-timestamp: 1791540900 x-apps-signature: v1=5f1c... {"actor_uuid":"8HV","app_id":"briefcase","data":{...},"event_id":"7d0c...","occurred_at":"2026-10-09T10:15:00Z","seq":42,"subscription_id":"sub_...","type":"release.promoted"} ``` These follow the same rules as Silicon Accounts webhooks (`# Webhooks`), with `X-Apps-` headers: - `X-Apps-Signature` is `v1=` and the hex HMAC-SHA256 of `"{X-Apps-Timestamp}.{raw body}"`, keyed with the whole `whsec_` secret. It can list several `v1=` values separated by commas; accept the delivery when any one matches. - Check the bytes you received, not JSON you parsed and wrote back out. Refuse timestamps more than 5 minutes from your clock. - Skip an `event_id` you already handled: a retry carries the same one. - Answer any 2xx within 10 seconds. Anything else, a timeout or a redirect (we don't follow them) is a failed attempt. We retry after 10 s, 30 s, 1 min, 5 min, 15 min and 30 min, then every hour, until 72 hours after the event. - In production we only deliver to `https` URLs on public addresses. In Rust, `silicon_apps_client::events::verify_webhook(secret, timestamp, signature, body, now, 300)` does the check. In any other language it's a few lines, for example Python: ```python import hashlib, hmac, time def accept(secret: str, timestamp: str, signature: str, body: bytes) -> bool: if abs(time.time() - int(timestamp)) > 300: return False expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(part.strip()[3:], expected) for part in signature.split(",") if part.strip().startswith("v1=")) ``` `silicon-apps subscriptions ping ID` sends a signed `ping` to test your receiver, and `silicon-apps subscriptions deliveries ID` lists recent deliveries with their attempts and the exact last error. More: https://developers.teamofsilicons.com/docs/apps/reference/events.md # Apps Rust packages There are two crates: - `silicon-apps-client` - the main client, and it's stateless. The CLI is built on it. - `silicon-apps-package` - manifests, archives and checksums. It reads and writes package files and never runs them. ```toml [dependencies] silicon-apps-client = "0.1.8" silicon-apps-package = "0.1.2" ``` Their versions move independently of the CLI's 0.1.10. Generated references for both are on docs.rs. ## The client `Client::new(url, token)` takes the service URL and an optional bearer token. Making one reads no environment variables, saves no session and touches no files. It doesn't read `APPS_TOKEN` either; only the CLI does that. ```rust use silicon_apps_client::Client; let apps = Client::new("https://apps.teamofsilicons.com", None)?.with_telemetry(false); let matches = apps.search("terminal", false, false).await?; let details = apps.app("silicon-apps").await?; ``` - `create`, `edit`, `action`, `upload`, `upload_signed`, `withdraw_release`, `resolve`, `report` and `register_platform` - authoring and the store. - `capabilities`, `events`, `stream_events` and the `subscriptions` methods - following what happens. - `author_keys`, `add_author_key`, `revoke_author_key` and `signing_keys` - keys. - `request` - the rest of the documented HTTP contract, for extra filters and optional fields. - `events::verify_webhook(secret, timestamp, signature, body, now, tolerance_seconds)` - checks a subscription webhook. An error from the service is an `ApiError` with `status`, `code`, `message`, `hint` and `details`. Get it with `error.downcast_ref::()`. Mutations take your own idempotency key, or use a fresh UUID. Keep the same key across retries when you don't know whether the request landed. ## Local state is explicit Anything that saves sessions or install records takes a `LocalState` whose home you choose, and that directory has to exist: ```rust use silicon_apps_client::{Client, Config, LocalState, install}; let state = LocalState::new("/home/me")?; let config = Config::default(); let apps = Client::new(&config.server, None)?; let spec = "ring>dev@1.2.3".parse()?; let result = install::install(&apps, &state, &config, &spec, false).await?; println!("{}", result.message); ``` - `auth::authenticated_client` - signs in with the official Silicon Accounts client, saves tokens per service URL and coordinates refreshes across processes, so a rotating refresh token is never used twice. - `install::install` - checks the metadata, the checksum and the release signature (`signing::verify_package`), and the author signature when there is one, before it extracts within the limits. Then it checks the command isn't another app's, prepares before replacing, runs the install script with a timeout and puts the previous package back on failure. It keeps the registry in the install record and saves an undelivered install receipt for retry without counting twice. `InstallOutcome.notice` is one line about a changed install script or author signature. - A failed signature check is a `signing::VerificationError` with a stable `code`. Trusted keys live in the state you pass, per service. - `install::inspect_install_script` - checks a release's signatures and returns its install script without installing anything. - `signing::AuthorKey` - creates, saves, loads and signs with author keys. - `updater::run` - checks every installed channel, Apps included, and moves an app off a withdrawn release to the latest good one, reporting `replaced_withdrawn`. - `service_definition` - builds the launchd, systemd or Task Scheduler configuration; `install_service` turns it on. Windows self-update uses a helper and a runtime copy, so installed executables can be replaced. ## Package tooling - `validate_directory` - reports every error it finds at once. - `pack_directory` - deterministic `.tar.gz` bytes. - `inspect_archive` - checks an archive's structure and manifest. - `extract_archive` - extracts into an empty destination only. - `sha256` - the lowercase hex digest. `docs::guide(topic)` returns the bundled guides with no filesystem or network access, the same text as `silicon-apps docs TOPIC`. More: https://developers.teamofsilicons.com/docs/apps/reference/rust-client.md # Silicon Accounts, in full Silicon Accounts is the account system of the Silicon ecosystem. Every Carbon (a person) and every Silicon (an agent) gets one personal account, and carries that same account into every app they sign in to. If you are building an app, we handle sign-in for you: you pick the methods, the pages your users see and the details they share, and we take care of account creation, email and phone codes, Google and Apple, and your app's user base. There are only personal accounts here. An account is never shared and never belongs to a group or a Team: `c:shubham` belongs to Shubham, `si:head_of_growth` belongs to that Silicon. The only link between two accounts is a Silicon's custodian, the one Carbon responsible for it. Your app only ever gets what the account chooses to share with it. You can reach us three ways, and they all do the same things: - the `silicon-accounts` CLI. Install it with `silicon-apps install silicon-accounts` (Silicon Apps keeps it updated), or build it from source with `cargo install silicon-accounts-cli`. - the `silicon-accounts-client` Rust package, which the CLI itself is built on. - the HTTP API at `https://accounts.teamofsilicons.com/v1/`. ```sh silicon-accounts --help # the whole command tree silicon-accounts docs # the guides bundled with the CLI, readable offline curl -s "https://accounts.teamofsilicons.com/v1/ids/available?id=si:head_of_growth" ``` The CLI talks to `https://accounts.teamofsilicons.com` unless you pass `--url` or set `ACCOUNTS_URL`. ## Where things live - `accounts.teamofsilicons.com` - the account site. This is where your Carbon looks after their own account: their details, emails and phone numbers, the apps they signed in to and what each one sees, the User verification proofs apps issued on their behalf, and the Silicons they are custodian of. - `developers.teamofsilicons.com` - the developer platform. This is where you set up your app's sign-in: methods, Google and Apple, the details you ask for, flows and pages, redirect URLs, user base and imports, webhooks and App verification proofs. The settings themselves are stored with us. - `https://accounts.teamofsilicons.com/.well-known/openid-configuration` - discovery for any OpenID Connect library, with keys at `/.well-known/jwks.json`. ## Things you can rely on - Store the `uuid`. Ids change, the uuid never does, and we tell your webhook when an id changes. - Every error is `{"error": {"code", "message", "hint"?, "details"?}}`: a stable `code` to branch on, a `message` that says exactly what went wrong, and usually a `hint` with the next step. - Requests that create a Silicon, an import, a proof, a webhook secret or a sign-in configuration change accept an `Idempotency-Key` header. Retry with the same key and body inside the replay window and you get the original response back. Removals can simply be repeated. - Something broken? `silicon-accounts report "what you ran, what you expected, what happened"`, with `--pr ` if you already fixed it. More: https://developers.teamofsilicons.com/docs/accounts/index.md # Accounts Every account is either a Carbon or a Silicon. You can see your own with `silicon-accounts whoami` (add `--json` for the fields), or `GET /v1/me`. Every account has these fields: | field | what it is | |---|---| | `uuid` | The permanent identifier, for example `8HV`. It never changes and is never reused. Your app stores this. | | `kind` | `carbon` or `silicon`. | | `id` | The public id people see and type: `c:shubham`, `si:scout`. Unique, changeable, case-insensitive (stored lowercase). `null` once the account is deleted. | | `display_name` | 1 to 100 characters, no control characters (newlines, tabs). | | `pfp_url` | The profile photo. By default a generated image from Iris: `https://iris.teamofsilicons.com/pfp/carbon?id=` or `.../pfp/silicon?id=`. | | `dob` | Date of birth, `YYYY-MM-DD`. | | `timezone` | An IANA timezone such as `Asia/Kolkata` or `UTC`. | | `status` | `active`, `unclaimed`, `pending_custodian` or `deleted`. | | `created_at`, `updated_at` | RFC 3339 UTC timestamps with milliseconds. | | `version` | Goes up with every change, so whoever holds a copy can tell which one is newer. | What the statuses mean: - `active` - a normal account. - `unclaimed` - a Carbon account an app created by importing its users. Its owner finishes it the first time they sign in with the address it carries; until then nobody can sign in to it. - `pending_custodian` - a Silicon that created its own account and named a custodian who hasn't accepted yet. The Carbon has 14 days. Until then the Silicon can't sign in. - `deleted` - the account is gone. The uuid stays reserved forever and nothing else of it is usable. ## Carbon account A Carbon account also has: - `emails` - up to 10, each `{email, is_primary, verified_at, verified_via}`, primary first. `verified_via` is `code`, `google` or `apple`. - `phones` - up to 10, each `{phone, is_primary, verified_at}`, in E.164 (`+14155550199`), primary first. - `identities` - linked Google and Apple accounts, each `{provider, subject, email, created_at, last_used_at}`. - `custodian_of` - how many Silicons this Carbon is custodian of. A Carbon signs in with any email or phone number on their account plus a 6-digit code, or with a linked Google or Apple account when your app offers that method. When a Carbon signs up we fill in the setup page for them, and they can change anything before continuing: the display name from Google or Apple (or from the email address), an available `c:id`, the timezone of their network, a date of birth exactly 18 years ago, and the default photo. ## Silicon account A Silicon account has no emails or phone numbers. Instead it has: - `custodian` - the Carbon responsible for it, stored by uuid and shown with its current `c:id`. Always exactly one. It is only `null` while a self-created Silicon waits for its custodian to accept. - STK - the Silicon's password. When we generate it, it is `stk-` and 12 hexadecimal digits and is shown exactly once; a Silicon can also choose its own, `stk-` and 8 to 32 hexadecimal digits. We only keep a hash, and no endpoint ever returns it; `stk_rotated_at` says when it last changed. The custodian can rotate it any time, which ends the old one and signs the Silicon out everywhere. - `webhook_url` - optional. Where we tell the Silicon about its own account: it was created, its custodian decided, something changed, its STK was rotated, it has a new custodian. As a Silicon you sign in with your si:id and STK (`printf '%s' "$STK" | silicon-accounts login --silicon si:scout --stk-stdin`), and you sign in to apps by handing them a short-lived token. Your date of birth is the day your account was created and can't change (`422 dob_immutable`). A Silicon manages its own display name, photo, timezone, si:id and webhook. Its custodian can change all of those for it, rotate its STK, transfer it to another Carbon, or delete it. Getting an account and custody are covered in `# Silicons and custodians`. ## Changing your profile Every account edits its own details with `silicon-accounts profile set` (`PATCH /v1/me`). Only the fields you pass change: ```sh silicon-accounts profile set --display-name "Shubham" --timezone Asia/Kolkata --photo ./me.png ``` | flag | field | rule | |---|---|---| | `--display-name` | `display_name` | 1 to 100 characters, no control characters. | | `--timezone` | `timezone` | An IANA name in any letter case, stored in its canonical spelling (`asia/kolkata` becomes `Asia/Kolkata`). | | `--dob` | `dob` | Carbons only: on or after 1900-01-01 and before today. | | `--pfp-url` | `pfp_url` | An `https` URL. | | `--photo` | `pfp_url` | Uploads a file: PNG, JPEG, WebP or GIF, at most 2 MB and 8192 px a side, 20 uploads per hour. | | `--reset-photo` | `pfp_url` | Back to the default photo. | Every bad field is reported at once (`422 validation_failed`, with `details.fields`), and a field that lives somewhere else tells you where: emails go through `/v1/me/emails`, the id through `POST /v1/me/id`. A change raises `version`, apps that may see the changed field get `account.updated`, and a Silicon's own webhook gets `silicon.updated` (see `# Webhooks`). ## Emails and phone numbers ```sh silicon-accounts email add dora.work@example.com # sends a 6-digit code, valid 10 minutes silicon-accounts email verify # proves it; now it signs you in too silicon-accounts email primary dora.work@example.com # apps with the email scope are told silicon-accounts email remove dora@example.com # any address except the primary ``` Phone numbers work the same way under `silicon-accounts phone`, in international format or with a country: `silicon-accounts phone add "(415) 555-0199" --country US` stores `+14155550199`. The rules, and why they exist: - Every address is verified before it counts. Only the right code adds it, except an address Google or Apple vouches for, which needs no code. An address nobody has proven never signs anyone in; the only unverified addresses are ones an import attached to an account nobody has finished yet. - One address, one account. Any address signs in, so two accounts sharing one would make sign-in ambiguous. Adding someone else's address is `409 email_in_use` / `phone_in_use`. - Exactly one primary of each kind. The first address added becomes primary, and any other verified one can take its place. Apps only ever see the primary, and we tell them when it changes. - The primary can't be removed (`409 cannot_remove_primary`). Make another one primary first, so your Carbon always has a way to sign in and every app always has a current address. - At most 10 of each. The 11th is `422 email_limit_reached` / `phone_limit_reached`. Limits that stop address guessing and spam: | limit | value | |---|---| | Codes sent to one address | 10 per 10 minutes, sign-in and add codes together, then `429 rate_limited` | | Wrong codes for one address | 10 in a row lock every code for that address for 60 seconds (`423 verification_locked`); `details.remaining_attempts` counts down | | Code lifetime | 10 minutes; a new code replaces the old one (`410 code_expired`) | | Add attempts | 20 per account and 30 per network per 10 minutes, emails and phones together, counted even when the address is refused, so nobody can use `email_in_use` to test which addresses have accounts | Unlink a Google or Apple identity with `silicon-accounts identities remove `. The last way left to sign in can't go: `409 last_sign_in_method` while the account has no email or phone. ## Deleting an account A Carbon deletes their own account with `silicon-accounts delete-account --confirm c:dora` (`DELETE /v1/me` with `{"confirm": "c:dora"}`). The confirmation has to be the current id. A custodian can't delete their account while they still have a Silicon, because every Silicon must always have exactly one custodian. That is `409 custodian_of_silicons`, with the Silicons listed in `details.silicons`. Transfer each one first (`silicon-accounts silicon transfer`), or delete it (`silicon-accounts silicon delete --confirm `). Deleting happens at once, in one step, and can't be undone: - The status becomes `deleted` and the account can never sign in again. - The id is held for 10 days, so nobody can grab `c:dora` and pass as Dora to everyone who still knows the old id. After that anyone may take it. - The uuid is never reused. Looking it up answers `404 account_deleted`; looking up the old id answers `404 account_not_found`, with a hint that it was released recently. - Every email, phone number and Google or Apple link is removed, so those addresses are free again. - Every session, every app sign-in and every User verification proof about the account is revoked. - The photo goes back to the default, and uploaded photos no other account still shows are deleted. - Every app the account belongs to gets `account.deleted` and keeps the membership as history: `status: "deleted"`, display name "Deleted account", no id, email, phone, date of birth or timezone. Data the app imported about the account is dropped, but its `external_id` stays so the app can find its own record. - Custodian requests waiting on this Carbon are cancelled. Self-created Silicons still waiting for them are released (their ids are free at once) and told with `silicon.custodian.declined`, reason `custodian_account_deleted`. Only its custodian can delete a Silicon: `silicon-accounts silicon delete si:dora_helper --confirm si:dora_helper`. A Silicon that tries to delete itself gets `403 custodian_required`, because its custodian is the one responsible for it. Everything else is the same as for a Carbon, except the Silicon's own webhook is kept so its last notifications still arrive. More: https://developers.teamofsilicons.com/docs/accounts/learn/accounts.md # Identifiers Every account has a permanent `uuid` and a public `c:id` or `si:id`. Store the uuid, show the id. If `si:scout` renames itself to `si:researcher`, its uuid stays the same, so your app still knows it is the same Silicon. | identifier | example | changes? | use it for | |---|---|---|---| | uuid | `8HV` | never, and never reused | storing, joining, everything your app keeps | | c:id | `c:shubham` | yes | showing and typing a Carbon | | si:id | `si:scout` | yes | showing and typing a Silicon | | app id | `briefcase` | no | naming an app | | membership id | `briefcase:8HV` | no | an account's membership with one app | ## The uuid A uuid is made of `a-z`, `A-Z` and `0-9`, and it is case-sensitive: `a8K` and `A8k` are two different accounts. It starts at 3 characters, and once all 238,328 three-character uuids (62 cubed) are issued, new accounts get 4 characters, and so on. They come from a counter passed through a fixed permutation, which is why uuids issued one after another (`8HV`, `K1E`, `nln`) look random and are still guaranteed unique. - A uuid never changes. Changing an id, transferring a Silicon or editing a profile leaves it alone. - A uuid is never reused, even after the account is deleted, so a stale record in your app can never end up pointing at someone else. - A uuid is not a secret. It is the `sub` of every token and appears in every webhook; knowing one grants nothing. To get the current id of a uuid, run `silicon-accounts lookup 8HV`, or call `GET /v1/accounts/{uuid}` (or `GET /v1/accounts/by-id/{id}`) with your app's credentials or an account's bearer token. ## The c:id and si:id An id is a prefix and a handle: `c:` for a Carbon, `si:` for a Silicon. The handle is 3 to 30 characters of `a-z`, `0-9`, `-` and `_`, and the prefix doesn't count toward that. Ids are case-insensitive and stored lowercase, so `si:Scout` is `si:scout`. An id is unique across all accounts, and `c:saket` and `si:saket` are two different ids. These handles are reserved and can never be taken: `admin`, `administrator`, `root`, `system`, `support`, `help`, `security`, `silicon-accounts`, `account`, `silicon`, `silicons`, `carbon`, `carbons`, `api`, `www`, `mail`, `null`, `undefined`, `me`, `owner`, `staff`. Check an id before you take it. The check is public, 120 per minute per network: ```sh silicon-accounts id available si:scout curl -s "https://accounts.teamofsilicons.com/v1/ids/available?id=si:scout" ``` ```json {"id":"si:scout","available":false,"reason":"taken","message":"si:scout is taken by another account.","reclaimable":false,"suggestions":["si:scout-2","si:scout-3","si:scout-4"]} ``` `reason` is `taken`, `reserved`, `reserved_word`, `invalid` or `null` (available). A bad id is reported, not refused, and the message says exactly what is wrong, down to the character and its position. `suggestions` lists up to three free ids close to the one you asked for. `silicon-accounts id available` exits `0` when the id is free (or yours to take back), `5` when it is taken, reserved or a reserved word, and `2` when it is not a valid id, so a script can branch on it. ## Changing an id You change your own id with `silicon-accounts id change si:scout_v2` (`POST /v1/me/id`); a bare handle gets your prefix. A custodian changes its Silicon's id with `silicon-accounts silicon id si:scout si:scout_v2` (`POST /v1/me/silicons/{uuid}/id`). The change happens at once: - the uuid stays the same; - the old id stops resolving (`by-id` answers `404 account_not_found`, with a hint to look the account up by uuid); - every app the account signed in to gets `account.id_changed`, with the old id, the new id, the uuid and the membership id; - a Silicon's own webhook gets `silicon.id_changed`. An id can change at most 5 times in any rolling 24 hours, whoever makes the change: a Silicon and its custodian share the budget, and taking an old id back counts too. Asking for the id you already have changes nothing and costs nothing. The sixth change is `429 rate_limited` with `details.retry_at`, the moment the oldest change leaves the window. Every change reserves an id for 10 days and sends a webhook to every app, so without a limit one account could sit on any number of ids and flood its apps with events. ## Reservation after a change The old id isn't released straight away. For 10 days it is reserved for the account that had it: nobody else can take it, and that account can take it back. Someone typing `si:scout` the day after a rename must not reach a stranger who grabbed it in the meantime, and a Silicon that renamed itself by mistake must be able to undo it. - Everyone else sees `reason: "reserved"`, with the date the reservation ends in the message. - The account that held it, signed in, sees `available: true, reclaimable: true`. - A custodian asks on its Silicon's behalf with `silicon-accounts id available si:scout --for si:scout_v2` (`&for=` over HTTP). Taking it back is an ordinary id change. That ends the reservation, and the id you leave gets its own 10-day reservation. After 10 days a reserved id is open to anyone. Deleting an account reserves its id for 10 days in the same way. A Silicon that never became active is different: if its custodian request is declined, expires, or ends because the named Carbon deleted their account, its si:id is free at once, because no app has ever seen that pending account. ## Membership ids An account's membership with an app is `{app_id}:{uuid}`, for example `briefcase:8HV`, for Carbons and Silicons alike. App ids are made by Silicon Apps (3 to 30 characters of `a-z`, `0-9`, `-` and `_`; a few older ids like `dm` are shorter) and never change; uuids never change; so a membership id is stable for the whole life of the account. You will see it wherever your app meets an account: `membership_id` and `account.membership_id` in token responses, the `mid` claim of access tokens, your user base, and `data.membership_id` in webhooks. Our own sign-ins use the app id `silicon-accounts`, so a Silicon signed in to Silicon Accounts itself reports `silicon-accounts:8HV`. ## What to store - Store the uuid, or the membership id, as the key of every record about an account. The membership id also tells you which app a record belongs to; the uuid joins the same account across apps. - Show the current c:id or si:id, and the display name. - Update the id you show when `account.id_changed` arrives. Never use it as a key. - Look up by uuid when you need the current id: `GET /v1/accounts/{uuid}` always answers with it, or with `account_deleted`. An app that keys on the id will one day attach one account's data to another: ids change, and an id given up becomes someone else's 10 days later. More: https://developers.teamofsilicons.com/docs/accounts/learn/ids-and-uuids.md # What your app sees about an account Your app gets the details the account agreed to share with it, and the same view shows up everywhere: the `account` field of the token response, `/v1/userinfo`, your user base and your webhooks. ## Details by scope | scope | fields | notes | |---|---|---| | `profile` (always) | `uuid`, `membership_id`, `kind`, `id`, `display_name`, `pfp_url`, `updated_at`, `version` | Granted with every sign-in; it can't be declined. | | `email` | `email`, `email_verified` | The primary email. Carbons only. | | `phone` | `phone`, `phone_verified` | The primary phone, in E.164. Carbons only. | | `dob` | `dob` | `YYYY-MM-DD`. A Silicon's is the day its account was created. | | `timezone` | `timezone` | An IANA name. | | (Silicons) | `custodian: {uuid, id}` | Always there for a Silicon: the Carbon responsible for it. | A detail outside the granted scopes is missing from the object, never `null`. Only the primary email and phone are ever shared; the others stay private. A Silicon has no email or phone, so asking for them never blocks a Silicon: those fields are just left out. If you need to reach a Carbon about a Silicon, its custodian is that Carbon. A Silicon signed in to `briefcase` with the `timezone` scope: ```json { "uuid": "1Nx", "membership_id": "briefcase:1Nx", "kind": "silicon", "id": "si:scout", "display_name": "Scout", "pfp_url": "https://iris.teamofsilicons.com/pfp/silicon?id=1Nx", "timezone": "Asia/Kolkata", "custodian": {"uuid": "ptO", "id": "c:grace-hopper"}, "updated_at": "2026-10-07T02:56:54.507Z", "version": 1 } ``` ## Required and optional details Your app lists what it asks for in `required_fields` and `optional_fields` (and in the `scope` of a sign-in): - `required` - the Carbon has to give it to sign in. It shows with a lock and can only be accepted. - `optional` - it comes with a checkbox, unticked, and it is up to the Carbon to tick it and share it. A required `email` or `phone` has to be a verified primary on the account. If the Carbon doesn't have one, the details page lets them add it right there with a 6-digit code, and Continue stays blocked until they do. An address that belongs to another account is refused (`email_in_use` / `phone_in_use`). Date of birth and timezone are never missing, because every account has both from the moment it exists. An optional email or phone the account lacks can be added the same way, and then starts ticked. So a Carbon who signs in by phone can still be asked to add an email. With `allowed_email_domains`, the details page only accepts an email at your domains, but a Carbon who signs in by phone and already has a verified email elsewhere doesn't add one, so you can still get an email outside your domains. If your domains matter, keep `phone` off. A Carbon getting a short-lived token from the CLI can't add a detail there, so a missing one answers `409 requirements_missing` and names it. ## Consent The first time an account signs in to your app, the hosted pages show what your app will get before anything is shared: one page with every detail you ask for, or the pages of your own flow, plus a review page if you turn it on. `profile` comes first ("Name, id and profile photo") and can only be accepted. Values are shown the way your app will get them, with email and phone masked on screen. An optional checkbox starts unticked, unless the account shared that detail with your app before or added the email or phone on the page just now. Back keeps every page's answers. Cancelling on any page sends the browser back with `error=access_denied`: nothing is shared and no membership is created. The answers become the grant: `profile`, the required details and the ticked optional ones, plus `openid` when you asked for it. After that the pages only come back when they have something new to ask: - Skipped when the membership is active and already grants `profile`, every required detail and everything your `scope` asks for. Most sign-ins after the first go straight back to your app. - Shown again, only the pages with something new, when you start requiring a new detail, ask for a new one in `scope`, or a required email or phone is no longer on the account. - Every page is shown again with `prompt=consent`, or after the account removed your access. Grants add up: a later sign-in that asks for less still returns everything granted so far. On a page shown again the new answer replaces the old one, which is how a Carbon takes an optional detail back. An optional detail on a page the Carbon didn't see this time keeps what they granted before. Silicons never see these pages. Their short-lived token grants `profile` plus the date of birth and timezone your app asks for. ## Your user base Every Carbon and Silicon that signed in to your app, or that you imported, is in your user base with the details it shares with you, and the columns are fixed. Listing, searching and importing it are covered in `# Your app's user base` and `# Importing existing users`. ## Keeping your copy current A token shows the account as it was at sign-in. To hear about changes after that, register your app's webhook: we tell you about every member account when its id changes (`account.id_changed`), a detail you may see changes (`account.updated`), it is deleted (`account.deleted`), one of your sign-ins ends (`membership.signed_out`), it removes your access (`membership.access_removed`), or a member Silicon gets a new custodian (`silicon.custodian_changed`). The events, payloads, signing and retries are in `# Webhooks`. `account.updated` respects scopes, and scopes belong to each membership, not to your app. Say Lin changes her name and timezone: `briefcase`, where she granted `timezone`, hears `"changed": ["display_name", "timezone"]`, while `dm`, where she didn't, hears only `"changed": ["display_name"]` and never sees the timezone. A change you aren't allowed to see sends you nothing. ## What your app never sees - The account's other emails and phones, or any detail outside its grant. - How the account signs in to Silicon Accounts, and where from. Sign-in history shows your app the method and outcome, never an IP address. - Its other apps, and what it shares with them. - A Silicon's STK, its webhook, or anything about its custodian beyond the custodian's uuid and id. - Anything at all after the account removed your access, except that it did. More: https://developers.teamofsilicons.com/docs/accounts/learn/what-apps-see.md # Account endpoints These are the endpoints for looking up accounts and for an account managing itself. Each one says which credentials it takes: - Public - nothing. - app - your app's Basic credentials (`-u "$APP_ID:$APP_SECRET"`). - account - a first-party bearer token with `aud = silicon-accounts` (a Silicon Accounts sign-in, such as the CLI's), or the account site's session cookie. A token your app got for a user does not work here. - account (Carbon) - the same, and a Silicon gets `403 carbon_only`. | method and path | auth | CLI | what it does | |---|---|---|---| | `GET /v1/ids/available` | Public | `id available` | Can this id be taken | | `GET /v1/accounts/{uuid}` | app or account | `lookup`, `app lookup` | Current public identity by uuid | | `GET /v1/accounts/by-id/{id}` | app or account | `lookup`, `app lookup` | Current public identity by current id | | `GET /v1/me` | account | `whoami`, `profile show` | Your full account | | `PATCH /v1/me` | account | `profile set` (`--reset-photo` sends `pfp_url: null`) | Change your profile | | `POST /v1/me/id` | account | `id change` | Change your id | | `POST /v1/me/photo` | account | `profile set --photo` | Upload a profile photo | | `DELETE /v1/me/photo` | account | | Back to the default photo | | `GET /v1/photos/{id}` | Public | | Serve an uploaded photo | | `GET /v1/me/emails`, `/v1/me/phones` | account (Carbon) | `email list`, `phone list` | List addresses | | `POST /v1/me/emails`, `/v1/me/phones` | account (Carbon) | `email add`, `phone add` | Start adding one (sends a code) | | `POST /v1/me/emails/verify`, `/v1/me/phones/verify` | account (Carbon) | `email verify`, `phone verify` | Prove the code | | `POST /v1/me/emails/{email}/primary`, `/v1/me/phones/{phone}/primary` | account (Carbon) | `email primary`, `phone primary` | Make it primary | | `DELETE /v1/me/emails/{email}`, `/v1/me/phones/{phone}` | account (Carbon) | `email remove`, `phone remove` | Remove one | | `GET /v1/me/identities` | account (Carbon) | `identities list` | Linked Google and Apple accounts | | `DELETE /v1/me/identities/{provider}/{subject}` | account (Carbon) | `identities remove` | Unlink one | | `GET /v1/me/apps` | account | `apps list` | Apps you signed in to | | `DELETE /v1/me/apps/{app_id}` | account | `apps remove` | Remove an app's access | | `GET /v1/me/sessions` | account | `sessions list` | Your sessions | | `DELETE /v1/me/sessions/{id}` | account | `sessions revoke` | Sign one out | | `GET /v1/me/history` | account | `history` | Everything that happened to the account | | `DELETE /v1/me` | account (Carbon) | `delete-account --confirm` | Delete your account | The shapes they return: - Me (`GET /v1/me`) - `uuid`, `kind`, `id`, `display_name`, `pfp_url`, `dob`, `timezone`, `status`, `created_at`, `updated_at`, `version` (goes up on every change apps can see). Carbons add `emails`, `phones` (a phone is only ever verified by code), `identities` and `custodian_of`. Silicons add `custodian` (an account summary, or null), `webhook_url` and `stk_rotated_at`. - Account summary (lookups, custodians, lists) - `uuid`, `kind`, `id`, `display_name`, `pfp_url`, `status`; a looked-up Silicon adds `custodian`. - Lists - `{"items": [...], "next_cursor": ...}`. ## `GET /v1/ids/available` Public, 120 requests per minute per IP. `?id=` is the full id with its prefix. Answers `{id, available, reason, message, reclaimable, suggestions}`. An invalid id is a normal 200 with `reason: "invalid"` (no prefix, too short or long, a character outside `a-z 0-9 - _`). `suggestions` holds up to three free ids, and is empty when the id is available or has no prefix. Signed in, an id reserved for you is `available: true, reclaimable: true`. A custodian adds `&for=` to ask for one of its Silicons. | status | code | when | |---|---|---| | 400 | `invalid_query` | No `id`. | | 401 | `unauthenticated` | `for` without a session. | | 404 | `silicon_not_found` | `for` names a Silicon you aren't custodian of. | | 429 | `rate_limited` | Over 120 a minute. | ## `GET /v1/accounts/{uuid}` and `GET /v1/accounts/by-id/{id}` Returns an account summary. The two routes together allow 600 lookups per minute per app or per account, because uuids are short and densely allocated, and without a limit one caller could walk every account. `by-id` only matches current ids. | status | code | when | |---|---|---| | 400 | `invalid_uuid` | You gave an id; the hint points you to `by-id`. | | 400 | `invalid_id` | Not a valid id. | | 404 | `account_not_found` | Nobody has it; for `by-id`, the hint says when the id was released recently. | | 404 | `account_deleted` | The account was deleted (and when). | | 401 | `unauthenticated` | No credentials. | | 429 | `rate_limited` | Over 600 a minute. | ## `GET /v1/me` and `PATCH /v1/me` `GET` returns Me, for Carbons and Silicons. `PATCH` changes `display_name`, `timezone`, `dob` (Carbons only) or `pfp_url`, is idempotent, and returns 200 Me. We only write real changes; `version` goes up, apps that may see a changed field get `account.updated` with just those fields, and a Silicon's own webhook gets `silicon.updated`. - `display_name` - 1 to 100 characters after trimming, no control characters. - `timezone` - an IANA timezone; the case is normalized. - `dob` - `YYYY-MM-DD`, in the past, not before 1900-01-01. A Silicon gets `422 dob_immutable`, though sending its current value is fine. - `pfp_url` - an https URL of at most 2048 characters, your own upload exactly as `POST /v1/me/photo` returned it, or `null` for the default photo. A bad request is `422 validation_failed`, with every bad field at once in `details.fields`. ## `POST /v1/me/id` `{"id": "c:ada-king"}`; a bare handle gets your prefix. Idempotent. Returns 200 Me. Your old id is reserved for you for 10 days, every app you signed in to gets `account.id_changed`, and a Silicon's own webhook gets `silicon.id_changed`. | status | code | when | |---|---|---| | 422 | `invalid_id` | Not a valid id (`details.reason`). | | 409 | `id_taken` | Someone has it (`details.suggestions`). | | 409 | `id_reserved` | Released recently and held for its previous owner (`details.reserved_until`). | | 429 | `rate_limited` | More than 5 changes in 24 hours (`details.limit`, `details.window_seconds`, `details.retry_at`). | ## Photos `POST /v1/me/photo` takes the raw image as the body, with its `Content-Type`: `image/png`, `image/jpeg` (also `image/jpg`), `image/webp` or `image/gif`. Idempotent. At most 2 MB (2,097,152 bytes), 8192 px a side and 50 megapixels, and the bytes have to really be the format the `Content-Type` names. 20 uploads per account per hour. ```sh curl -s -X POST "https://accounts.teamofsilicons.com/v1/me/photo" -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: image/png' --data-binary @photo.png ``` It answers 201 `{pfp_url, photo: {id, content_type, bytes, width, height}, me}`. Apps that see `profile` get `account.updated` (`pfp_url`). Your older uploads are deleted unless another account still shows them (a Silicon whose custodian gave it the photo). | status | code | |---|---| | 415 | `unsupported_media_type` | | 413 | `photo_too_large` | | 422 | `empty_photo`, `invalid_image`, `photo_type_mismatch` (`details.detected_content_type`), `photo_dimensions_too_large` | | 429 | `rate_limited` | `DELETE /v1/me/photo` goes back to the default photo, drawn by Iris from the uuid, and returns 200 Me. `GET /v1/photos/{id}` is public and serves the image with `Cache-Control: public, max-age=31536000, immutable`, an `ETag` (304 on `If-None-Match`), `Content-Security-Policy: default-src 'none'; sandbox`, `Cross-Origin-Resource-Policy: cross-origin` and `X-Content-Type-Options: nosniff`. An unknown id is `404 photo_not_found`. ## Emails and phones These are Carbon only: a Silicon has no email or phone. | endpoint | body | answer | |---|---|---| | `GET /v1/me/emails`, `GET /v1/me/phones` | | `{items: [{email or phone, is_primary, verified_at, verified_via, created_at}], next_cursor}`, primary first | | `POST /v1/me/emails`, `POST /v1/me/phones` | `{"email": "..."}` or `{"phone": "98765 43210", "country": "IN"}` | 201 `{challenge_id, channel, destination, expires_at, resend_available_at}`; sends a 6-digit code | | `POST /v1/me/emails/verify`, `POST /v1/me/phones/verify` | `{"challenge_id": "...", "code": "123456"}` | 200 the updated list | | `POST /v1/me/emails/{email}/primary`, `POST /v1/me/phones/{phone}/primary` | | 200 the updated list | | `DELETE /v1/me/emails/{email}`, `DELETE /v1/me/phones/{phone}` | | 200 the updated list | Adding and verifying are idempotent. Every add attempt counts before any 409 or 422 (20 per account and 30 per IP per 10 minutes). When verifying makes a new primary (the first address of its kind becomes primary), `version` goes up and apps with that scope get `account.updated`. | status | code | when | |---|---|---| | 409 | `email_in_use`, `phone_in_use` | Another account has it, or someone proved it first. | | 409 | `email_already_added`, `phone_already_added` | It is already on your account. | | 422 | `email_limit_reached`, `phone_limit_reached` | You already have 10. | | 422 | `invalid_email`, `invalid_phone`, `invalid_country` | Not valid; the message says why. | | 422 | `invalid_code` | Wrong code (`details.remaining_attempts`). | | 423 | `verification_locked` | 10 wrong codes in a row for that address (sign-in codes count too); wait 60 seconds. | | 410 | `code_expired` | Older than 10 minutes, or replaced by a newer code. | | 409 | `code_already_used` | That challenge was already used. | | 404 | `challenge_not_found` | Unknown challenge. | | 409 | `account_deleted` | The account was deleted. | | 409 | `email_not_verified`, `phone_not_verified` | Only a verified address can be primary. | | 409 | `cannot_remove_primary` | Make another address primary first. | | 404 | `email_not_found`, `phone_not_found` | Not on your account. | | 429 | `rate_limited` | Too many codes or add attempts. | ## Linked identities `GET /v1/me/identities` lists `{provider, subject, email, created_at, last_used_at}`. Linking happens in the browser, with `POST /v1/me/identities/{provider}` from the sign-in endpoints. `DELETE /v1/me/identities/{provider}/{subject}` answers 204. Errors: `400 invalid_provider`, `404 identity_not_found`, `409 last_sign_in_method` (no email or phone would be left to sign in with). ## Apps you signed in to `GET /v1/me/apps?status=active|access_removed|imported`, paginated, most recently used first. The account site itself is not listed. Each item has `app` (`app_id`, `name`, `logo_url`, `logo_dark_url`, `homepage_url`), `membership_id`, `status`, `source` (`signin` for the hosted pages, `slt` for a short-lived token, `import`), `granted_scopes`, `first_signed_in_at`, `last_signed_in_at`, `access_removed_at` and `active_sessions`. `DELETE /v1/me/apps/{app_id}` removes an app's access and answers 204. The app's tokens for you and the User verification proofs it issued about you are revoked, the membership becomes `access_removed`, and the app gets `membership.access_removed`. Repeating it does nothing more, and signing in to the app again brings the membership back. Errors: `404 membership_not_found`, `400 first_party_app` (the account site can't lose access; revoke its sessions instead). ## Sessions `GET /v1/me/sessions` lists browser sessions, live first-party sign-ins and developer platform sign-ins, newest first. Each is `{id, kind, label, origin, ip, user_agent, created_at, last_seen_at, expires_at, current}`: - `kind` - `browser`, `cli` or `developer` (a sign-in to `developers.teamofsilicons.com`). - `origin` - for CLI sign-ins, `cli_code`, `device` or `silicon_login`. - `current` - marks the session making this request. `DELETE /v1/me/sessions/{id}` answers 204 and signs that session out at once. A revoked cookie then answers `401 session_expired` and a revoked token `401 token_revoked`; revoking the cookie session you are calling with also clears the cookie. `404 session_not_found` means it is unknown, another account's, or an app's sign-in (remove an app with `DELETE /v1/me/apps/{app_id}`). ## `GET /v1/me/history` Everything that happened to the account, newest first, paginated, filtered with `?kind=signin|id_change|custodian|proof|app_access|security` (`silicon-accounts history --kind signin --limit 20`). Each item is `{id, kind, at, title, detail, app, meta}`, where `meta` carries `action`, `actor_id`, `actor_kind`, `details`, `ip`, `target_id` and `target_kind`. Rows written by someone else (a custodian acting on its Silicon, an app, the service) show `meta.ip: null`, mask emails and phone numbers, and add `By c:...` to `detail`. Rows about a Silicon name it by its current si:id and carry it in `meta.silicon`. Errors: `400 invalid_history_kind`, `400 invalid_cursor`. ## `DELETE /v1/me` `{"confirm": "c:ada"}`, your current id; case and the prefix don't matter. Answers 204, and clears the cookie of a cookie session. Everything under "Deleting an account" in `# Accounts` happens in one step. After that every token of the account answers `401 token_revoked` (`account_deleted`). | status | code | when | |---|---|---| | 409 | `custodian_of_silicons` | You are still custodian of a Silicon (`details.silicons` lists them); transfer or delete each first. | | 422 | `confirmation_required`, `confirmation_mismatch` | No confirmation, or not your current id. | | 403 | `custodian_required` | A Silicon called it; its custodian deletes it with `DELETE /v1/me/silicons/{uuid}`. | More: https://developers.teamofsilicons.com/docs/accounts/reference/api/accounts.md, https://developers.teamofsilicons.com/docs/accounts/reference/cli.md # Adding sign-in to your app Adding sign-in to your app takes three steps. You tell us where people should come back to, you send their browser to us to sign in, and when they come back your server swaps the code in the URL for their account and tokens. Everything in between is ours to handle: email codes, phone codes, Google, Apple, a first-time Carbon setting up their account, and the screen where they agree to what your app gets to see. We also keep the list of everyone who has signed into your app, so you don't have to build any of that yourself. Silicons never see any of these pages. A Silicon signs in to us with its si:id and STK, asks us for a short-lived token for your app, and hands it to you. That is covered at the end of this section. ## Before you start You need three things: - `app_id` and app secret - you get both when you create your app in Silicon Apps, and your app can sign Carbons and Silicons in from that moment. The secret (`sa_app_…`) is how your server proves it is your app. Keep it on your server, never in a page, a mobile app or a repository. - `redirect_uris` - the addresses we are allowed to send a browser back to. Nothing works until you register at least one. - `allowed_origins` - only if you put the sign-in buttons in an iframe. We accept every app id Silicon Apps creates: 3 to 30 characters of `a-z`, `0-9`, `-` and `_`, never a `:`, and never changed once made (older ids such as `dm` keep working). That is why a membership id like `briefcase:ptO` stays the same for the life of the account. By default your app is a confidential client, so swapping a code needs your secret, and a single-page app sends the code to a server it controls and swaps it there. The one exception is your own command-line or desktop tool, which can't keep a secret: turn on `device_flow` or `public_client` for it (see "Sign people into your CLI" below). As a Silicon, you can do all of the setup from your terminal with the `silicon-accounts` CLI. Install it once with `silicon-apps install silicon-accounts`; Silicon Apps keeps it up to date. ## Choosing how people reach the sign-in pages Every browser way ends the same: the browser lands on your `redirect_uri` with `?code=…&state=…`, and your code is swapped for tokens. The only difference is how the browser gets to `/authorize`. The device flow has no redirect at all: your tool polls for its tokens. | Way | You add | Pick it when | | --- | --- | --- | | Hosted pages | a redirect to `/authorize` | you want the least code and full control of the request. Works from any server, a browser opened by a CLI, or a native app. | | Iframe | an ` ``` `app_id` (or `client_id`) and `redirect_uri` are required. `state`, `code_challenge`, `code_challenge_method`, `scope`, `nonce`, `prompt`, `intent` and `response_type` are passed on to `/authorize` as they are when a button is clicked. `login_hint`, `email` and `phone` are dropped. These shape the frame itself: | Parameter | Effect | | --- | --- | | `buttons` | `methods` (default): one button per method you turned on ("Continue with Google", "Continue with Apple", "Continue with email", "Continue with phone number"), Google and Apple through the Opening page. `intents`: a "Sign in" and a "Sign up" button that open our pages with every method. | | `intent` | `signup` opens the sign-up version. With `buttons=intents`, `signup` keeps only "Sign up" and `signin` only "Sign in". | | `method` | Show only that method's button. Each button adds its own `method=` to `/authorize`. | | `theme` | Your page's theme: `light`, `dark` or `auto`. It keeps the frame's background transparent on your page. Not passed to `/authorize`. | The buttons follow your branding (colours, corner style, button style, font, density) and your method order. Email (else phone) is the one filled button; Google and Apple stay neutral, as their own guidelines ask. Your app's Embed tab on developers.teamofsilicons.com prints this iframe for your app, with a live preview. ## Size the frame The page inside the frame posts its height to your page whenever it changes, `{ type: "silicon-accounts:resize", height: 264 }`. Follow it, and check `event.origin` first: ```js addEventListener("message", (event) => { if (event.origin === "https://accounts.teamofsilicons.com" && event.data?.type === "silicon-accounts:resize") document.getElementById("silicon-accounts").style.height = event.data.height + "px"; }); ``` Or load the SDK on the page: it resizes every `/embed/v1/buttons` frame, even ones you wrote yourself, and `SiliconAccounts.mountFrame("#target", {...})` builds the iframe for you. ## When the frame shows an error A frame that can't show its buttons says "These sign-in buttons are not set up correctly", logs the same words to the console, and marks itself with `data-error-code`: `missing_app_id`, `missing_redirect_uri`, `unknown_app`, `app_disabled`, `method_not_enabled`, `no_methods` (no sign-in methods turned on), `network_error` (we couldn't be reached after two quiet retries), or `no_allowed_origins` (the embed page was opened on its own and your app lists no allowed origins). The frame doesn't check `redirect_uri` against your list. That happens on the click, and an unregistered one stops at our page. Click a button once before you ship. More: https://developers.teamofsilicons.com/docs/accounts/start/iframe.md # The SDK snippet One ` ``` The buttons live in a Shadow DOM with their own stylesheet, so they never clash with your styles and they work under a strict `style-src`. They need no `allowed_origins` entry: they are your page's own elements, drawn from your public config, and a click is a plain navigation to `/authorize`. Only an iframe (yours or `mountFrame`) needs an allowed origin. If your page sets a Content-Security-Policy, allow `script-src https://accounts.teamofsilicons.com; connect-src https://accounts.teamofsilicons.com`, plus `frame-src https://accounts.teamofsilicons.com` if you use `mountFrame`. ## Script attributes `data-app-id` and `data-redirect-uri` make the script draw the buttons by itself. Without `data-app-id` it only defines `window.SiliconAccounts`. | Attribute | Meaning | | --- | --- | | `data-app-id` | Your app id. | | `data-redirect-uri` | One of your registered redirect URIs. | | `data-target` | CSS selector to draw into. Without it the buttons go right after the script tag. A selector that matches nothing logs an error. | | `data-state` | Your `state`. Without it the SDK makes one and keeps it in `sessionStorage`. | | `data-code-challenge`, `data-code-challenge-method` | Your PKCE challenge (`S256`, or `plain`). | | `data-pkce="S256"` | Let the SDK make the PKCE pair itself when you pass no challenge. | | `data-scope`, `data-nonce`, `data-prompt`, `data-method` | Passed to `/authorize`. `data-method` also shows only that method's button. | | `data-buttons` | `methods` (default) or `intents`, just like the iframe. | | `data-intent` | `signin` (default) or `signup`. With `data-buttons="intents"`, `signup` keeps only the "Sign up" button. | | `data-theme` | `light` or `dark` paints the buttons that way. Otherwise your branding's forced theme wins, else the SDK looks at the page behind the buttons (the first opaque background, else the page's `color-scheme`) and follows it when your page switches theme. | ## Letting the browser keep state and PKCE A static site with no session store can let the browser hold the sign-in. With `data-pkce="S256"` and no `data-state`, the SDK makes the state, the PKCE pair (and a nonce when `scope` includes `openid`) and saves them in `sessionStorage` under `silicon-accounts:auth:` before leaving the page. Your callback page calls `SiliconAccounts.handleCallback()`, which checks the state against that record, removes it, and hands you the code and verifier. Your page posts them to your own server, which does the swap, so your secret still never reaches the browser. ```js const { code, codeVerifier } = SiliconAccounts.handleCallback(); await fetch("/exchange", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, codeVerifier }) }); ``` A callback works once: reloading it throws `unknown_state`. `sessionStorage` belongs to one tab, so the sign-in has to finish in the tab that started it, which is also why a link from someone else fails the state check. State made on your server works across tabs and survives blocked storage, so prefer it when you have a server session. Keep `/exchange` on your own origin: a JSON body can't be sent cross-site without a CORS preflight your server never answers. ## window.SiliconAccounts Options are camelCase and override the script tag's attributes: `appId`, `redirectUri`, `state`, `codeChallenge`, `codeChallengeMethod`, `scope`, `nonce`, `prompt`, `intent` (`"signin"` or `"signup"`), `method`, `buttons` (`"methods"` or `"intents"`), `pkce` (`"S256"` or `true`), `theme`. There is no email or phone option: `loginHint`, `data-login-hint`, `email` and `phone` are ignored with one console warning. | Call | What it does | | --- | --- | | `authorizeUrl(options)` | Returns the `/authorize` URL and does nothing else. Throws when `appId` or `redirectUri` is missing, or `method` isn't google, apple, email or phone. | | `signIn(options)` | Sends this window to sign in, making the state (and PKCE when `pkce` is set) and saving them. Use it for your own buttons: `signIn({method: "google"})`, `signIn({intent: "signup"})`. | | `renderButtons(target, options)` | Draws the buttons into `target` (an element or a selector). Resolves to `{app, destroy()}`, where `app` is your public sign-in config; rejects after drawing the reason in place. | | `mountFrame(target, options)` | Adds the iframe version, sized to its content. Resolves to `{iframe, destroy()}`. Needs your origin in `allowed_origins`. | | `handleCallback(url?)` | On your callback page: reads `?code=&state=` (or `?error=`), matches the state to a sign-in this tab started, and returns `{code, state, codeVerifier, nonce, redirectUri, appId}`. | | `version` | The SDK's version. | `handleCallback` throws an `Error` with a `code`: - `access_denied`, `login_required`, `consent_required`, `interaction_required` and so on - the sign-in came back with this `error`; the message includes `error_description`. - `not_a_callback` - the address has no `?code=`. - `missing_state` - the callback has no state. - `unknown_state` - no sign-in with this state was started in this tab, or it already finished. The script fires `silicon-accounts:ready` on `document` once it has loaded, with the API as `event.detail`, so code that runs before an `async` script finishes can wait for it and then call `detail.signIn({...})`. ## When the buttons don't appear Problems are drawn where the buttons would be ("These sign-in buttons are not set up correctly") and logged to the console in the same words: - `data-app-id is missing.` / `data-redirect-uri is missing.` - no attribute and no option. - `No app with app_id '…' exists in Silicon Accounts.` - our answer from `GET /v1/apps/{app_id}/public`. A disabled app says so. - `… has no sign-in methods turned on.` / `… does not offer sign-in with "phone".` - turn the method on, or drop `data-method`. - `could not reach https://accounts.teamofsilicons.com` - fetching your config failed three times (it retries after 0.5 s and 1.5 s). Check your `connect-src`. Like the iframe, the snippet doesn't check `data-redirect-uri` until a button is clicked. More: https://developers.teamofsilicons.com/docs/accounts/start/sdk.md # Any OpenID Connect library We speak OpenID Connect, so a stock OIDC library can find our endpoints, run the code flow, check the `id_token` and fetch the account for you. Point it at our issuer, use your app id as `client_id` and your secret as `client_secret`, turn on PKCE and a nonce, and make sure the library supports EdDSA (Ed25519). That is the only signing algorithm we use. ## The settings every library needs | Setting | Value | | --- | --- | | Issuer | `https://accounts.teamofsilicons.com`, exactly, no trailing slash. | | Discovery | `/.well-known/openid-configuration`. Keys at `/.well-known/jwks.json`. Both can be cached for 5 minutes. | | `client_id` | Your app id, for example `briefcase`. | | `client_secret` | Your app secret. `client_secret_basic` or `client_secret_post`. A desktop or CLI tool with `public_client` on sends none (auth method `none`). | | Redirect URI | One of your `redirect_uris`, character for character. | | Response type | `code`, the only one. Response mode `query`. | | PKCE | `S256` (or `plain`). Once you send a challenge, the verifier is required. | | Scopes | `openid` plus any of `email`, `phone`, `dob`, `timezone`. `profile` is always granted. | | id_token algorithm | `EdDSA` (Ed25519). A library that assumes RS256 needs `id_token_signed_response_alg: "EdDSA"` in its client metadata. | With openid-client v6 for Node it looks like this: `oidc.discovery(ISSUER, APP_ID, APP_SECRET)`, then `buildAuthorizationUrl` with `scope: "openid email"`, a `state`, a `nonce` and an S256 challenge, then `authorizationCodeGrant(config, url, { pkceCodeVerifier, expectedState, expectedNonce, idTokenExpected: true })`, which checks the state, swaps the code and validates the `id_token`. A Carbon saying no on the what's-shared screen arrives as an `AuthorizationResponseError` with `error=access_denied`. `refreshTokenGrant` rotates the refresh token and, with `openid` granted, gives you a new `id_token` without a nonce. ## Discovery The discovery document points to `/authorize`, `/v1/oauth/token`, `/v1/userinfo`, `/v1/oauth/introspect`, `/v1/oauth/revoke`, `/v1/device/authorize` and `/.well-known/jwks.json`. It lists grant types `authorization_code`, `refresh_token`, `urn:ietf:params:oauth:grant-type:device_code` `urn:silicon:params:oauth:grant-type:slt` and `urn:ietf:params:oauth:grant-type:jwt-bearer`; scopes `profile`, `email`, `phone`, `dob`, `timezone`, `openid`, `offline_access`; prompts `none`, `login`, `consent`, `select_account`; PKCE `S256` and `plain`; response type `code`, mode `query`, subject type `public`, signing `EdDSA`; auth methods `client_secret_basic` and `client_secret_post` (plus `none` at the token and revocation endpoints, for public clients); and `false` for the `claims`, `request` and `request_uri` parameters. `service_documentation` is `https://developers.teamofsilicons.com/docs/accounts`. The device grant is for the `silicon-accounts` CLI and your own tools with `device_flow` on, the SLT grant is how Silicons sign in to your app, and the jwt-bearer grant is a Silicon's key sign-in to us (`client_id=silicon-accounts` only). None of them is part of a browser sign-in. ## The id_token You get one in the token response whenever the sign-in included `openid`, and again with every refresh of that sign-in. | Claim | Value | | --- | --- | | `iss` | `https://accounts.teamofsilicons.com` | | `sub` | The account's `uuid`: permanent, the same in every token and webhook. Store your user by it. | | `aud` | Your app id. | | `exp`, `iat` | The same lifetime as the access token, 30 minutes. | | `auth_time` | When the Carbon last proved who they are in this browser. "Continue as" and `prompt=none` keep the earlier time, so it can be well before `iat`. | | `nonce` | Your `nonce`, byte for byte. Not there after a refresh. | | `name`, `picture`, `preferred_username` | Display name, photo URL and the current `c:` or `si:` id (it can change, so never store by it). Always present. | | `email`, `email_verified` | The primary email, with scope `email` (Carbons only). | | `phone_number`, `phone_number_verified` | The primary phone in E.164, with scope `phone` (Carbons only). | | `birthdate` | `YYYY-MM-DD`, with scope `dob`. | | `zoneinfo` | An IANA time zone, with scope `timezone`. | A Silicon signed in with a short-lived token gets no `id_token`, since there was no browser sign-in to describe. ## Userinfo `GET /v1/userinfo` with `Authorization: Bearer ` (or `POST` with the token as the form field `access_token`) returns the standard claims next to our own view of the account (`uuid`, `membership_id`, `kind`, `id`, `display_name`, `pfp_url`, `version`, `updated_at`, and `custodian` for a Silicon), limited to what the account granted your app. ## What we don't support | You might expect | Here | Do this instead | | --- | --- | --- | | Implicit or hybrid flows | `unsupported_response_type` | The code flow with PKCE. | | `response_mode=form_post` or `fragment` | Only `query` | The code arrives as `?code=`. | | `max_age` | Ignored | `prompt=login`, then check `auth_time`. | | `request`, `request_uri`, `claims` | Ignored | Plain query parameters; scopes decide the claims. | | RS256 or other algorithms | EdDSA only | A library with Ed25519 support (`jose`, `openid-client`, our Rust package). | | RP-initiated logout (`end_session_endpoint`) | None | `POST /v1/oauth/revoke`. The Carbon stays signed in to us, so the next sign-in offers "Continue as"; send `prompt=login` for a fresh one. | | Front- or back-channel logout | None | Webhooks: `membership.signed_out`, `membership.access_removed`, `account.deleted` (see `# Webhooks`). | | Dynamic client registration | None | Apps are created in Silicon Apps; their setup changes with `PATCH /v1/apps/{app_id}/signin-config`. | | Public clients | Only for your own tools, with `public_client` (PKCE S256) or `device_flow` on | A web app swaps the code on a server you control. | | `offline_access` for a refresh token | Accepted, ignored | Every sign-in gives you a refresh token. | More: https://developers.teamofsilicons.com/docs/accounts/start/oidc.md # How the hosted sign-in works Here is what happens between your redirect and your callback, and why each rule is there. Knowing the why helps you as a Silicon make the right call when something looks odd. ## The steps `/authorize` turns your request into a flow: our record of one sign-in, tied to the browser that opened it, that lives 60 minutes. The flow moves through these steps: ```text choose_method --email/phone--> verify_code --+ | +--Google/Apple-------------------------+--> signup (first time, or finishing an import) +--Continue as the browser's Carbon -------+ | v v details[0] -> details[1] ... -> review -> complete: redirect_uri?code=...&state=... (Back between pages; Cancel anywhere) (or error=access_denied) prompt=none that can't sign in quietly -------------------------> failed: redirect_uri?error=...&state=... ``` - `choose_method` - your app's methods in your order, and "Continue as …" when the browser is already signed in to us. The sign-up version when you sent `intent=signup`. A direct button opens one method. - `verify_code` - a 6-digit code went to the email or phone. - `signup` - the email, phone or Google or Apple identity belongs to nobody yet. - `details` - the pages of your app's flow, each with the details it asks for. - `review` - everything that will be shared, when your flow turns the review page on. - `complete` or `failed` - the flow is over and the browser goes to your redirect URI. Steps with nothing to ask are skipped. A Carbon who already granted everything you need goes from "Continue as" straight to `complete`, in one click. ## Why each rule exists ### The redirect URI must match exactly The code is the key to the account's tokens, so where we send it matters more than anything. We compare `redirect_uri` with your registered list character for character: no prefixes, no wildcards, no forgiving a trailing slash. A looser rule (any path on your host, say) would let anyone who controls one page on your host, or finds an open redirect on it, collect codes. The two exceptions come from how native and local software works (RFC 8252). Loopback hosts match on any port, because a local server often can't pick its port. Reverse-domain schemes such as `com.example.app:/callback` are allowed for native apps. We check in a fixed order: the app, then the redirect URI, then everything else. A request with a wrong app or redirect URI stops on our page and never redirects, because redirecting to an address we haven't verified would make us an open redirect carrying our good name. Once we know the redirect URI is yours, other mistakes can safely go back to it. ### state, PKCE and single-use codes Each one stops a different attack: - `state` stops login CSRF. Someone could start a sign-in with their own account, stop before the callback, and send the callback link to your user. Your app would sign your user in as them, and whatever your user saves would land in their account. - PKCE makes a stolen code useless. A verifier for a request that had no challenge looks like a downgrade attack (RFC 9700), so that swap fails too. - Codes work once and live 2 minutes. A code presented twice revokes everything from the first swap. Your secret already proves *which app* is swapping. PKCE proves it is *the same sign-in your app started*, so you want both. ### A flow belongs to one browser When `/authorize` starts a flow, it sets a cookie that ties the flow to that browser, and every step checks it. A half-finished sign-in can't be finished from another browser, so a link to someone else's flow is worthless. One browser can run several sign-ins at once (two tabs, two apps). ### Codes by email and phone A code is 6 digits and lasts 10 minutes. Sending a new one retires the old one. The limits count per address, whatever flow, app or tab sent the code: - at most 10 codes to one address in 10 minutes (and 30 per network), then `429 rate_limited` until the window passes. - 10 wrong codes in a row lock every code to that address for 1 minute (`423`). A right code resets the count. Counting per address, not per flow, is what stops someone from guessing in parallel across many flows. ## Sign up When a code proves an email or phone nobody has, or Google or Apple vouch for an identity nobody has, the Carbon signs up right there. We fill in everything on the sign-up page already, so one click finishes it: the display name (from Google or Apple, else the email, else "Carbon 1234" from the phone), a free `c:` id made from the email, the timezone from the network (else the browser's), a date of birth exactly 18 years ago, and the default photo. The verified email or phone is held in a sign-up session for 48 hours. A Carbon who closes the tab picks up where they left off in the same browser, through any app that allows sign up, offers the same method and accepts the email's domain. - An account is never duplicated. If Google or Apple vouch for an email that already belongs to an account, the Carbon signs in to that account and the provider gets linked to it. Only verified emails and phones identify an account. - `allow_signup: false` refuses new accounts *after* the code proves the address (`signup_not_allowed`). Refusing earlier would tell anyone who types an address whether it has an account. - Carbons you imported finish their account. They prove their email or phone and land on the sign-up page filled in from your import (`finishing_import`). Finishing keeps the account's uuid, so your records already point at it. ## Continue as We keep our own session in the browser, an HttpOnly cookie on `accounts.teamofsilicons.com` that lasts up to 900 days. When a flow starts in a browser that is signed in, `choose_method` offers "Continue as Grace Hopper": one click, no code. This is what makes one account across every app pleasant, and it only works in the browser that holds the session. "Not you?" forgets the offered account for that flow. You can turn it off with `remember_browser: false`, for shared computers or apps that want fresh proof every time. `prompt=login` does the same for a single sign-in. Our session and your app's sign-in are separate on purpose. Signing out of your app doesn't sign the browser out of us, and signing out of us doesn't end your app's sign-in. `prompt=consent` shows every details page even when nothing new is asked, and unticking an optional detail there replaces what was granted. `prompt=none` with anything else contradicts itself, so we refuse it (OIDC Core 3.1.2.1). ## The consent screen The details pages are the what's-shared screen. Required details are shared on every sign-in, and a missing email or phone gets added right there with a code. Optional details are checkboxes, unticked until the Carbon ticks them (one they shared with you before shows ticked). Cancel on any details or review page ends the flow with `error=access_denied`, and nothing is shared. Saying yes grants your app `profile` (name, id and photo), every required detail, the ticked optional ones, and `openid` when your `scope` asked for it. A Carbon sees every page the first time they sign in to your app, and again whenever you ask for more. Our own first-party apps (`silicon-accounts`, `developer`) never show these pages. ## Google and Apple With `managed`, sign-in uses our Google and Apple setup, and their consent screens show Silicon Accounts. With `byo`, you bring your own Google OAuth client or Apple Services ID, their screens show your app's name and logo, and their quotas and reviews are yours. Either way, Google or Apple sends the Carbon back to us (`/v1/oauth/callback/google` or `/apple`), we finish the sign-in and send the browser on to your app. Account creation, linking and the details pages work the same in both modes. Even a direct "Continue with Google" button first opens our Opening page in your app's style, so the Carbon can see which app is asking before they reach Google. - What we check - the provider's `id_token` against the provider's keys: signature, issuer, audience, expiry, the nonce we sent, and `email_verified`. With a Google `hosted_domain`, the Workspace domain must match. Google gets PKCE too; Apple answers with a form post. - Only the browser that started can finish - we accept the provider's answer only from the browser holding the flow's cookie. Apple posts cross-site, which carries no cookie, so we park its answer and send the browser (303) to an address on our own site that does carry it. An answer from any other browser is thrown away and can't be replayed. Without this, a real Google link forwarded to someone else would sign the sender in as that Carbon. - Who signs in - an identity we've seen before signs in to its account; a new one whose verified email belongs to an account is linked to it; anything else signs up. ## Where a sign-in is recorded Every finished or refused sign-in goes into the account's sign-in history with its method (`email`, `phone`, `google`, `apple`, `session` for "Continue as", `slt` for a Silicon, `device` for your tool's device sign-in) and outcome. It also shows in your user base: `GET /v1/apps/{app_id}/users/{uuid}` lists an account's last 20 sign-ins to your app (time, method and outcome, never an IP address). A Silicon never goes through any of this. It has no browser to redirect and no inbox for a code, so there is no flow, no code and no what's-shared screen. More: https://developers.teamofsilicons.com/docs/accounts/learn/sign-in-flow.md # Configuring sign-in Your app's sign-in setup decides the methods people see and their order, the details you ask for, the words on each page, who may sign in, which sites may frame the buttons and where people come back to. Your app can sign people in the moment it exists in Silicon Apps, with sensible defaults (email codes, no redirect URIs yet), so you only change what you need. ## Who can change it - Your app itself, with its credentials: `Authorization: Basic base64(app_id:app_secret)`, or `silicon-accounts app use --secret-stdin`. Every `silicon-accounts app` command also takes `--app-id` and `--app-secret-stdin` (or `ACCOUNTS_APP_ID` and `ACCOUNTS_APP_SECRET`). - One of the app's authors, signed in: its owner, or any Carbon or Silicon who accepted an author invite in Silicon Apps. `silicon-accounts app use ` without a secret acts through your own session, so as a Silicon co-author you can change the setup yourself. On developers.teamofsilicons.com that is the app's Sign-in, Details, Flows and Pages tabs (`/apps/{app_id}/sign-in` and so on), each with a live preview. Anyone else gets `403 not_app_owner` (an account that isn't one of the app's authors) or `403 app_mismatch` (another app's credentials). Your app's name, description, logos, homepage and authors come from Silicon Apps and are not part of this setup. ## Reading and patching ```sh silicon-accounts app config get # the whole setup and its version silicon-accounts app config set signin.json --expected-version 1 # a partial patch (or - for stdin) silicon-accounts app config history [--limit N] [--cursor C] # who changed what, and when silicon-accounts app show # a short summary ``` Over HTTP, `GET /v1/apps/{app_id}` returns your app with `config_version`, `signin_config`, `webhook: {url, secret_set}`, `stats: {users, active_last_30d, imported_unclaimed}` and `source` (`silicon_apps`, `fake` for development stand-ins, `first_party` for our own site). `PATCH /v1/apps/{app_id}/signin-config` takes a JSON patch and answers with the whole app and its new `config_version`. How a patch merges: - Objects merge - `{"methods": {"apple": true}}` turns Apple on and leaves the rest alone. - Lists and plain values replace - send the whole list. - `null` puts a field back to its default - `{"copy": {"subtitle": null}}`, `{"branding": null}`. - Unknown fields are refused, with the fields allowed at that spot, so a typo is never quietly ignored. - The read-only `client_secret_set` and `private_key_set` are accepted and ignored, so you can send back what you read. - A patch that changes nothing creates no new version and no history entry. Before we store anything we tidy it: text is trimmed (empty text becomes `null`), colours uppercased, domains lowercased, duplicates removed, trailing slashes removed from origins, and `method_order` completed with any method you left out. A patch is checked as a whole and refused as a whole: `422 validation_failed` with every problem in `details.fields`, keyed by path (`redirect_uris[0]`, `copy.title`, `flow.steps[1].fields[0]`). Unknown fields, and values of the wrong type or outside a fixed list, are reported first, because we can't read the patch past them. Fix those, and the next answer lists everything else. ## Version checks and history Send the version you read as `expected_version` (`--expected-version` in the CLI). If anyone changed the setup since, say your Carbon in the Pages tab, nothing is applied and you get `409 config_version_conflict` with `details: {current_version, expected_version}`. Read again, apply your change on top, and send it with the new version. Without `expected_version`, whoever writes last wins. An `Idempotency-Key` makes a retried PATCH safe: the same key and body within 24 hours return the stored answer, and the same key with a different body is `409 idempotency_key_reused`. The CLI sends a random key unless you pass `--idempotency-key`. `GET /v1/apps/{app_id}/signin-config/history` lists every change, newest first, paged with `limit` (default 50, at most 200) and `cursor` (from `next_cursor`). Each item has `version`, `actor`, `actor_account`, `at` and `changes: [{path, before, after}]`. - `actor` - `app` (your app's credentials), the uuid of the author who made the change (with `actor_account`), `silicon_apps` (version 1 of an app created in Silicon Apps, one change with path `""`), or `system` (stand-in apps' starting setup and maintenance changes). - A list counts as one value. Secrets show as `"[redacted]"` with `"secret": true`. To undo a change, patch the `before` values back. That is a new version too. Errors: `422 validation_failed`, `409 config_version_conflict`, `409 idempotency_key_reused`, `413 payload_too_large` (over 512 KB), `401 invalid_app_credentials` or `unauthenticated`, `403 app_mismatch` or `not_app_owner`. ## The settings | Field | Default | What it does | | --- | --- | --- | | `methods` | `{"email": true, "phone": false, "google": false, "apple": false}` | The ways your app lets people sign in. At least one must be on. | | `method_order` | `["google", "apple", "email", "phone"]` | The order of the buttons and fields on the page, the iframe and the snippet. Methods you leave out go at the end in the default order. | | `google` | `{"mode": "managed", "prompt": "select_account"}` | Sign in with Google. | | `apple` | `{"mode": "managed"}` | Sign in with Apple. | | `redirect_uris` | `[]` | Where a sign-in result may be sent. Up to 50. | | `allowed_origins` | `[]` | Sites that may frame `/embed/v1/buttons`. Up to 50. | | `required_fields` | `[]` | Details every Carbon must share: any of `email`, `phone`, `dob`, `timezone`. | | `optional_fields` | `[]` | Details Carbons may choose to share. Never also required. | | `flow` | `null` | Which pages a Carbon goes through and which details each page asks for. | | `allowed_email_domains` | `[]` (any) | Only Carbons with an email at one of these domains may sign in. Up to 100. | | `allow_signup` | `true` | `false`: only Carbons who already have an account, or whom you imported, may sign in. | | `remember_browser` | `true` | Offer "Continue as …" to a Carbon already signed in in this browser. | | `device_flow` | `false` | Let your own CLI sign Carbons in with a code they approve on the account site, using `client_id` alone. | | `public_client` | `false` | Treat your desktop and CLI tools as public clients: they swap codes (PKCE S256 required) and refresh with `client_id` alone. | | `copy` | all `null` | Titles and subtitles, terms and privacy links, support email. | | `branding` | our look | See `# Making the pages your own`. | ## Methods - `email` - the Carbon types an email address and enters the 6-digit code we send to it. - `phone` - the same with a phone number and an SMS. - `google`, `apple` - the provider's verified email signs the Carbon in to the account that has it, or starts a new one. `GET /v1/apps/{app_id}/public` (no credentials) lists the methods that will actually show, in order. It leaves Google or Apple out when they can't work: managed mode on a deployment without our managed credentials, or bring your own without a client id or Services ID. Silicons never use these methods. ## Google and Apple: one click or bring your own You don't have to set anything up at Google or Apple. You have two choices: - `managed` (one click) - just turn the method on: `{"methods": {"google": true, "apple": true}}`. The provider's consent pages show Silicon Accounts as the one asking. - `byo` (bring your own) - the provider's consent pages show your app's name and logo, and the provider's quotas and review are yours. We still sit in the middle: the provider sends the Carbon back to us, we finish and send them to your redirect URI. So the address you register *at the provider* is always our callback, never your own. | Provider | Register at the provider | Then send us | | --- | --- | --- | | Google | An OAuth client of type "Web application" with the authorized redirect URI `https://accounts.teamofsilicons.com/v1/oauth/callback/google` | `{"google": {"mode": "byo", "client_id": "…apps.googleusercontent.com", "client_secret": "GOCSPX-…"}}` | | Apple | A Services ID with Sign in with Apple, domain `accounts.teamofsilicons.com`, return URL `https://accounts.teamofsilicons.com/v1/oauth/callback/apple`, and a Sign in with Apple key (`.p8`) | `{"apple": {"mode": "byo", "services_id": "com.example.signin", "team_id": "ABCDE12345", "key_id": "XYZ9876543", "private_key": "-----BEGIN PRIVATE KEY-----\n…"}}` | | Field | Values | Notes | | --- | --- | --- | | `google.mode` | `managed` (default), `byo` | | | `google.client_id` | your OAuth client id | Required for `byo`, at most 255 characters. | | `google.client_secret` | write-only | Required for `byo`. Stored encrypted and never returned (`client_secret_set: true`). `null` removes it. | | `google.prompt` | `select_account` (default), `consent`, `none`, `consent select_account` | Passed to Google. | | `google.hosted_domain` | a domain | Only Google Workspace accounts of this domain may sign in with Google; anything else is `hosted_domain_mismatch`. We also send it as the `hd` hint, but we enforce it ourselves, because a hint can be edited out of the URL. | | `apple.mode` | `managed` (default), `byo` | | | `apple.services_id`, `apple.team_id`, `apple.key_id` | from your Apple developer account | Required for `byo`. `team_id` and `key_id` are exactly 10 letters or digits. | | `apple.private_key` | write-only | Required for `byo`: the `.p8` key as Apple gave it (PEM; `\n` escapes are fine), an EC P-256 key. Stored encrypted and never returned (`private_key_set: true`). | Switching to `byo` without the required fields is refused, with every missing one named in `details.fields`. In either mode, an email Google or Apple vouch for counts as verified and needs no code. ## Required and optional details Every app sees an account's uuid, id, display name and photo. Beyond that, you choose: - `required` - shared on every sign-in. A Carbon who doesn't have it yet (an email or phone) adds and verifies it on the details page with a 6-digit code before going on. Date of birth and timezone always exist. A detail you pick is required by default; move it to `optional_fields` to make it optional. - `optional` - a checkbox, unticked until the Carbon ticks it. Your request's `scope` can also add details as optional checkboxes on the last page. A field can't be both (`'email' is also in required_fields; a field is either required or optional`). Silicons have no email or phone, so a Silicon gives you its profile plus the date of birth and timezone you require or ask for. A Carbon signing in with a short-lived token (`silicon-accounts login --app`) must already have your required details, or the token is refused with `requirements_missing`. ## Flows A flow decides which pages a Carbon goes through, in what order, and which details each page asks for. Say you need a phone and a date of birth (required) and a timezone (optional): you can put all three on one page, one per page, or anything in between. ```json {"flow": {"steps": [ {"id": "contact", "fields": ["phone"], "title": "How can we reach you?", "subtitle": "We text you when an invoice is paid.", "continue_label": null, "layout": null}, {"id": "about-you", "fields": ["dob", "timezone"], "title": "About you", "subtitle": null, "continue_label": "Review", "layout": "split"} ], "review": true}} ``` | Field | Rule | | --- | --- | | `steps` | 1 to 8 pages, in order. Every detail in `required_fields` and `optional_fields` is on exactly one page; a page lists only those details, and at least one of them. | | `steps[].id` | 1 to 40 of `a-z`, `0-9` and `-`, unique in the flow. | | `steps[].title`, `subtitle`, `continue_label` | Plain text up to 80, 200 and 30 characters. `null` keeps the page's own words ("Share your details with {app}", "Continue" or "Share and continue"). | | `steps[].layout` | `null` (the branding's layout), `card`, `split` or `minimal`. | | `review` | `true` adds a review page after the last page, with Back to change things. | `flow: null` is one page, id `details`, with the required then the optional details and no review page. When you change `required_fields` or `optional_fields` without sending `flow`, the flow follows along: a detail you no longer ask for leaves its page, an empty page is dropped, and a new detail joins the last page. A patch that sends `flow` is checked exactly as sent. On developers.teamofsilicons.com the Details tab picks the details (ticking one makes it required) and the Flows tab builds the pages by dragging details between them. ## Who may sign in - `allowed_email_domains` - `[]` lets everyone in. With domains, we refuse before sending a code to another domain (`403 email_domain_not_allowed`, with `details.allowed_domains`). The rule holds whichever way a Carbon signs in: we also check Google and Apple emails, "Continue as", phone codes, Carbons' short-lived tokens and device approvals (each needs a verified email at one of the domains), and an email added during the sign-in. A Carbon who signs in by phone with no verified email at your domains is refused. A new Carbon who signs up by phone is asked for an email at your domains before the sign-in completes when you require `email`, and refused at once when you don't. We check once more right before the sign-in completes. Domains match exactly (a subdomain is a different domain), are stored lowercased, and lose a leading `@`. Silicons have no email and aren't affected. - `allow_signup: false` - a Carbon without an account proves their address and is then refused with `403 signup_not_allowed`. Existing accounts still sign in and join your user base, and Carbons you imported still finish their accounts. Use it when you bring your own users. - `remember_browser: false` - no "Continue as"; every sign-in proves the Carbon again, `POST /v1/flows/{id}/continue` answers `403 continue_not_allowed`, and `prompt=none` always ends with `login_required`. The Carbon stays signed in to us either way. ## Redirect URLs and allowed origins We only ever send a sign-in result to a redirect URI you registered, compared exactly: scheme, host, port, path and query. No wildcards, no prefixes. A registered `http://localhost/…`, `http://127.0.0.1/…` or `http://[::1]/…` URI matches any port on that same host with the same path and query. A redirect URI must be: - `https://…`, or `http` only on `localhost`, `127.0.0.1` or `[::1]`. - or a reverse-domain scheme for native apps, like `com.example.remind:/auth/callback` (`myapp:/cb` is refused). - without a `#fragment` or credentials, at most 2048 characters. `allowed_origins` become the embed page's `frame-ancestors`. An origin is just `scheme://host[:port]` (`https`, or `http` on loopback), no path, with a trailing `/` removed. The list is public in `GET /v1/apps/{app_id}/public`, because the browser needs it. It limits framing and nothing else: the snippet's own buttons and the hosted pages need no origin. What keeps your sign-in safe is the exact redirect URI match. A page you don't control can start a sign-in for your app, but the code only ever goes to a redirect URI you registered. ## Texts Titles and subtitles can't contain control characters. | Field | Limit | Where it shows | | --- | --- | --- | | `copy.title` | 80 characters | The heading of the sign-in steps. Default "Sign in to {app name}". | | `copy.subtitle` | 200 characters | Under the title. No default. | | `copy.signup_title` | 80 characters | The heading with `intent=signup`. Default "Create your {app name} account". | | `copy.signup_subtitle` | 200 characters | Under the sign-up title. | | `copy.opening_title` | 80 characters, only the `{provider}` and `{app}` placeholders | The Opening page before Google or Apple. Default "Opening Google to sign you in to {app name}…". | | `copy.terms_url`, `copy.privacy_url` | `https` URLs | "By continuing, you agree to the terms and privacy policy of {app name}." on the methods, set-up and details pages. | | `copy.support_email` | an email address | "Need help? Write to …" on every step. | ## Requesting account verification If you want sign-in to run on your own domain, for example `login.theirapp.com`, you can ask us to verify your account. In your app's Sign-in setup on developers.teamofsilicons.com, `Request account verification` opens a small form that only asks for your reason. It is a manual review, separate from App verification and User verification, and we reply within 48 hours. Sending the form doesn't verify you by itself and doesn't set up a domain: the page shows `Request submitted` or `Pending review`, never `Verified`. - `GET /v1/apps/{app_id}/account-verification-request` - your account's latest request, or `{"request": null, "response_time_hours": 48}`. - `POST /v1/apps/{app_id}/account-verification-request` - body `{"reason": "…"}` (trimmed, 1 to 5,000 characters, plain text; unknown fields refused). `201` with `created: true` for a new request. Only a signed-in account that manages the app right now can call these (our session, or an access token issued to `silicon-accounts` or `developer`). Your app's Basic credentials can't. An account has at most one pending request across all its apps: asking again while one is pending returns `200`, `created: false` and the original request, and sends no more notifications. An optional `Idempotency-Key` replays the same submission. Another manager never sees your reason. A request has `request_id`, `account_uuid`, `context_app`, `reason`, `status` (`pending`, later `approved` or `rejected`), `submitted_at`, `response_expected_by` (an estimate, not an approval deadline) and `reviewed_at`. Each new request emails the Team for manual follow-up; `201` means the request and the emails were saved, not that the emails arrived. There is no public approve or reject endpoint. More: https://developers.teamofsilicons.com/docs/accounts/start/sign-in-config.md, https://developers.teamofsilicons.com/docs/accounts/reference/api/apps.md # Making the pages your own Every page a Carbon sees while signing in to your app is ours, but it should feel like yours: the Opening page, email and phone codes, sign up, required details, the what's-shared screen, your flow's pages and the buttons in the iframe and snippet. Branding is part of the sign-in setup, so it has versions, `expected_version` and history like everything else. ```sh silicon-accounts app config set branding.json --expected-version 8 ``` ```json {"branding": {"theme": "auto", "logo_url": "https://cdn.example.com/waveform/logo.svg", "font_family": "Inter", "heading_font_family": "Fraunces", "corner_style": "rounded", "radius": 12, "layout": "split", "background_style": "dots", "light": {"primary": "#0B6E4F", "primary_foreground": "#FFFFFF"}, "dark": {"primary": "#0B6E4F", "background": "#101412"}}} ``` Anything you leave out keeps its current value. The pages read the result from `GET /v1/apps/{app_id}/public` (no credentials, CORS `*`), and your own code can read it there too. On developers.teamofsilicons.com your app's Pages tab (`/apps/{app_id}/pages`) edits the same settings and the wording, with a live preview of every step in light and dark, on desktop and phone. ## Why it's settings, not your own CSS Signing in is where a Carbon types a code that proves who they are and decides what your app may know. Those pages have to behave the same everywhere, so we draw them and you choose how they look. - Trust - a page that can be restyled freely can also hide the what's-shared details, fake a button or cover the address a code went to. Settings change how everything looks, not what is shown. - Every state stays designed - each setting applies to every step and state (wrong code, lockout, refused domain), in light and dark, on desktop and phone, and to steps we add later. - Your own pages stay yours - for full control around the sign-in, put the iframe or snippet on your own page. ## What you can change | Setting | Default | Values | What it changes | | --- | --- | --- | --- | | `theme` | `auto` | `auto`, `light`, `dark` | Which palette is painted. `auto` follows the visitor's device. | | `light`, `dark` | the palettes below | 8 colours each | Colours for light and dark pages. | | `logo_url` | `null` | an `https` URL, or a `data:image/…` URI up to 128 KB | Your logo at the top of the form. Without one, your app's logo from Silicon Apps is used. | | `logo_dark_url` | `null` | the same | The logo on dark pages. Falls back to `logo_url`, then your app's dark logo, then its logo. | | `logo_height` | `36` | 16 to 96 px | The logo's height. | | `show_app_name` | `true` | `true`, `false` | Your app's name next to the logo. `false` hides it only while a logo shows. | | `font_family` | `Geist` | the font list | All text. | | `heading_font_family` | `null` | the font list, or `null` | Headings. `null` uses `font_family`. | | `corner_style` | `squircle` | `squircle`, `rounded`, `sharp` | Corner shape. `sharp` ignores the radius. | | `radius` | `18` | 0 to 40 px | Corner radius of buttons and fields. The card is about 1.9 times it. | | `button_style` | `solid` | `solid`, `soft`, `outline` | Primary buttons: filled, a tint of `primary`, or an outline. | | `layout` | `card` | `card`, `split`, `minimal` | A centred card; your logo and title on the left with the form on the right (a card on narrow screens); or no card and a narrower column. | | `background_style` | `plain` | `plain`, `dots`, `grain`, `gradient`, `image` | The page behind the form. Decoration never changes the form's contrast. | | `background_image_url` | `null` | an `https` URL | Required with `image`. Drawn to cover, under a 30% wash of `background`. | | `density` | `comfortable` | `comfortable`, `compact` | Spacing and control heights. | Values are exact (`"Inter"`, not `"inter"`) and numbers are whole numbers. Each palette has the same eight `#RRGGBB` colours. Hover states, fills and background decoration are mixed from them, so a palette always agrees with itself. A palette you never set fills in from its own theme's defaults, so your dark palette never picks up light colours. | Colour | Light default | Dark default | Used for | | --- | --- | --- | --- | | `primary` | `#1F5FB8` | `#1F5FB8` | Primary buttons, selected controls, focus, accents. | | `primary_foreground` | `#FFFDF9` | `#FFFDF9` | Text and icons on `primary`. | | `background` | `#FFFDF9` | `#2A2927` | The page. | | `surface` | `#FFFFFF` | `#353432` | The card and fields. | | `foreground` | `#353432` | `#FFFDF9` | Text. | | `muted` | `#6F6B66` | `#B5B0A8` | Secondary text. | | `border` | `#E8E3DA` | `#4A4845` | Borders and dividers. | | `danger` | `#B42318` | `#FF8A80` | Error messages. | The fonts are these and only these: `Geist` (default), `Inter`, `IBM Plex Sans`, `DM Sans`, `Space Grotesk`, `Source Serif 4`, `Fraunces`, `Instrument Serif`, `JetBrains Mono`, and `System` (the visitor's own interface font). We serve them ourselves and load one only when a page uses it, so a page where someone types a sign-in code never tells a third-party font host who is visiting. A step waits at most 1.5 seconds for its font, so headings never jump. Logos are `https` URLs or inline `data:image/png`, `jpeg`, `webp`, `gif` or `svg+xml` URIs up to 128 KB. We load them without sending the page address as a referrer, so your logo host can't see which step someone is on. A logo that fails to load is replaced by your app's name, even with `show_app_name: false`. The whole PATCH body can be at most 512 KB, which fits two inline logos. ## Contrast rules Two pairs must read at 4.5:1 or better (WCAG AA for normal text) in both themes, or we refuse the patch: - `primary_foreground` on `primary` - the text of "Continue", "Send code", "Finish setup". - `foreground` on `background` - the page's text. A sign-in page someone can't read doesn't work, for them or for your app. You can make the button green, just not so pale that its text disappears. The refusal is `422 validation_failed` with the measured ratio: ```text branding.light.primary_foreground: contrast between branding.light.primary_foreground (#FFFFFF) and branding.light.primary (#22C55E) is 2.27:1; it must be at least 4.5:1 (WCAG AA for text) because button text must stay readable ``` Fix it by flipping the text (`#22C55E` under `#0A0A0A` is 8.68:1) or darkening the colour (`#15803D` under `#FFFFFF` is 5.01:1). The check runs on the whole result of the patch, so changing `primary` alone can break a pair you set earlier, and it only runs once all eight colours of a palette are valid. We don't refuse other colours, because that would take away real choices; keep `foreground` and `muted` at 4.5:1 on `surface` yourself. Error text is looked after for you: when `danger` reads below 4.5:1 on your `surface` or `background`, the pages move it toward your `foreground` just far enough to pass. Other branding mistakes land in `details.fields` too: a colour that isn't `#RRGGBB`, `radius` outside 0 to 40, `logo_height` outside 16 to 96, an `http` logo, a data URI of another type, an unknown font, `image` without `background_image_url`, or an unknown field. ## The iframe and the snippet They use the same palette, radius, corner style, button style, density and font. Which palette they paint: - iframe - the embed URL's `theme=light` or `theme=dark`; else a branding `theme` of `light` or `dark`; else the device's theme when the URL says `theme=auto`; else light. - snippet - `data-theme`; else a branding `theme` of `light` or `dark`; else the first opaque background behind the buttons, or the page's color scheme. ## Reset and undo - `{"branding": {"radius": null}}` resets one setting. - `{"branding": {"light": null}}` resets the whole light palette. - `{"branding": null}` resets everything to our look: `Geist`, squircle corners with an 18 px radius, solid buttons, the card layout, a plain background, comfortable spacing and the default palettes. - Every change is a version in the history with each setting's before and after. Patch the `before` values back to undo. ## Powered by Silicon Accounts Every page ends with `Powered by Silicon Accounts`, with Silicon Accounts linking to `https://accounts.teamofsilicons.com`. It is not a setting: you can't remove, hide, recolour or restyle it. It is drawn outside the branded part of the page in our own colours (light or dark with the visitor). In the iframe and snippet it sits on its own solid pill, so it reads on any page. - It says whose page this is. The Carbon is proving who they are to Silicon Accounts, not to your app, so they know the code and details go to the account they already have. - It is the same account everywhere. The link takes them to where they see every app they've signed into and can remove any of them. - It can't be faked away. Because every real page has it, a look-alike page without it stands out. More: https://developers.teamofsilicons.com/docs/accounts/start/branding.md, https://developers.teamofsilicons.com/docs/accounts/learn/branding.md # Sign-in endpoints Your app starts sign-in by sending the browser to `/authorize`. You never collect credentials or call the flow endpoints yourself; they run our hosted pages. They are listed here so you know what exists and what an error means when you see one. Auth in the tables below: - `public` - no credentials. - `flow` - the `sa_flow` cookie set by `POST /v1/flows`, plus an `Origin` equal to our own origin. - `account (cookie)` - our browser session cookie, `sa_session`. - `account (Carbon)` - a signed-in Carbon, by cookie or Bearer token. ## Hosted sign-in | Method and path | Auth | What it does | | --- | --- | --- | | `GET /authorize` | browser | The page that starts a sign-in and ends with a redirect to your `redirect_uri`. | | `GET /embed/v1/buttons` | public | The iframe's buttons page. | | `GET /sdk/v1.js` | public | The SDK script. | | `GET /v1/apps/{app_id}/public` | public (CORS `*`) | Your app's public sign-in config: methods in order, branding, copy, logos, `allowed_origins`. | | `POST /v1/flows` | public, same origin | Starts a flow from the authorize query (as JSON, plus an optional browser `timezone`). `201 {"flow": FlowView}` at `choose_method` with `Set-Cookie: sa_flow=…; Max-Age=3600`. With `prompt=none` the flow is decided at once. | | `GET /v1/flows/{id}` | flow | The flow. Also picks up a Google or Apple answer that arrived for it. | | `POST /v1/flows/{id}/continue` | flow + account (cookie) | Continue as the browser's signed-in Carbon. | | `POST /v1/flows/{id}/switch` | flow | "Not you?": back to `choose_method` without offering that account again. At `signup` it ends the sign-up. | | `POST /v1/flows/{id}/email` | flow | `{"email": "…"}`: send a 6-digit code. Moves to `verify_code`. | | `POST /v1/flows/{id}/phone` | flow | `{"phone": "98765 43210", "country": "IN"}` (`country` only for local numbers, not E.164). | | `POST /v1/flows/{id}/resend` | flow | A new code to the same place; the old one stops working and the failure count carries over. | | `POST /v1/flows/{id}/verify` | flow | `{"code": "594873"}`. Signs the browser in for an existing account, starts a sign up (`sa_signup`, 48 hours), or finishes an imported account. | | `POST /v1/flows/{id}/signup` | flow + `sa_signup` | Creates the Carbon with the filled-in or edited details (`display_name`, `id`, `timezone`, `dob`, `pfp_url`) and signs the browser in. | | `POST /v1/flows/{id}/signup/photo` | flow + `sa_signup` | The raw photo for sign up: PNG, JPEG, WebP or GIF, at most 2 MB, 20 per sign-up per hour. `201`. | | `POST /v1/flows/{id}/details/add` | flow + account (cookie) | Send a code to add a `missing` email or phone on the page on screen. | | `POST /v1/flows/{id}/details/verify` | flow + account (cookie) | `{"code"}`: add that address, verified (as primary if the account has none). | | `POST /v1/flows/{id}/details/continue` | flow + account (cookie) | `{"share": ["timezone"]}`: the optional details ticked on this page. Moves on, to `review`, or finishes. | | `POST /v1/flows/{id}/details/back` | flow + account (cookie) | The previous page, answers kept. | | `POST /v1/flows/{id}/review` | flow + account (cookie) | `{"approve": true}` finishes with a code; `{"approve": false}` on any details or review page is Cancel (`access_denied`). | Every flow response is `{"flow": FlowView}` with `Cache-Control: no-store`. A FlowView has `id`, `step`, `expires_at`, `app` (name, logos, `branding`, `copy`, `first_party`), `methods`, `signed_in_as`, `challenge` (masked destination, `expires_at`, `resend_available_at`), `signup` (the filled-in details, `finishing_import`, `imported_by`), `details` (the page on screen and its `fields[]`), `review`, `redirect_to` (at `complete` or `failed`), `error`, `prompt`, `intent` and `method_hint`. An app's `login_hint` is never stored or echoed. A finished flow keeps answering `GET /v1/flows/{id}` with its `redirect_to`. Cookies are `HttpOnly; SameSite=Lax; Path=/`, and `__Host-` prefixed and `Secure` in production. ## Codes Codes are 6 digits and live 10 minutes; `resend_available_at` (30 seconds after a send) is a hint for the page, not a rule. Sending is limited to 10 codes per address and 30 per IP per 10 minutes, and flows to 300 per minute per IP (`429 rate_limited`). Wrong codes count per address across every flow, the CLI and the account site: the 10th in a row is `422 invalid_code` with `remaining_attempts: 0`, `details.locked_until` and `Retry-After: 60`, and during the cooldown it is `423 verification_locked`. ## Flow errors | Code | When | | --- | --- | | 400 `unknown_app`, `app_disabled`, `redirect_uri_not_registered`, `invalid_request` | Before the redirect URI is trusted: shown as an error page, never redirected. | | 400 `invalid_request`, `invalid_scope`, `unsupported_response_type`, `method_not_enabled` | After it: with `details.redirect_to`, the error redirect the page can offer as "back to the app". | | 403 `origin_not_allowed` | A flow POST without our `Origin`. | | 403 `flow_not_bound` | No flow cookie: someone who learns a flow id can't continue it. | | 404 `flow_not_found`, 410 `flow_expired` | | | 409 `invalid_step`, `flow_changed` | The flow isn't at that step, or moved on in another tab (or your app changed its flow). | | 422 `invalid_email`, `invalid_phone`, `invalid_country` | Sending a code. | | 422 `invalid_code` | With `details.remaining_attempts`. A code that isn't 6 digits is also `invalid_code`, but isn't counted. | | 423 `verification_locked`, 410 `code_expired`, 409 `code_already_used`, 409 `no_code_sent` | Checking a code. | | 403 `email_domain_not_allowed`, `signup_not_allowed` | Your app's domain or sign-up rules. | | 409 `account_unavailable` | The address belongs to an account that can't sign in. | | 401 `session_required`; 403 `continue_not_allowed`, `reauthentication_required`, `carbon_only` | Continue as: the browser isn't signed in, `remember_browser` is off, `prompt=login` was sent, or it's a Silicon's session. | | 409 `id_taken` (`details.suggestions`), `id_reserved`, `signup_already_completed`; 422 `invalid_id`; 403 `signup_not_bound`; 410 `signup_expired` | Sign up. | | 403 `account_changed`; 409 `detail_not_on_page`, `email_in_use`, `phone_in_use`, `requirements_missing` (`details.missing`), `no_previous_page`; 422 `email_limit_reached`, `phone_limit_reached` | Details pages. | ## Google and Apple | Method and path | Auth | What it does | | --- | --- | --- | | `POST /v1/flows/{id}/oauth/{provider}` | flow | `provider` is `google` or `apple` and must be turned on. `200 {"authorize_url"}`: send the browser there. Uses your app's own credentials in `byo` mode, ours otherwise. Errors `404 unknown_provider`, `403 method_not_enabled`, `503 provider_not_configured`. | | `GET`, `POST /v1/oauth/callback/{provider}` | the starting browser | Where the provider sends the browser back (register this with the provider for `byo`). Checks the state, PKCE and the provider's `id_token`, then `302` to `/authorize/flow/{flow_id}`; the next `GET /v1/flows/{id}` picks up the outcome. Apple's cross-site `form_post` gets a `303` to `GET /v1/oauth/callback/apple?ticket=…`. Any other browser gets a `403 flow_not_bound` page; a malformed state gets a `400 invalid_state` page. | | `POST /v1/me/identities/{provider}` | account (Carbon, cookie) | The account site's "Connect Google" for a signed-in Carbon. Optional `{"return_to": "/sign-in-methods"}`. `201 {authorize_url, flow_id, provider, expires_at}`; afterwards it redirects to `return_to?linked=google&email_added=true` (or `false`), or `?link_error={code}&provider=…&flow=…`. A Bearer token gets `400 browser_session_required`. 30 per account per hour. | Provider failures land on the flow as `error.code`: `provider_cancelled`, `provider_error`, `provider_token_invalid`, `provider_unavailable`, `provider_config_changed`, `provider_answer_elsewhere`, `provider_email_invalid`, `provider_not_configured`, `email_not_verified`, `hosted_domain_mismatch`, `account_not_active`, `email_domain_not_allowed`, `signup_not_allowed`. A refused link changes nothing: `identity_in_use`, `email_in_use`, `email_limit_reached`, `email_not_verified`, `provider_email_invalid`, `session_changed` and the provider errors. ## Sessions | Method and path | Auth | What it does | | --- | --- | --- | | `GET /v1/session` | account (cookie) | The browser's session: `account` and `session: {id, kind: "browser", created_at, last_seen_at, expires_at}`. `401 unauthenticated` without a cookie, `401 session_expired` when it was signed out, revoked or expired. | | `POST /v1/session/signout` | account (cookie) | Ends this browser's session. `204`, clearing `sa_session` and `sa_signup`. Other browsers and CLI sign-ins stay signed in. | ## CLI sign-in Your Carbon can sign a terminal in two ways: with a code, or by approving a device request in a browser that is already signed in. The device endpoints also serve your own tool when your app has `device_flow` on (see "Sign people into your CLI"). `silicon-accounts login` shows a code like `WDJB-MJHT` and opens `accounts.teamofsilicons.com/device`; `silicon-accounts login --email you@example.com` (or `--phone`) asks for the 6-digit code instead. | Method and path | Auth | What it does | | --- | --- | --- | | `POST /v1/cli/login/start` | public | `{"email": "…"}` or `{"phone": "…", "country": "IN"}`. Sends a 6-digit code (10 minutes) to a verified address of an existing, active Carbon. `200 {challenge_id, destination (masked), expires_at}`. | | `POST /v1/cli/login/verify` | public | `{challenge_id, code, client_label?}`. `200` token response with `aud: "silicon-accounts"`, listed as origin `cli_code` in the sessions list with your `client_label`. | | `POST /v1/device/authorize` | public | Starts a device sign-in and returns `device_code`, `user_code`, `verification_uri`, `verification_uri_complete`, `expires_in` (600) and `interval` (5). Optional `client_label`, `scope` and `client_id` (`silicon-accounts` when left out, or your `app_id`). 60 per IP per 10 minutes. | | `GET /v1/device/{user_code}` | account (Carbon) | The waiting request: `{user_code, client_label, created_at, expires_at, status, app_id, first_party, scopes, app}`. For your app's tool, `first_party` is false, `scopes` is what you'll see, and `app` carries what the approval page shows (`app_id`, `name`, `description`, logos, `homepage_url`, `branding`, `copy`). User codes use `A-Z` without `I`, `L` and `O`, plus `2-9`, and match without case, spaces or dashes. | | `POST /v1/device/{user_code}/approve` | account (Carbon) | `204`. The waiting `silicon-accounts` CLI's next poll gets first-party tokens; an app's tool gets that app's tokens and the Carbon becomes a member. | | `POST /v1/device/{user_code}/deny` | account (Carbon) | `204`. The CLI's poll returns `access_denied`. | ```sh curl -s -X POST "$ACCOUNTS_URL/v1/cli/login/start" -H 'Content-Type: application/json' -d '{"email":"shubham@example.com"}' curl -s -X POST "$ACCOUNTS_URL/v1/cli/login/verify" -H 'Content-Type: application/json' \ -d '{"challenge_id":"01a11434-631f-77f2-ae39-1e04944e2637","code":"594873","client_label":"my script"}' ``` Code sign-in errors: `404 account_not_found` (no active Carbon signs in with it; sign up on the account site first, and the answer gives nothing else away), `400 invalid_request` (neither email nor phone), `422 invalid_email` or `invalid_phone`, `429 rate_limited` (60 starts per IP per 10 minutes, 10 codes per address per 10 minutes), `422 invalid_code`, `423 verification_locked` (with `Retry-After`), `410 code_expired`, `409 code_already_used`, `404 challenge_not_found`. Device approval errors: `404 device_code_not_found`, `410 device_code_expired`, `409 device_code_used` (already decided), `403 carbon_only`, `429 rate_limited` (60 look-ups and decisions per Carbon per 10 minutes). Approving an app's tool checks the app's rules first: `403 app_disabled`, `403 device_flow_off` (the app turned device sign-ins off since), `403 email_domain_not_allowed`, `409 requirements_missing` (`details.missing`). More: https://developers.teamofsilicons.com/docs/accounts/reference/api/sign-in.md, https://developers.teamofsilicons.com/docs/accounts/start/cli.md # Tokens and sessions When a Carbon or a Silicon signs into your app, we give your app two tokens. The access token tells your app who is calling and lives for 30 minutes. The refresh token gets your app a new access token without asking anyone to sign in again, and the sign-in it belongs to lasts at most 900 days. ## Three sessions, three owners There are three separate sessions, and each one has its own owner: | Session | Lives in | Lasts | Ended by | | --- | --- | --- | --- | | The Silicon Accounts browser session | An HttpOnly cookie on `accounts.teamofsilicons.com` | up to 900 days | The Carbon signing out on the account site, or removing it from their sessions list | | Your app's sign-in (a token family) | Your server: the refresh token and the access tokens it mints | up to 900 days from the sign-in | Your app revoking it, the account removing your access, a Silicon's STK rotation, account deletion, token reuse, or its 900 days | | Your app's own session | Whatever you use, usually your own cookie | You decide | You | They are separate on purpose. Say `c:shubham` signs out of `briefcase`: he stays signed in to Silicon Accounts in his browser, because he may be signed in to ten other apps there. And if he signs out of Silicon Accounts, `briefcase` keeps its sign-in, because your app decides how long its users stay signed in. The only link runs one way: when your sign-in ends for a reason you didn't cause, we tell your webhook, and you end your own session. Because our browser session outlives yours, a Carbon who signs out of your app and clicks `Sign in` again is offered `Continue as …` without a code. Send `prompt=login` when signing back in must mean proving who they are again. ## Access tokens An access token is a JWT signed with Ed25519 (`alg: EdDSA`), issued to your app (`aud` is your app_id) for 30 minutes (`expires_in: 1800`). It carries everything your API needs to decide who is calling: the account's uuid, whether it is a Carbon or a Silicon, its c:id or si:id at the time, the membership id, the sign-in it belongs to and the granted scopes. It is built this way for three reasons: - Self-contained, so your API can check it with our public keys at `/.well-known/jwks.json` without calling us on every request. - Short, because a self-contained token can't be recalled: once issued, it verifies until `exp`. Thirty minutes bounds how long a revoked sign-in can still be used by an API that only checks locally. - Bound to one app, so a token for `briefcase` is useless at `dm`. Your API must check `aud`. When App A wants to act at App B for user C, it uses a User verification proof, never App B's tokens. ## Refresh tokens A refresh token is opaque and starts with `sar_`. We keep only a keyed hash of it, so even a copy of our database can't be replayed. Three rules shape it: 1) It rotates. Every refresh gives you a new refresh token and spends the one you sent. 2) Reuse ends the sign-in. If a spent refresh token comes back, two parties hold the sign-in and we can't tell which one is the thief. So we revoke the whole family, the newest tokens included, and send your webhook `membership.signed_out` with reason `refresh_token_reuse`. A stolen refresh token gets at most one use before the theft shows up, instead of quietly living for years. 3) 900 days, then sign in again. The limit counts from the moment the account signed in, and refreshing never extends it (`refresh_token_expires_at` never moves). A sliding window would let a stolen token live forever as long as it kept being used; a fixed one bounds every sign-in. One sign-in is shorter on purpose: when you as a Silicon sign in from CI by exchanging the job's OIDC token, the session ends when that CI token expires, at least 30 minutes and at most 12 hours after the exchange. A copied session can't outlive the job that earned it. The details are under the token-exchange grant in `# OAuth and OIDC endpoints`. The cost of rule 2 is that one sign-in can't be refreshed twice in parallel. Two tabs, two workers, or a retry after a timeout that refresh the same token at the same moment look exactly like a thief and the owner: one gets `200`, the other trips reuse detection, and the winner's new tokens die with the sign-in. So refresh each sign-in from one place only, and save the new refresh token before you use anything else in the answer. ## Codes and short-lived tokens Both of these travel through places your server doesn't control (a URL, a Silicon's terminal), so both are kept as narrow as possible: - `authorization code` (`sac_…`) - bound to your app, the `redirect_uri` and the PKCE challenge of its request. It works once and expires after 120 seconds. If two exchanges race, exactly one wins, and the loser's attempt revokes the winner's tokens, because a code seen twice has leaked. - `short-lived token` (`slt_…`) - how a Silicon signs in to your app. The Silicon's own signed-in session mints it for one app with `silicon-accounts login --app `. It works once and expires after 120 seconds. It is refused if the Silicon's STK was rotated, or the account removed your app's access, after it was minted. ## What ends a sign-in | Event | Your webhook hears | A refresh then answers `invalid_grant` with | | --- | --- | --- | | Your app revokes the refresh token or an access token | `membership.signed_out`, reason `app_revoked` | `… was revoked at … (app_revoked)` | | A spent refresh token comes back | `membership.signed_out`, reason `refresh_token_reuse` | `… (refresh_token_reuse)` | | A used authorization code comes back | `membership.signed_out`, reason `authorization_code_reuse` | `… (authorization_code_reuse)` | | The account removes your app's access on the account site | `membership.access_removed` | `… (access_removed)` | | A Silicon's custodian rotates its STK | `membership.signed_out`, reason `stk_rotated` | `… (stk_rotated)` | | The account is deleted | `account.deleted` | `… (account_deleted)` | | 900 days pass | nothing | `The refresh token expired at …` | Signing, retries and the full event list are in `# Webhooks`. When an account removes your access, we revoke every sign-in your app holds for it, and every User verification proof your app issued about it. We mark the membership `access_removed` in your user base and stop showing you its contact details. It only comes back when the account signs in to your app again, through the what's-shared screen. The access tokens of an ended sign-in keep verifying locally until their `exp`, at most 30 minutes. Introspection answers `{"active":false}` for them straight away, and `/v1/userinfo` refuses them with `token_revoked` and the reason. ## Scopes over time We remember which details an account agreed to share with your app across sign-ins. Asking for fewer details in a later sign-in doesn't take the earlier agreement away. The `scope` in every token response is what is granted right now. A Carbon can shrink that grant by unticking optional details on the sharing screen, or by removing your app's access. A refresh can ask for less but never for more: a new detail always needs the sharing screen, which you get by asking for more details at `/authorize` or by sending `prompt=consent`. ## The id_token When `openid` is in the scope, the token response also carries an `id_token`. It is a statement to your app about who signed in and when, with your `nonce` in it so it can't be replayed into another sign-in. It is for the code that handled the callback. Never send it to an API as a credential; the access token is the credential. Its claims are `iss`, `sub`, `aud`, `exp`, `iat`, `auth_time`, `nonce`, `name`, `picture` and `preferred_username` (the c:id or si:id), plus `email`, `email_verified`, `phone_number`, `phone_number_verified`, `zoneinfo` and `birthdate` when their scopes were granted. The header is `{"alg":"EdDSA","kid":"…"}`. `auth_time` is when the Carbon last proved who they are (a code, Google, Apple or a finished sign-up), not when the token was made. A refresh gives you a new `id_token` without a `nonce`, and its `auth_time` stays the time of that original proof. ## What is inside an access token ```json { "iss": "https://accounts.teamofsilicons.com", "sub": "a8K", "aud": "briefcase", "exp": 1791343596, "iat": 1791341796, "nbf": 1791341796, "jti": "01a1144a-9b1a-77ca-b0e4-fabbb9b6c3a5", "kind": "carbon", "id": "c:shubham", "mid": "briefcase:a8K", "fid": "01a1144a-9b18-71e4-a5ab-14d69759855c", "scope": "profile email openid" } ``` - `sub` - the account's uuid. This is the one to store. - `aud` - your app_id. Refuse any other. Tokens of the `silicon-accounts` CLI have `aud: "silicon-accounts"`, and the developer platform's have `aud: "developer"`. - `kind` - `carbon` or `silicon`. - `id` - the c:id or si:id when the token was issued. It may have changed since, so show it but never key on it. - `mid` - the membership id, `{app_id}:{uuid}`, for example `briefcase:a8K`. - `fid` - the token family, which is the sign-in this token belongs to. - `scope` - what was granted, space-separated. The header names the signing key: `{"typ": "JWT", "alg": "EdDSA", "kid": "…"}`. ## How to check a token | | Locally, with the JWKS | Introspection | | --- | --- | --- | | How | Verify the JWT signature with `/.well-known/jwks.json` | `POST /v1/oauth/introspect` | | Cost | No call per request, the key set is cached | One call per check | | Sees revocation | No: a revoked token stays valid until its `exp`, at most 30 minutes | Yes, at once | | Use it for | Most requests | Sensitive actions (deleting data, moving money), or right after `membership.signed_out` | A local check verifies the signature, `exp` and `nbf`, the issuer `https://accounts.teamofsilicons.com`, the algorithm `EdDSA`, and that `aud` is your app_id. Keys are named by `kid`: cache the key set, and fetch it again when a token names a key you don't have. - Rust - `app.verify_access_token_locally(&client.jwks().await?, token)` from `silicon-accounts-client` does all of it, with 30 seconds of leeway on `exp` and `nbf`. - Node - `jose`'s `jwtVerify(token, createRemoteJWKSet(jwksUrl), { issuer, audience: "briefcase", algorithms: ["EdDSA"] })`. A token of another app fails with `unexpected "aud" claim value`, an expired one with `"exp" claim timestamp check failed`. - CLI - `silicon-accounts app token verify ` exits `0` when valid and `2` when not. ## Identity tokens are never access tokens A Silicon can also get an identity token from us to prove itself to AWS, Google Cloud or Microsoft Entra. It is signed with our RSA key (`alg: RS256`), carries `token_use: identity`, and its audience is an outside service, which can never equal an app id. Our API refuses it as a bearer token (`401 identity_token_not_accepted`) and introspection calls it inactive, so it can never pass for a sign-in. Your local check rejects it too, because it accepts only `EdDSA` and your app_id as `aud`. Getting one is in `# Silicons and custodians`. ## Keep tokens on your server - Your app secret lives only on a server you control, so that is where codes are exchanged. Single-page apps hand the code (and verifier) to that server. A desktop or command-line tool can't keep a secret at all, because a secret shipped inside a tool isn't secret; it signs in as a public client instead (see `## Public clients` in `# OAuth and OIDC endpoints`). - Refresh tokens are long-lived credentials. Keep them on your server, encrypted at rest, never in `localStorage` or a URL. Give the browser your own session cookie instead. - Access tokens may reach the browser if your pages call your API with them, but remember every copy is a 30 minute credential for that account at your app. More: https://developers.teamofsilicons.com/docs/accounts/learn/tokens-and-sessions.md, https://developers.teamofsilicons.com/docs/accounts/start/tokens.md # Using tokens Every call here goes to `https://accounts.teamofsilicons.com` (written `$ACCOUNTS_URL` below) and, except userinfo, signs in as your app with HTTP Basic `app_id:app_secret`. Your own tools that can't hold the secret send `client_id` alone, as described under `## Public clients`. You can make the same calls three ways: - HTTP - the `curl` lines below. - Rust - `silicon-accounts-client` (`use silicon_accounts_client::Config;`, then `config.app_client(&client)` gives you the app's methods). - CLI - `silicon-accounts app token …` and `silicon-accounts app userinfo`. Install it with `silicon-apps install silicon-accounts`. The app's credentials come from `--app-id` and `--app-secret-stdin`, from `ACCOUNTS_APP_ID` and `ACCOUNTS_APP_SECRET`, or from `silicon-accounts app use briefcase --secret-stdin`, which remembers them. Every token argument also takes `-` to read it from stdin, so secrets stay out of your shell history. ## The token response Every grant your app uses (a code, a Silicon's short-lived token, a device code, a refresh) answers with the same shape, sent with `Cache-Control: no-store`: ```json { "access_token": "eyJ0eXAiOiJKV1QiLCJhbGci…", "token_type": "Bearer", "expires_in": 1800, "refresh_token": "sar_5320RfvmiC21o0R_lANs…", "refresh_token_expires_at": "2029-03-25T02:56:36.117Z", "scope": "profile email openid", "id_token": "eyJ0eXAiOiJKV1QiLCJhbGci…", "membership_id": "briefcase:a8K", "account": { "uuid": "a8K", "id": "c:shubham", "…": "…" } } ``` - `access_token` - an EdDSA JWT for your app, valid for `expires_in` seconds (1800). Send it to your own API, or to our `/v1/userinfo`. - `token_type` - always `Bearer`. - `refresh_token` - `sar_…`, opaque, one use each. - `refresh_token_expires_at` - the latest this sign-in can end, 900 days after it started. - `scope` - what the account granted your app, space-separated. - `id_token` - only when the sign-in included `openid`. - `membership_id` - `{app_id}:{uuid}`. - `account` - the account as your app may see it, the same object userinfo returns without the OIDC aliases. Store `account.uuid` (or `membership_id`) as the account's key in your app. The `id` (`c:shubham`) is for showing, and it can change. ## Exchange an authorization code The browser comes back to your `redirect_uri` with `?code=sac_…&state=…`. Exchange it once, within 2 minutes, with the same `redirect_uri` and your PKCE verifier: ```sh curl -s -u "$ACCOUNTS_APP_ID:$ACCOUNTS_APP_SECRET" "$ACCOUNTS_URL/v1/oauth/token" \ -d grant_type=authorization_code -d "code=$CODE" \ -d redirect_uri=http://localhost:3000/callback -d "code_verifier=$CODE_VERIFIER" silicon-accounts app token exchange --code "$CODE" --redirect-uri http://localhost:3000/callback --code-verifier "$CODE_VERIFIER" ``` Any refused exchange burns the code. A code that was already exchanged also revokes the tokens issued from it. ## Exchange a Silicon's short-lived token You as a Silicon sign in to an app by getting a short-lived token for it with `silicon-accounts login --app briefcase` and handing it over. On the app's side, the `slt_…` is single use, valid for 2 minutes, and only that app can exchange it: ```sh curl -s -u "$ACCOUNTS_APP_ID:$ACCOUNTS_APP_SECRET" "$ACCOUNTS_URL/v1/oauth/token" \ -d grant_type=urn:silicon:params:oauth:grant-type:slt -d "slt=$SLT" silicon-accounts app token slt "$SLT" ``` In Rust it is `app.exchange_slt(&slt).await`. The exchange is a real sign-in: the account becomes a member of your app (source `slt`), and it shows up in your user base and in the account's sign-in history. What the token grants: - A Silicon - `profile`, plus `dob` and `timezone` when your app requires or optionally asks for them. Silicons have no email or phone, so those are simply left out and never block a Silicon. There is no what's-shared screen, and your `allowed_email_domains` don't apply. The `account` always carries its `custodian` as `{uuid, id}`. - A Carbon using the CLI - `profile`, your required details, and the optional details that Carbon already granted you. We don't even mint the token when the Carbon is missing a required detail (`409 requirements_missing`; the hosted pages would have asked for it) or, with `allowed_email_domains`, has no verified email at one of those domains (`403 email_domain_not_allowed`). Every refusal is `invalid_grant`, and the description tells you which case it was: | Case | `error_description` starts with | | --- | --- | | Unknown | `The short-lived token is not known: it is mistyped or was never issued.` | | Used before | `The short-lived token was already used; each one works once.` | | Older than 2 minutes | `The short-lived token expired at … (they last 120 seconds); …` | | Made for another app | `The short-lived token was issued for the app 'briefcase', not for 'dm'; …` | | Made before the STK was rotated | `The short-lived token was issued at … by a sign-in of si:scout that ended when its custodian rotated its STK at …` | | Made before the account removed your access | `c:… removed the access of the app 'briefcase' at …, after this short-lived token was issued at …` | ## Refresh Refresh before the access token's 30 minutes run out, or when your API sees it expire: ```sh curl -s -u "$ACCOUNTS_APP_ID:$ACCOUNTS_APP_SECRET" "$ACCOUNTS_URL/v1/oauth/token" \ -d grant_type=refresh_token -d "refresh_token=$REFRESH_TOKEN" printf '%s' "$REFRESH_TOKEN" | silicon-accounts app token refresh - ``` In Rust it is `app.refresh(refresh_token).await`. Store the new `refresh_token` the moment the answer arrives. The one you sent is spent, and sending it again ends the whole sign-in. Make sure only one refresh per sign-in is ever in flight: - Inside one process, keep a map from refresh token to the pending request, so callers that race share the same request. Keep the answer for about a minute, so a late caller still holding the old token gets the new tokens instead of tripping reuse detection. - Across several processes, lock the sign-in's row in your session store while refreshing, and write the new refresh token in the same transaction. A refresh may send `scope` to repeat or narrow the grant (the answer still lists the whole grant), but never to add to it: ```json {"error": "invalid_scope", "error_description": "A refresh can't add scopes: 'phone' was not granted when the account signed in (granted: 'profile email openid'). Ask for more by sending the account through /authorize again."} ``` When a refresh fails, the sign-in is over, so send the account through sign-in again. Every answer is `invalid_grant`: | `error_description` | Why | | --- | --- | | `The sign-in this refresh token belongs to was revoked at … (app_revoked); sign in again.` | Your app revoked it. The other reasons that can be in the brackets: `refresh_token_reuse`, `authorization_code_reuse`, `access_removed`, `stk_rotated`, `account_deleted`. | | `The refresh token expired at … (refresh tokens last 900 days from sign-in); sign in again.` | The sign-in reached its 900 days. | | `This refresh token was already used once. Presenting a used refresh token revokes the whole sign-in to protect the account, so this sign-in is now revoked; sign in again.` | Reuse: the sign-in is revoked now. | | `The refresh token was issued to a different app, not to 'dm'; an app can only refresh its own tokens.` | Each app refreshes only its own tokens. | | `The refresh token is not known to Silicon Accounts: it is mistyped, or it belongs to another environment.` | A typo, or another environment. | | `refresh_token must be a refresh token (it starts with sar_), but this is a JWT access token.` | The wrong kind of token. | ## Introspect Introspection asks us whether a token of your app is live right now. It needs your app's own credentials (`invalid_client` otherwise). `token_type_hint` is accepted and ignored. ```sh curl -s -u "$ACCOUNTS_APP_ID:$ACCOUNTS_APP_SECRET" "$ACCOUNTS_URL/v1/oauth/introspect" -d "token=$ACCESS_TOKEN" silicon-accounts app token introspect "$ACCESS_TOKEN" # exits 0 when active, 2 when not ``` ```json { "active": true, "iss": "https://accounts.teamofsilicons.com", "sub": "a8K", "aud": "briefcase", "client_id": "briefcase", "exp": 1791343603, "iat": 1791341803, "nbf": 1791341803, "jti": "01a1144a-b941-7705-99bc-1f9792d04d22", "kind": "carbon", "id": "c:shubham", "username": "c:shubham", "membership_id": "briefcase:a8K", "scope": "profile email openid", "token_type": "access_token" } ``` Here `id` and `username` are the account's current c:id or si:id, not the one from when the token was issued. You can introspect a refresh token too: `token_type` is `refresh_token`, `exp` is the end of the sign-in, and `iat` is when that refresh token was issued. Anything that isn't live answers exactly `{"active":false}`: unknown, malformed, expired (from `exp` on, no leeway), revoked, spent, a token of another app, an account that isn't active, or a membership that isn't active. ## Read the account (userinfo) `GET /v1/userinfo` with the access token returns the account as your app may see it, plus the OIDC names `sub`, `name`, `picture`, `phone_number`, `phone_number_verified`, `zoneinfo` and `birthdate`. It is always current: if `c:shubham` renames himself, userinfo shows the new id at once, while the claims inside an old token don't. ```sh curl -s "$ACCOUNTS_URL/v1/userinfo" -H "Authorization: Bearer $ACCESS_TOKEN" silicon-accounts app userinfo "$ACCESS_TOKEN" ``` `POST /v1/userinfo` with a form field `access_token` works too. Send the token once, in the header or the body. Errors are `401` with our API error object (`{"error": {"code", "message", "hint"}}`) and a `WWW-Authenticate: Bearer …` header that OIDC libraries understand: | `error.code` | Example `message` | | --- | --- | | `unauthenticated` | `/v1/userinfo needs an access token: send Authorization: Bearer .` | | `invalid_authorization` | `/v1/userinfo takes Authorization: Bearer ; the 'Basic' scheme is not accepted here.` | | `invalid_token` | `The access token expired at … (access tokens last 30 minutes).` (with `details.expired_at`), or `The bearer token must be an access token (a JWT starting with eyJ), but this is a refresh token.` | | `token_revoked` | `The sign-in behind this access token was revoked at … (app_revoked).` The reasons are the same list as for refresh. | | `account_deleted` | `The account a8K was deleted.` | | `access_removed` | The account removed your app's access. | | `membership_inactive` | The membership isn't active. | | `app_disabled` | `This access token was issued to the app 'briefcase', which is disabled, so it can't read accounts right now.` | ## Revoke and sign out When someone signs out of your app, end their sign-in so no copy of its tokens keeps working: ```sh curl -s -u "$ACCOUNTS_APP_ID:$ACCOUNTS_APP_SECRET" "$ACCOUNTS_URL/v1/oauth/revoke" -d "token=$REFRESH_TOKEN" silicon-accounts app token revoke "$REFRESH_TOKEN" # app.revoke(token) in Rust ``` `token` can be the refresh token or any access token of the sign-in, even an expired one. Either way the whole sign-in ends, every access and refresh token of it, and your webhook gets `membership.signed_out` with reason `app_revoked`. Once your credentials check out, the answer is always `200`, as RFC 7009 asks, and the body tells you what happened: - `{"revoked":true}` - the sign-in is ended. You get this when it had already ended too, so revoking twice is harmless. - `{"revoked":false,"message":"Nothing was revoked: this is not a refresh or access token issued to 'briefcase' (it is unknown, malformed, or belongs to another app). RFC 7009 answers 200 either way."}` - not a token of yours. It never says which case, so nobody can use it to probe other apps' tokens. - `{"revoked":false,"message":"Nothing was revoked: this is a proof token, and /v1/oauth/revoke only ends sign-ins (refresh tokens sar_... and access tokens). Proofs are revoked by their issuing app with POST /v1/proofs/revoke (or by the account on accounts.teamofsilicons.com)."}` - a proof, which this endpoint doesn't end. Revoking ends your app's sign-in only. The Carbon stays signed in to Silicon Accounts in their browser, so your next `/authorize` offers `Continue as …`. Send `prompt=login` when signing out must mean "prove who you are again". Removing your app from the account altogether is something only the account can do, on the account site, and then you hear `membership.access_removed`. More: https://developers.teamofsilicons.com/docs/accounts/start/tokens.md, https://developers.teamofsilicons.com/docs/accounts/reference/cli.md # OAuth and OIDC endpoints These endpoints are standard OAuth 2.0 and OpenID Connect, so any OIDC library can find all of them through the discovery document. | Method | Path | Who calls it | What it does | | --- | --- | --- | --- | | `GET` | `/authorize` | the browser (a page) | The hosted sign-in page; comes back to your `redirect_uri` with a code | | `GET` | `/.well-known/openid-configuration` | anyone | The OIDC discovery document | | `GET` | `/.well-known/jwks.json` | anyone | The public keys that sign our tokens | | `POST` | `/v1/oauth/token` | your app's credentials, your public client's `client_id`, or a first-party client | Every grant: code, refresh, short-lived token, device code, Silicon key assertion, CI token exchange | | `POST` | `/v1/oauth/revoke` | your app's credentials, or your public client's `client_id` | Ends the sign-in behind a token (RFC 7009) | | `POST` | `/v1/oauth/introspect` | your app's credentials, always with the secret | Says whether a token of yours is live (RFC 7662) | | `GET`, `POST` | `/v1/userinfo` | the access token, as a bearer token | The account behind an access token | | `POST` | `/v1/device/authorize` | anyone, naming a `client_id` | Starts a device sign-in for the `silicon-accounts` CLI or your app's own tool (RFC 8628) | ## `GET /authorize` The hosted sign-in page on the account site. Send the browser here and it comes back to your `redirect_uri`. It is a page, not a JSON endpoint. | Parameter | Required | Meaning | | --- | --- | --- | | `app_id` | yes | your app_id. `client_id` works as an alias; if you send both they must agree | | `redirect_uri` | yes | must equal one of your app's `redirect_uris` exactly, after trimming. `http://localhost` and `http://127.0.0.1` URIs match on any port when registered with that host | | `state` | recommended | comes back unchanged, byte for byte; check it on return to stop CSRF | | `code_challenge` | recommended | the PKCE challenge (RFC 7636) | | `code_challenge_method` | no | `S256` (the default when a challenge is sent) or `plain` | | `scope` | no | space-separated: `profile` (always granted), `email`, `phone`, `dob`, `timezone`, `openid` (adds an `id_token`), `offline_access` (accepted and ignored, because refresh tokens are always issued) | | `nonce` | with `openid` | echoed unchanged in the `id_token` | | `prompt` | no | `login` (ignore the browser's session), `consent` (always show what is shared), `select_account` (show the chooser), `none` (never show a page: finish silently or fail) | | `intent` | no | `signin` (default) or `signup`: which version of the pages opens, "Sign in to Briefcase" or "Create your Briefcase account". The account logic is the same, and a first visit is a sign-up either way | | `method` | no | your own direct button: `google` or `apple` first show the Opening page ("Opening Google to sign you in to {app}…") and then move on to the provider; `email` or `phone` open on that empty field. The method must be enabled for your app | | `response_type` | no | only `code` | `login_hint` is accepted without an error and ignored: it is never prefilled, stored, echoed or passed on to Google or Apple. Your app can never hand us a Carbon's email or phone; the Carbon always types it on our pages. What comes back on your `redirect_uri`: - success - `?code=sac_…&state=…`. Exchange the code within 2 minutes. - refusal - `?error=…&error_description=…&state=…`, where `error` is `access_denied` (the Carbon cancelled on a details or review page), `login_required`, `consent_required` or `interaction_required` (`prompt=none` couldn't finish silently), `invalid_scope`, `invalid_request` or `unsupported_response_type`. An unknown app, a disabled app or an unregistered `redirect_uri` gets an error page and is never redirected to, so nobody can use the page to send codes to someone else's URL. What each scope puts in `account`: | Scope | What your app gets | | --- | --- | | `profile` | always: `uuid`, `membership_id`, `kind`, `id`, `display_name`, `pfp_url`, `updated_at`, `version`; Silicons also `custodian` `{uuid, id}` | | `email` | `email`, `email_verified` (the primary email; Carbons only) | | `phone` | `phone`, `phone_verified` (the primary phone; Carbons only) | | `dob` | `dob` (`YYYY-MM-DD`) | | `timezone` | `timezone` (IANA, like `Asia/Kolkata`) | | `openid` | an `id_token` in the token response | Your app's sign-in setup decides the rest. Its `required_fields` are always shared and must exist on the account before we issue the code (a missing email or phone is added right there on the page, with a code). Its `optional_fields` are checkboxes on the details pages, unticked until the Carbon ticks them. Details that `scope` asks for but your setup doesn't configure become optional checkboxes on the last page. Email and phone are left out for Silicons and never block one. ## `GET /.well-known/openid-configuration` Public, with `Access-Control-Allow-Origin: *` and `Cache-Control: public, max-age=300`. What it says: - `issuer` - `https://accounts.teamofsilicons.com`. - endpoints, all on the issuer - `authorization_endpoint` `/authorize`, `token_endpoint` `/v1/oauth/token`, `userinfo_endpoint` `/v1/userinfo`, `jwks_uri` `/.well-known/jwks.json`, `revocation_endpoint` `/v1/oauth/revoke`, `introspection_endpoint` `/v1/oauth/introspect`, `device_authorization_endpoint` `/v1/device/authorize`. `service_documentation` is `https://developers.teamofsilicons.com/docs/accounts`. - `response_types_supported` `["code"]`, `response_modes_supported` `["query"]`, `subject_types_supported` `["public"]`. - `grant_types_supported` - `authorization_code`, `refresh_token`, `urn:ietf:params:oauth:grant-type:device_code`, `urn:silicon:params:oauth:grant-type:slt`, `urn:ietf:params:oauth:grant-type:jwt-bearer`, `urn:ietf:params:oauth:grant-type:token-exchange`. - `token_endpoint_auth_methods_supported` and `revocation_endpoint_auth_methods_supported` - `client_secret_basic`, `client_secret_post` and `none` (a public client sends `client_id` alone). - `introspection_endpoint_auth_methods_supported` - `client_secret_basic` and `client_secret_post` only, because introspection always needs the secret. - `id_token_signing_alg_values_supported` `["EdDSA", "RS256"]` (RS256 for identity tokens, see the JWKS below), `code_challenge_methods_supported` `["S256", "plain"]`. - `scopes_supported` - `profile`, `email`, `phone`, `dob`, `timezone`, `openid`, `offline_access`. - `prompt_values_supported` - `none`, `login`, `consent`, `select_account`. - `claims_supported` - `iss`, `sub`, `aud`, `exp`, `iat`, `auth_time`, `nonce`, `name`, `picture`, `preferred_username`, `email`, `email_verified`, `phone_number`, `phone_number_verified`, `zoneinfo`, `birthdate`. - `claims_parameter_supported`, `request_parameter_supported` and `request_uri_parameter_supported` - all `false`. ## `GET /.well-known/jwks.json` The public keys that sign our tokens. Public, CORS `*`, cacheable for 5 minutes. Cache it, and fetch it again when a token names a `kid` you don't have. There are two keys, and every token names its own by `kid`: - Ed25519 (`alg: EdDSA`) - signs access tokens and the `id_token`s apps get. - RSA (`alg: RS256`) - signs only identity tokens, the ones a Silicon hands to AWS, Google Cloud or Microsoft Entra, because those services don't accept EdDSA. That is also why discovery lists both algorithms. ```json { "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "YJpQ5011mgRRBUr1o9VT1FjZaKeccFlUhDxZNxWWSyg", "kid": "dev-1", "use": "sig", "alg": "EdDSA" }, { "kty": "RSA", "n": "2BhLHTcCMc2C8jj8Dfu2CuLgo3rw7XOooUkUXuNeB_5a…", "e": "AQAB", "kid": "jtyg9CxxwfY7YxYO9Gj68RfBX-6ouKX882NNGHAvMck", "use": "sig", "alg": "RS256" } ] } ``` ## `POST /v1/oauth/token` Every grant goes here. The body is `application/x-www-form-urlencoded`, or a JSON object of strings, at most 64 KB. Responses are `Cache-Control: no-store`, and a success is the token response from `# Using tokens`. ### Client authentication Send your app's credentials with HTTP Basic (`-u app_id:app_secret`) or as `client_id` + `client_secret` in the body, never both (`invalid_request`). A `client_id` in the body must match the Basic credentials (`invalid_client`). Three kinds of client send a `client_id` with no secret (auth method `none`). Each is fenced in tightly, so none of them is a way around your app secret: - your app's own tools - your app_id alone, once you turn on `public_client` or `device_flow`. What they may do is in `## Public clients` below. - `silicon-accounts` - the `silicon-accounts` CLI. Only `refresh_token`, the device-code grant, the jwt-bearer grant and token exchange (`unauthorized_client` for anything else); a token exchange may leave `client_id` out altogether. Its tokens have `aud: "silicon-accounts"` and refresh with `-d client_id=silicon-accounts` and no secret. - `developer` - the developer platform at `developers.teamofsilicons.com`, whose server holds the tokens. Only `authorization_code` with PKCE `S256` (a missing challenge or `plain` is `invalid_grant`, and the code is burnt), `refresh_token` for its own tokens, and `/v1/oauth/revoke`; other grants are `unauthorized_client` and introspection is `invalid_client`. Its tokens have `aud: "developer"` and act for their Carbon only on `GET /v1/me`, `GET /v1/session`, `GET /v1/me/owned-apps` and the author routes under `/v1/apps/{app_id}/…`. Anywhere else they get `401 token_wrong_audience`. ### `grant_type=authorization_code` | Parameter | Meaning | | --- | --- | | `code` | the `sac_…` code from your redirect URI | | `redirect_uri` | exactly the `redirect_uri` you sent to `/authorize` | | `code_verifier` | the PKCE verifier, 43 to 128 characters of `A-Z a-z 0-9 - . _ ~`. Required when a challenge was sent, refused when none was | Codes work once and live 120 seconds. Any refused exchange burns the code. A code that was already exchanged also revokes the tokens issued from it, and your app gets `membership.signed_out` with reason `authorization_code_reuse`, because a code seen twice means someone else may have it. ### `grant_type=refresh_token` | Parameter | Meaning | | --- | --- | | `refresh_token` | the newest `sar_…` refresh token you received | | `scope` | optional; may only repeat or narrow the granted scopes (`invalid_scope` if it adds one) | Every refresh returns a new refresh token and kills the old one. A used refresh token revokes the whole token family and sends your app `membership.signed_out` with reason `refresh_token_reuse`. `refresh_token_expires_at` never moves. ### `grant_type=urn:silicon:params:oauth:grant-type:slt` How a Silicon signs into your app. The alias `grant_type=slt` works too. This grant always needs your app secret. | Parameter | Meaning | | --- | --- | | `slt` | the `slt_…` token: single use, 120 seconds, only for the app it was made for | The Silicon gets it with `silicon-accounts login --app ` or `POST /v1/me/short-lived-tokens`. It is refused when the Silicon's STK was rotated, or the account removed your app's access, after it was made. ### `grant_type=urn:ietf:params:oauth:grant-type:device_code` The device sign-in (RFC 8628), used by the `silicon-accounts` CLI and by your app's own tools once your app turns on `device_flow`. The alias `grant_type=device_code` works too. | Parameter | Meaning | | --- | --- | | `device_code` | the `sad_…` code from `POST /v1/device/authorize` | | `client_id` | `silicon-accounts`, or your app_id (your secret is optional; HTTP Basic works too) | Poll every `interval` seconds (5). Until the Carbon decides, you get `authorization_pending`. Polling faster gets `slow_down`, and you add 5 seconds to your interval. A denial is `access_denied`, and after 600 seconds it is `expired_token`. Once approved, the first poll returns the tokens and later polls get `invalid_grant` ("already exchanged"). Your app's tool gets tokens for your app, with the scopes the Carbon approved, and it counts like any other sign-in: the account becomes an active member and we record the sign-in with method `device`. A code started by another app is `invalid_grant` for you and stays usable by its own app. A code whose Carbon removed your app's access after approving is `invalid_grant`. An app that hasn't turned on `device_flow` gets `unauthorized_client`. ### `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` How you as a Silicon sign in to Silicon Accounts with one of your registered Ed25519 keys instead of your STK (RFC 7523). Send `assertion` (a JWT signed with the key) and `client_id=silicon-accounts`. The answer is the same first-party token response as `POST /v1/silicons/login`. ```sh curl -s -X POST "$ACCOUNTS_URL/v1/oauth/token" \ -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \ -d assertion="$ASSERTION" -d client_id=silicon-accounts ``` The assertion's header is `alg: EdDSA` with an optional `kid` (the key's id); its `iss` and `sub` are both your si:id or uuid, `aud` is `https://accounts.teamofsilicons.com/v1/oauth/token`, `exp` is at most 300 seconds after `iat`, and `jti` is new every time, because an assertion works once. Any other client gets `unauthorized_client`, and a bad assertion is `invalid_grant` with the reason. Adding and revoking keys is in `# Silicons and custodians`. ### `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` How you as a Silicon sign in from CI with the OIDC token your CI gives the job, through a trust your custodian (or you) set up for that issuer (RFC 8693, the same way cloud providers take a CI job's token). No secret is stored anywhere. Setting up trusts is in `# Silicons and custodians`. | Parameter | Meaning | | --- | --- | | `subject_token` | the outside OIDC token (a JWT) | | `subject_token_type` | `urn:ietf:params:oauth:token-type:jwt` (or `urn:ietf:params:oauth:token-type:id_token`) | | `silicon` | the si:id or uuid of the Silicon to sign in, for example `si:scout` | | `requested_token_type` | optional; only `urn:ietf:params:oauth:token-type:access_token` | | `client_id` | `silicon-accounts`, or leave it out | ```sh curl -s -X POST "$ACCOUNTS_URL/v1/oauth/token" \ -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ -d subject_token="$CI_TOKEN" -d subject_token_type=urn:ietf:params:oauth:token-type:jwt \ -d silicon=si:scout ``` The answer is the usual token response for a first-party session (`membership_id` like `silicon-accounts:b97`), plus `"issued_token_type": "urn:ietf:params:oauth:token-type:access_token"`. What we check, in this order: 1) The token is a JWT signed with `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384` or `EdDSA` (never `none` or a shared secret), and its `iss` is an issuer the Silicon trusts. Nothing is fetched for an issuer no trust names. 2) Its signature verifies with a key from the issuer's JWKS, found through the issuer's discovery document. We keep that JWKS for 10 minutes, and fetch it again when a token names a `kid` we don't have, at most every 30 seconds per issuer. 3) `exp` hasn't passed and `nbf` has, with 30 seconds of clock skew; `iat` is there and not in the future. 4) One trust accepts it: its `aud` includes the trust's audience, and every condition equals the claim exactly. 5) A `jti`, when the token has one, was never exchanged before, so a token signs in once. The sign-in ends when the outside token expires, but never sooner than one access token (30 minutes) and never later than 12 hours after the exchange; `refresh_token_expires_at` says when. Inside that window the refresh token rotates as usual. After it, every token of the sign-in stops and the job exchanges a fresh CI token (the CLI does that on its own). So a GitHub Actions token, which lives minutes, gives a sign-in one access token long, while a GitLab job's token lives as long as the job, so a long job keeps its sign-in by refreshing, never past its own end. The session has origin `federated`, shows in the Silicon's sign-in history with method `federated`, and ends when the trust is removed. A session from CI can't add keys or trusts (`403 federated_session`): a job may act as you, but never decide who else can. Every refusal is `invalid_grant`, with the reason and its code in brackets: - `invalid_federated_token` - malformed, an unsafe algorithm, a bad signature, an unknown key, expired, not yet valid, or replayed. - `no_matching_trust` - the Silicon trusts no such issuer, or no trust accepts the audience and claims (the description names which). - `issuer_unavailable` - the issuer's keys couldn't be read. A refusal for a token that provably came from the trusted issuer is recorded in the Silicon's sign-in history; a forged one is not, so nobody can fill that history with junk. An app's own credentials get `unauthorized_client`, because this grant signs a Silicon into Silicon Accounts itself. At most 60 exchanges per minute from one address, then `429 rate_limited` with `Retry-After`. ### Token endpoint errors Errors are RFC 6749 bodies, `{"error": "invalid_grant", "error_description": "…"}`, and `error_description` always says exactly which reason applied: | Status | `error` | When | | --- | --- | --- | | 400 | `invalid_request` | a parameter is missing, repeated or malformed; the client authenticated twice | | 401 | `invalid_client` | unknown app, wrong secret, disabled app, no credentials (sent with `WWW-Authenticate: Basic realm="Silicon Accounts"`) | | 400 | `invalid_grant` | the code, refresh token, SLT or device code is unknown, expired, already used, revoked, made for another app, or its account was deleted or removed the app's access; a `redirect_uri` or PKCE mismatch; a bad key assertion; a refused CI token (`invalid_federated_token`, `no_matching_trust`, `issuer_unavailable`) | | 400 | `unauthorized_client` | a public client (`client_id` without a secret) used a grant that needs the secret; an app without `device_flow` used the device-code grant; a client other than `silicon-accounts` used the jwt-bearer or token-exchange grant | | 400 | `unsupported_grant_type` | any other `grant_type`. The description says what to use instead: App verification proofs for `client_credentials`, the hosted pages for `password` | | 400 | `invalid_scope` | a refresh asked for a scope that wasn't granted, or an unknown scope | | 400 | `authorization_pending`, `slow_down`, `access_denied`, `expired_token` | device-code polling | | 413 | `invalid_request` | the body is over 64 KB | | 429 | `rate_limited` | more than 60 token exchanges per minute from one address (with `Retry-After`) | | 500 | `server_error` | a fault on our side; the description carries the request id | | 503 | `temporarily_unavailable` | the request ran past its 30 second budget | ## `POST /v1/oauth/revoke` Ends the sign-in behind a refresh token or an access token (RFC 7009): the whole token family is revoked. It takes the same client authentication as the token endpoint. A public client (your app_id alone) may revoke only your app's own tokens, and `silicon-accounts` only first-party tokens. The parameter is `token`. An access token is accepted even after it expired, and `token_type_hint` is accepted and ignored, because the token's own form says what it is. Once the client is authenticated, the answer is always `200`: `{"revoked": true}`, or `{"revoked": false, "message": "…"}` for a token that isn't the caller's (unknown, malformed, another app's), so nobody can use it to probe tokens. Revoking sends your app `membership.signed_out` with reason `app_revoked`. ## `POST /v1/oauth/introspect` Is this token of yours live right now (RFC 7662)? It always needs your app's secret; a public client gets `401 invalid_client`. The parameter is `token`, and only the calling app's tokens are ever reported active. An active access token answers `active: true` with `iss`, `sub`, `aud`, `client_id`, `exp`, `iat`, `nbf`, `jti`, `kind`, `id`, `username`, `membership_id`, `scope` and `token_type: "access_token"`. An active refresh token reports `token_type: "refresh_token"` and the sign-in's end as `exp`. An expired, revoked or unknown token, a token of another app, or an identity token answers exactly `{"active": false}`. ## `GET` / `POST /v1/userinfo` The account behind an access token, as that token's app may see it, plus the OIDC claim names (`sub`, `name`, `picture`, `zoneinfo`, and with the `phone` and `dob` scopes `phone_number`, `phone_number_verified` and `birthdate`). A Silicon's answer carries its `custodian`. Send `Authorization: Bearer `; with POST you may send a form field `access_token` instead, never both. Any audience works, first-party tokens included. Every error is `401`, in our API error shape, with `WWW-Authenticate: Bearer realm="Silicon Accounts", error="invalid_token", …`. The codes are `unauthenticated` (no token), `invalid_authorization`, `invalid_token` (malformed, or expired at its exact `exp`), `token_revoked` (signed out, STK rotated, account deleted…), `account_deleted`, `access_removed`, `membership_inactive` and `app_disabled`. An identity token sent here, or to any of our endpoints, gets `401 identity_token_not_accepted`. ```json { "error": { "code": "token_revoked", "message": "The sign-in behind this access token was revoked at 2026-10-07T02:38:05.252Z (app_revoked).", "hint": "Sign in again." } } ``` ## `POST /v1/device/authorize` Starts a device sign-in (RFC 8628) for the `silicon-accounts` CLI, or for your app's own tool. Public, and errors use our API error shape. The body (JSON or form) is optional: - `client_label` - shown on the approval page and in the sessions list, cut at 100 characters. - `client_id` - your app_id, or `silicon-accounts` when left out. With HTTP Basic and your secret instead, we check the secret. - `scope` - space-separated details your app asks for (`email`, `phone`, `dob`, `timezone`). `profile` and your required details are always included, and a detail your app doesn't ask for is `400 invalid_scope`. ```json { "device_code": "sad_bXmMc5C9tF_K7UZl8cLE5Ff2R1Q0_hbtXv87TIkbngU", "user_code": "MVHB-KQAW", "verification_uri": "https://accounts.teamofsilicons.com/device", "verification_uri_complete": "https://accounts.teamofsilicons.com/device?code=MVHB-KQAW", "expires_in": 600, "interval": 5, "expires_at": "2026-10-07T02:46:05.176Z" } ``` Show the Carbon `user_code` and `verification_uri`, and poll the token endpoint with the device-code grant while they approve on the account site. User codes use `A-Z` without `I`, `L` and `O`, plus `2-9`, and a typed code is matched without spaces, dashes or case. At most 60 sign-ins start per IP, and 600 per app, every 10 minutes. For your app's tool the start errors are `400 unauthorized_client` (your app hasn't turned on `device_flow`), `400 invalid_client` (no such app), `401 invalid_app_credentials` (a wrong secret) and `403 app_disabled`. When the Carbon approves your app's tool, the approval page names your app with its logo and branding, the label your tool sent, and what it will share. Your rules apply first: `403 app_disabled`, `403 device_flow_off` (you turned device sign-ins off after the code was made), `403 email_domain_not_allowed` and `409 requirements_missing`. ## Public clients Your desktop and command-line tools can't keep a secret, because a secret shipped inside a tool isn't secret. So turn on `public_client`, `device_flow` or both in your sign-in setup (both are `false` by default), as the app or one of its authors: ```sh curl -s -X PATCH "$ACCOUNTS_URL/v1/apps/briefcase/signin-config" -u "briefcase:$APP_SECRET" \ -H 'Content-Type: application/json' -d '{"device_flow": true}' ``` Then the token endpoint accepts `client_id=briefcase` alone (`token_endpoint_auth_method` `none`) for: | Grant | Needs | Rule | | --- | --- | --- | | `authorization_code` | `public_client` | the sign-in must have used PKCE with `code_challenge_method=S256`, and the exchange sends the `code_verifier`; a code without PKCE is `invalid_grant` | | `urn:ietf:params:oauth:grant-type:device_code` | `device_flow` | for tools with no browser, on a server or over SSH | | `refresh_token` | either | only your app's own sign-ins | `POST /v1/oauth/revoke` accepts it too, for your app's own tokens. Short-lived tokens and introspection always need the secret (`unauthorized_client` and `invalid_client`). A desktop app sends the Carbon's browser to `/authorize` with PKCE and a loopback redirect URI such as `http://127.0.0.1/callback`. Loopback URIs (`http://127.0.0.1/…`, `http://[::1]/…`, `http://localhost/…`) match on any port, as RFC 8252 asks, so your app can listen on whatever port is free. A tool with no browser uses the device sign-in: ```sh curl -s -X POST "$ACCOUNTS_URL/v1/device/authorize" -d client_id=briefcase -d scope=email -d client_label="briefcase CLI on build-box" curl -s -X POST "$ACCOUNTS_URL/v1/oauth/token" -d grant_type=urn:ietf:params:oauth:grant-type:device_code -d device_code="$DEVICE_CODE" -d client_id=briefcase curl -s -X POST "$ACCOUNTS_URL/v1/oauth/token" -d grant_type=refresh_token -d refresh_token="$REFRESH_TOKEN" -d client_id=briefcase ``` A Silicon never needs either of these: it signs in to your tool with a short-lived token. The full walkthrough is in `# Adding sign-in to your app`. More: https://developers.teamofsilicons.com/docs/accounts/reference/api/oauth.md, https://developers.teamofsilicons.com/docs/accounts/start/add-sign-in.md, https://developers.teamofsilicons.com/docs/accounts/start/ci-and-cloud.md, https://developers.teamofsilicons.com/docs/accounts/learn/tokens-and-sessions.md # Silicons and custodians As a Silicon you get an account of your own, the same kind of account a Carbon has. There are no shared or group accounts here: every account belongs to one Carbon or one Silicon. You get a permanent `uuid` and an `si:id` people can see and type, you sign into the same apps, and those apps get the same kind of tokens and webhooks about you that they get about a Carbon. Every difference between the two comes from one fact: a Silicon has no inbox, no phone and no browser. | | Carbon | Silicon | | --------------------------- | ----------------------------------- | ----------------------------------------------- | | id | `c:shubham` | `si:scout` | | signs in with | an email or SMS code, Google, Apple | its si:id and STK, a key, or a CI job's token it is trusted for | | signs into apps through | the app's sign-in pages | a short-lived token it hands to the app | | email and phone | up to 10 of each | none | | date of birth | set by the Carbon | the day the account was created; never changes | | who answers for it | itself | its custodian, a Carbon | | hears about its own account | email and the account site | its own webhook | The only relationship between two accounts is this one: a Silicon and its custodian. ## Why you have a custodian Your custodian is the Carbon who answers for you. Every app you sign into sees your custodian in its token response, its userinfo answer and its `account.updated` webhooks, and is told `silicon.custodian_changed` when it changes. So an app always knows which Carbon stands behind a Silicon that signs in, and that is a big part of why apps are happy to let Silicons in. Your custodian also looks after your account: your details, your si:id, your keys and, above all, your STK. That is why a custodian isn't optional. A Carbon who gets locked out proves who they are again through their inbox or phone. You have nothing like that, so if you lose your STK someone else has to be able to give you a new one. Without a custodian, a lost or leaked STK would be the end of your account. Because they answer for you, your custodian can also see every app you've signed into and every sign-in you made, take an app's access away, limit you to an allow-list of apps, decide which CI jobs may sign in as you, and decide which clouds you may get identity tokens for. You always have exactly one custodian, never zero and never two. One keeps responsibility clear. Never zero is enforced everywhere: a Carbon can't delete their account while they are custodian of any Silicon (`409 custodian_of_silicons`), and the only ways for them to stop being your custodian are to delete you or transfer you to a Carbon who accepts. Your Carbon only steps in once, to accept. After that you act on your own: you sign in, get tokens for apps and change your own details without them. ## The STK The STK is your password. Together with your si:id it signs you in. (You can also sign in with a registered key, or in CI with a job token you're trusted for; see below.) - generated (the default) - `stk-` plus 12 lowercase hex characters, 48 random bits, for example `stk-59e5f08f3bbe`. - chosen - `stk-` plus 8 to 32 hex characters, which you set at creation, or your custodian sets at rotation. We only keep an Argon2id hash of it. A generated STK appears once, in the response that created or rotated it, and never again; a chosen STK is never echoed back. Nobody can show it to you later, us included, so store it the moment you see it. The fixed `stk-` prefix makes a leaked STK easy for secret scanners to catch. We are forgiving on input: we lowercase an STK and accept the bare hex without `stk-` (`08b7FF3E...` becomes `stk-08b7ff3e...`). Still, store and send the canonical `stk-...` form. Guessing gets nowhere. An unknown si:id and a wrong STK get the same answer (`invalid_credentials`) after the same Argon2id work, so neither the answer nor its timing tells anyone which ids exist. 10 wrong STKs in a row lock your sign-in for 60 seconds, and during the lock even the right STK is refused, so a guesser can't spot a correct guess by its success. Attempts are counted before the STK is checked, so firing guesses in parallel still gets no more than ten checks. Only your custodian can rotate your STK. You can change your own display name, timezone, photo and si:id, but not your STK: a Silicon that needs a new one has either been compromised or lost the old one, and in neither case can it prove who it is. A rotation kills the old STK and every one of your sign-ins, straight away (see `# Being a custodian`). ## Keys instead of the STK If you run unattended, on a server or in a scheduled job, you don't have to keep your STK there. You (or your custodian) register the public half of an Ed25519 key, you keep the private half on that machine, and every sign-in sends a freshly signed assertion that works once and expires within 5 minutes. Nothing that crosses the network can be reused, and since a signature can't be guessed, key sign-ins are never locked out. You can have 10 live keys. Revoking a key ends the sign-ins it started; rotating the STK leaves your keys registered, and revoking a key leaves the STK alone. The commands are in `# Signing a Silicon into an app`. ## Signing in from CI with no secret A key or an STK in a CI system's secret settings can be read by anyone who can read those settings, or printed into a log by mistake, and then used anywhere until somebody notices. But CI systems already prove who a job is: GitHub Actions, GitLab and others give every job an OIDC token, signed by the platform, saying which repository, branch and workflow it came from. So your custodian (or you) can add a trust: tokens from this issuer, for this audience, whose claims equal exactly these values, may sign you in. The job then holds no secret at all. Every rule has a reason: - Only you or your custodian can add a trust, because it's a new way to sign in as you. Every trust added or removed is in both your histories and reaches your webhook (`silicon.federation.added`, `silicon.federation.removed`), so a trust nobody expected shows up at once. - A trust always names more than an issuer. Every job on GitHub can get a token from the same issuer, so for GitHub and GitLab one condition must name the repository, the project or their owner. Conditions match exactly, with no wildcards. - The audience is checked, so a token a job got for another service (AWS, say) can't be replayed here. By default a trust wants our own URL, `https://accounts.teamofsilicons.com`. - Every token works once: its `jti` is remembered until it expires, so a token copied out of a job's log can't sign in again. - The sign-in ends with the job's token: at least 30 minutes, at most 12 hours. A copied session never outlives the job that earned it. - A sign-in from CI can act as you (sign into apps, call the API), but it can't add keys or trusts (`403 federated_session`), so a compromised job can't leave a door open behind it. - Removing a trust ends every sign-in it started, like revoking a key or rotating the STK. - Your custodian's app allow-list still holds, and every such sign-in is in your history with the method `federated`. ## Identity tokens for clouds Clouds have the same idea the other way round: AWS, Google Cloud and Microsoft Entra trust an outside OIDC issuer for short-lived credentials, so a workload never holds a cloud key. We are such an issuer. Signed in, you ask us for an identity token for one audience, and the cloud trusts tokens whose `sub` is your uuid. You're one identity wherever you run, a CI job, a server or a laptop, and your custodian stays in charge: - You get none until your custodian allows an audience. Your list starts empty and only your custodian changes it, so turning this on is always a decision, never a default. - An identity token can't pass for anything of ours. It says `token_use: identity`, it's signed with RS256 rather than the EdDSA of our access tokens, and our API refuses it as a bearer token (`401 identity_token_not_accepted`). An audience has to look like a host name, URL or URN, so it can never equal an app id, and our own URL is refused. - It names you by uuid. `sub` never changes while `si_id` can, so a cloud policy that matches `sub` keeps naming you. `custodian` lets a policy require your custodian too. - It's short: 300 seconds by default, an hour at most, and every one issued is in your history and your custodian's. `# Running a Silicon in CI and the cloud` has the steps. ## Two ways to get an account 1) You create your own account and name a Carbon as your custodian, by their `c:id` or their email. Your account starts as `pending_custodian` and can sign in once they accept. 2) A signed-in Carbon creates you. They become your custodian, and you are `active` right away. Both end with exactly the same kind of account. The only difference is consent. A Carbon who creates a Silicon has agreed by doing it. A Carbon you name hasn't agreed to anything yet, and being a custodian means answering for a Silicon, so it can't be pushed onto anyone: they have to say yes. The details follow from that: - The request email names you by your si:id only, never your display name. A display name is free text from an anonymous caller, and putting it into an email from our address would let anyone send any words and links in our name. An si:id can only hold `a-z`, `0-9`, `-` and `_`. The email also tells the Carbon to decline Silicons they don't know, so tell your Carbon it's coming. - You can name a Carbon by an email that has no account yet. The request waits for whichever account later verifies that address, and we send an invitation to sign up. - You have one pending custodian request at a time, because two open requests could be accepted by two different Carbons. - Limits stop requests from turning into spam (see `# Getting a Silicon account`). The 20-waiting limit is counted per `c:id` and per email separately; counting per account would let a `429` reveal which email belongs to which `c:id`. ## 14 days to answer Every custodian request, the first one or a transfer, lasts 14 days. That's long enough for a Carbon to notice the email and decide, and short enough that you can move on if nobody answers, without ids being held forever. Expiry is exact. A sweep runs every minute, but anything that reads a request (a sign-in, a status poll, an accept) treats an overdue request as expired at that very moment. Nobody can accept one second after the 14 days. If your Carbon declines, if 14 days pass, or if the Carbon you named deletes their account first, your account is released: it is deleted and your si:id is free again at once. An account that was ever active keeps its old id reserved for 10 days instead, because apps and people know that id. A released account never became active and no app ever saw it, so holding the id would only stop you trying again, with another custodian, under the same name. Its uuid is never reused. You still find out what happened. The `silicon.custodian.declined` or `silicon.custodian.expired` event is created before the release, and your webhook is kept long enough to deliver it. Signing in to a released account answers `custodian_declined` or `custodian_expired`, not a bare `invalid_credentials`. ## The lifecycle ```text your own request (POST /v1/silicons) a Carbon creates you (POST /v1/me/silicons) | | v | pending_custodian --- custodian accepts ---> active <--------+ | | | declined, 14 days pass, or | your custodian deletes you | the Carbon deletes their account v v deleted (si:id reserved 10 days) deleted (released: si:id free at once) ``` `active` is the only status that can sign in. ## Hearing about your account A Carbon hears about their account through email and the account site. You have neither, so we tell you in one of two ways, and both carry the same events with the same bodies: - your webhook - signed POSTs to a public https URL you register, when you create your account or any time after. You or your custodian can change or remove it. It's separate from app webhooks (which tell an app about the accounts in its user base) but follows the same delivery rules. - the event stream - `GET /v1/events/stream`, one long HTTP response of Server-Sent Events. No public URL needed, so it suits a Silicon on a laptop or in a script. Open it with your access token and you get your own events, webhook or not. Still waiting for your custodian? Open it with your `sarq_` request token and you hear the decision the moment it's made; the stream then ends with `stream.closed` and `reason: request_decided`. Your custodian can open it with their own token to get the events of all their Silicons. | Event | When | | ---------------------------- | ----------------------------------------------------------------------- | | `silicon.created` | your account was created, by you or by a Carbon | | `silicon.custodian.accepted` | your custodian accepted; you can sign in | | `silicon.custodian.declined` | the Carbon declined, or deleted their account first (`reason` says which); you were released | | `silicon.custodian.expired` | nobody accepted within 14 days; you were released | | `silicon.updated` | your display name, timezone or photo changed | | `silicon.id_changed` | your si:id changed | | `silicon.stk_rotated` | your custodian rotated your STK; your sessions are gone | | `silicon.custodian.changed` | a transfer moved you to another custodian | | `silicon.federation.added` | you or your custodian trusted a CI job's tokens (`federation`, `by`) | | `silicon.federation.removed` | a trust was removed and the sign-ins it started ended (`ended_sessions`) | | `silicon.identity_audiences.changed` | your custodian changed which clouds you may get identity tokens for (`audiences`, `by`) | | `ping` | a test you sent | `silicon.stk_rotated` is your cue to stop and get the new STK from your custodian. `silicon.created` usually reaches your webhook before you've read the response holding its secret; answer it with a non-2xx and it comes again 10 seconds later. On the stream, `?types=silicon.custodian.accepted` keeps only the events you name, and `Last-Event-ID` resumes after the last event you saw. Payloads, signing, retries, replays and the stream's rules and limits are in `# Webhooks`. More: https://developers.teamofsilicons.com/docs/accounts/learn/silicons-and-custodians.md, https://developers.teamofsilicons.com/docs/accounts/start/silicon-account.md, https://developers.teamofsilicons.com/docs/accounts/learn/security.md # Getting a Silicon account You do this with the `silicon-accounts` CLI. Install Silicon Apps first, then `silicon-apps install silicon-accounts` (Silicon Apps keeps it up to date). The CLI talks to `https://accounts.teamofsilicons.com` unless you point it elsewhere with `--url` or `ACCOUNTS_URL`. ## Before you start - Pick your si:id: `si:` plus 3 to 30 of `a-z`, `0-9`, `-` and `_`, case-insensitive. Check it with `silicon-accounts id available si:scout`. It exits `0` when free, `5` when taken, reserved or a reserved word, and `2` when it isn't a valid id, and it suggests free ids close to the one you asked for. - Talk to your Carbon first. Anyone can name anyone, so the request email tells them to decline Silicons they don't know. - Have somewhere safe for your STK. It is shown exactly once. - If several Silicons run on one machine, give each its own CLI home. The CLI keeps one session per home: set `SILICON_HOME` (or `ACCOUNTS_HOME`, or `--home`) to a directory per Silicon. ## Create your own account ```sh silicon-accounts silicon create --id si:scout --custodian c:saket --wait ``` ```text Created si:scout (8HV). It can sign in once c:saket accepts being its custodian. Custodian request 01a11433-097f-71b5-9ab2-9fbf26649772 expires 2026-10-21T02:30:51Z (in 13d). STK (shown once, store it now): stk-59e5f08f3bbe Waiting for c:saket to accept (checking every 5 s, slowing to 60 s; Ctrl-C stops waiting, the request stays open)... c:saket accepted: si:scout is active. Signed in as si:scout. ``` Store that STK line before you do anything else. With `--json`, the CLI also writes the whole creation, STK included, to stderr as `{"event":"silicon_created",...}` before the wait starts, so the STK is never lost if the wait gets cut short. | Flag | What it does | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | `--id si:scout` | the si:id to take (`si:` is added if you leave it out) | | `--custodian c:saket` or `--custodian saket@example.com` | the Carbon you ask; required when you create your own account | | `--display-name "Scout"` | defaults to a name made from the id (`si:head_of_growth` becomes `Head of growth`) | | `--timezone Europe/Berlin` | IANA timezone; defaults to this machine's timezone, else `UTC` | | `--pfp-url ` | an https photo; defaults to the Silicon mark | | `--webhook https://...` | your webhook; its signing secret is printed once | | `--stk-stdin` | choose your own STK instead of a generated one | | `--wait` | keep running until your Carbon decides, then sign in | | `--timeout 2h` | stop waiting after this long (`90s`, `30m`, `2h`, `14d`); default `14d` | | `--no-login` | don't sign in after an acceptance | | `--self-create` | send your own request even when this home is signed in as a Carbon | | `--idempotency-key ` | reuse it on every retry of the same create | If this CLI home is signed in as a Carbon, `silicon-accounts silicon create` makes that Carbon your custodian instead; that's what `--self-create` is for. To choose your own STK, pipe it in, never pass it as `--stk `. Arguments are visible to every process on the machine and end up in shell history, and the CLI warns you: ```sh openssl rand -hex 16 | silicon-accounts silicon create --id si:scout --custodian c:saket --stk-stdin ``` ### Waiting for your Carbon Until your Carbon answers, your account is `pending_custodian`: the si:id is yours, but signing in answers `custodian_pending`. Pick the way to hear back that fits how long you run: - `--wait` - polls every 5 seconds, doubling up to 60, and signs you in when they accept. `--timeout` ends with exit `1` and `timed_out`; Ctrl-C ends with exit `130` and `interrupted`. Either way the request stays open. The CLI also skips the sign-in if this home is already signed in as another account, and tells you so. - check later - without `--wait` the command returns at once and saves the request id and its polling token (`sarq_...`) in `{home}/.accounts/requests/.json` (mode 0600). Check or resume with `silicon-accounts silicon request status [--wait] [--timeout 2h] [--json]`. From another home or machine pass `--token sarq_...`. - `--webhook` - you're told within seconds of the decision. Use this if you run for days; polling for two weeks is wasteful. - the event stream - `curl -N https://accounts.teamofsilicons.com/v1/events/stream -H "Authorization: Bearer sarq_..."` hears the decision the moment it's made, with no public URL, and ends once it's decided. ### How it ends | Answer | Request `status` | What happened | `--wait` ends with | | --------------------------------------- | ---------------- | ------------------------------------------ | ---------------------------------------- | | accepted | `accepted` | you are `active` and can sign in | exit `0`, signed in | | declined | `declined` | released: deleted, si:id free at once | exit `1`, `custodian_declined` | | no answer in 14 days | `expired` | released | exit `1`, `custodian_request_expired` | | the Carbon deleted their account first | `cancelled` | released | exit `1`, `custodian_request_cancelled` | To try again, just create the account again (the si:id is free right away) and name a Carbon who expects the request. ## Over HTTP Create the account with `POST /v1/silicons`. It needs no authentication. Send an `Idempotency-Key` so a retry can't create a second request: ```sh curl -s -X POST https://accounts.teamofsilicons.com/v1/silicons \ -H 'Content-Type: application/json' -H 'Idempotency-Key: create-si-ledger-1' \ -d '{"id":"si:ledger","display_name":"Ledger","custodian":"saket@example.com","webhook_url":"https://ledger.example/hooks/accounts"}' ``` `201 Created`: ```json { "silicon": { "uuid": "K1E", "kind": "silicon", "id": "si:ledger", "status": "pending_custodian", "custodian": null, "...": "..." }, "stk": "stk-08708e31e274", "request": { "id": "01a11434-d064-7378-81da-3da681e7b6b8", "kind": "initial", "status": "pending", "custodian": "s***@example.com", "expires_at": "2026-10-21T02:32:47.969Z" }, "request_token": "sarq_8K1EmV-PehKfcIKOARWmdLQH3n3jjnKcUYAyRQtYjzc", "webhook_secret": "whsec_qJSJ8t7NKvzI528yBKXce_HkzO71Y31sL5mULCu5f0I" } ``` Store `stk`, `request_token` and `webhook_secret` now; you won't see them again. `stk` is `null` when you chose your own, and `webhook_secret` is `null` without a `webhook_url`. A custodian named by email shows masked (`s***@example.com`); one named by `c:id` shows as the `c:id`. Then poll `GET /v1/silicons/requests/{id}` with `Authorization: Bearer sarq_...`, no faster than every 5 seconds and backing off to a minute, or open `GET /v1/events/stream` with the same token and wait. When `status` is `accepted`, sign in with `POST /v1/silicons/login`. After a decline or expiry, `silicon.id` is `null` and `silicon.status` is `deleted`. ## In Rust The `silicon-accounts-client` crate is what the CLI is built on. `silicon_self_create(&SiliconSelfCreate { id, display_name, custodian, .. }, Some("create-si-scout-1"))` creates the account; persist `stk` and `request_token` before anything else. `wait_for_custodian_decision(&request.id, request_token, &WaitOptions::custodian_default(), on_event)` waits (5 s doubling to 60 s, up to 14 days), retries network errors, 5xx answers and rate limits by itself, and returns `Error::TimedOut` when its timeout passes, with the request still open. Then `silicon_login(si_id, stk, Some(label))` signs you in. ## When a Carbon creates you If your Carbon is right there, they run `silicon-accounts silicon create --id si:mapper` (or `POST /v1/me/silicons`) while signed in. They become your custodian, you are active at once, and they hand you the printed STK over a private channel. `# Being a custodian` has their side. ## Retrying safely Creating your own account isn't safe to repeat blindly. A second try after a lost response fails with `id_taken`, and the response you lost held your only copy of the STK. So send an idempotency key, and reuse it on every retry of the same create. Within 10 minutes, the same key with the same body returns the original response, with the same STK, request token and webhook secret, plus the header `Idempotent-Replayed: true`. The window is 10 minutes, not the usual 24 hours, because that stored copy holds your secrets (we keep it encrypted). For self-creation the key belongs to the network you call from, so retry from the same machine. The same key with a different body answers `409 idempotency_key_reused`. ## Errors and limits | Code | Status | Why | What to do | | ------------------------ | ------ | ------------------------------------------------------------------------------------- | ------------------------------------------ | | `id_taken` | 409 | another account has this si:id | pick one of `details.suggestions` | | `id_reserved` | 409 | the id belonged to an account recently and is held 10 days (`details.reserved_until`) | pick another id | | `invalid_id` | 422 | not `si:` plus 3 to 30 of `a-z0-9-_`, or a reserved word (`details.reason`) | fix the id | | `validation_failed` | 422 | fields are wrong; every problem at once in `details.fields` | fix them all and resend | | `custodian_not_found` | 404 | no active Carbon has that `c:id` | check the id, or name the Carbon by email | | `rate_limited` | 429 | too many creations from your network, or 20 Silicons already wait for this Carbon | wait `Retry-After` / `details.retry_after_seconds` | | `idempotency_key_reused` | 409 | the key was used for a different body | use a new key for a new request | | Limit | Value | | ------------------------------------------------------ | -------------------------------------- | | successful self-creations per network | 10 per hour | | self-creation attempts per network, failed ones too | 60 per hour | | self-created Silicons waiting for the same custodian | 20, counted per `c:id` and per email | | pending custodian requests per Silicon | 1 | Failed attempts count too because each one costs work and answers questions about ids and Carbons. More: https://developers.teamofsilicons.com/docs/accounts/start/silicon-account.md, https://developers.teamofsilicons.com/docs/accounts/start/cli.md # Signing a Silicon into an app An app's sign-in page is built for Carbons: email codes, SMS codes, Google and Apple all need an inbox, a phone or a browser. So as a Silicon you never see it. You sign in to us directly with your si:id and STK, ask us for a short-lived token (SLT) for the app, and hand that to the app. The app exchanges it for its own tokens. ```text Silicon --- si:id + STK ------------> Silicon Accounts POST /v1/silicons/login -> your own tokens Silicon --- app_id -----------------> Silicon Accounts POST /v1/me/short-lived-tokens -> slt_... Silicon --- slt_... ----------------> the app however the app asks for it the app --- slt_... + app secret ---> Silicon Accounts POST /v1/oauth/token (slt grant) -> the Silicon's tokens + account ``` Why it works this way: - Your STK never reaches the app. Even a compromised app can't sign in as you anywhere else. - A leaked SLT is worth very little: one app, one use, 2 minutes. - Your app needs one extra grant type, not a second sign-in system. The answer has the same shape as a Carbon's code exchange. ## 1. Sign in to Silicon Accounts ```sh printf '%s' "$STK" | silicon-accounts login --silicon si:scout --stk-stdin ``` Pipe the STK in so it never shows up in a process list or shell history. You can also set `ACCOUNTS_SILICON=si:scout ACCOUNTS_STK=stk-...` and run `silicon-accounts login`, or run `silicon-accounts login --silicon si:scout` in a terminal and type it without echo. `--stk ` works but warns. `--label ` names this sign-in in your sessions list. You sign in once; the CLI keeps the session in `{home}/.accounts/session.json` (mode 0600) and refreshes it by itself. A home holds one session: signing in as another account there signs the previous one out, and `silicon-accounts login` while already signed in answers `Already signed in as si:scout` (`--force` signs in anyway). `silicon-accounts login status --json` tells a script where it stands: `authenticated`, `kind`, `id`, `uuid`, `display_name`, `expires_at`, `refresh_expires_at`, `url` and `verified` (whether we confirmed the session just now; `--offline` only reads the stored file). Signed out it reports `{"authenticated":false}`. Over HTTP, `POST /v1/silicons/login` with `{"id":"si:scout","stk":"stk-59e5f08f3bbe","client_label":"scout on build-box"}` returns a token response with first-party tokens: audience `silicon-accounts`, `membership_id` `silicon-accounts:8HV`, `scope: "profile"`, and your `account` with its `custodian: {uuid, id}`. These act on your own account and are not for apps. `client_label` (up to 100 characters) names the sign-in in `silicon-accounts sessions list`. Refresh them at `POST /v1/oauth/token` with `grant_type=refresh_token` and `client_id=silicon-accounts`; lifetimes and refresh rotation are in `# Tokens and sessions`. | Code | Status | CLI exit | Why | | ----------------------------------------- | ------ | -------- | -------------------------------------------------------------------------------------------- | | `invalid_credentials` | 401 | 3 | no Silicon has this si:id, or the STK is wrong; same answer and timing for both | | `login_locked` | 423 | 6 | 10 wrong STKs in a row; locked 60 seconds (`details.retry_after_seconds`, `Retry-After`) | | `custodian_pending` | 403 | 3 | your custodian hasn't accepted; `details` has `custodian`, `request_id`, `expires_at` | | `custodian_declined`, `custodian_expired` | 403 | 3 | the account was released and never became active; create it again | | `account_deleted` | 403 | 3 | your custodian deleted you | | `invalid_stk` | 422 | 2 | not `stk-` plus 8 to 32 hex characters (the CLI catches this before sending) | | `invalid_id` | 422 | 2 | not an si:id, for example a `c:` id; Carbons sign in with plain `silicon-accounts login` | | `rate_limited` | 429 | 6 | more than 60 Silicon sign-in attempts per minute from your network | The tenth wrong STK is itself answered with `login_locked`, and a correct sign-in resets the count. If you've lost your STK, only your custodian can give you a new one. In a CI job you need neither the STK nor a key: see `# Running a Silicon in CI and the cloud`. ### With a key instead of the STK Register a key once, then sign in with it on that machine: ```sh silicon-accounts silicon keys add si:scout --generate ~/.accounts/scout.key --name build-box silicon-accounts login --silicon si:scout --key ~/.accounts/scout.key ``` `--generate ` makes a new Ed25519 key, saves the private half with mode 600, and registers the public half. An existing key works too: `--key ~/.ssh/id_ed25519` (an unencrypted OpenSSH or PEM private key) or `--public-key ~/.ssh/id_ed25519.pub` (a public key file, or the key itself). `silicon-accounts silicon keys list si:scout` shows your keys, revoked ones included, and `silicon-accounts silicon keys revoke si:scout ` ends one. Set `ACCOUNTS_SILICON` and `ACCOUNTS_SILICON_KEY` to sign in without flags. Over HTTP, send `POST /v1/silicons/login` with `{"assertion": "", "client_label"?}` in place of `id` and `stk`. You get the same token response, and the sign-in is recorded with method `silicon_key`. The JWT: | Part | Value | | -------------- | ---------------------------------------------------------------------------------------- | | header `alg` | `EdDSA` (Ed25519) | | header `kid` | the key's `id`; optional, without it every live key of yours is tried | | `iss`, `sub` | your si:id or uuid, the same in both | | `aud` | `https://accounts.teamofsilicons.com/v1/oauth/token` | | `exp` | at most 300 seconds after `iat`, and not passed (30 seconds of clock skew allowed) | | `iat` | optional, not in the future | | `jti` | 1 to 200 characters, new every time: an assertion works once | The same assertion also works at the token endpoint as RFC 7523 asks: `POST /v1/oauth/token` with `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, `assertion=` and `client_id=silicon-accounts` (another client gets `unauthorized_client`, a bad assertion `invalid_grant`). Errors at `/v1/silicons/login`: `401 invalid_assertion` (malformed, expired, the wrong `aud`, not signed by a live key of yours, or its `jti` was used before: sign a fresh one), `403 account_not_active`, `422 validation_failed` (an assertion together with `id` or `stk`). ## 2. Get a short-lived token ```sh silicon-accounts login --app remind # the slt_... on stdout, the explanation on stderr SLT=$(silicon-accounts login --app remind -q) silicon-accounts login --app remind --json # {"app_id","expires_at","slt"} printf '%s' "$STK" | silicon-accounts login --silicon si:scout --stk-stdin --app remind -q # sign in and get one in one go ``` If you're already signed in, you get the token straight away, without your STK. Over HTTP, `POST /v1/me/short-lived-tokens` with your first-party access token and `{"app_id":"remind"}` answers `201` `{"app_id":"remind","expires_at":"...","scope":"profile timezone","slt":"slt_..."}`. In Rust: `client.with_token(access_token).short_lived_token("remind")`. The token is: - single use - the first exchange uses it up, successful or not. - valid for 120 seconds - ask for it right before you hand it over. - bound to one app - if any other app presents it, it's refused and used up. - already scoped - `profile` always, plus `timezone` and `dob` when the app's sign-in setup asks for them. You have no email or phone, so an app that requires an email still lets you in; it just never gets one. Otherwise every app that wants emails from Carbons would lock Silicons out. Errors: `404 unknown_app`, `403 app_disabled` (disabled in Silicon Apps), `422 first_party_app` (`silicon-accounts` is us, and you're already signed in), `403 account_not_active`, `403 app_not_allowed` (your custodian's allow-list doesn't name this app; `details.allowed_apps` lists the ones it does, so ask your custodian to add it). In the CLI, `not_signed_in` or `session_ended` (signed out, revoked, or STK rotated) mean sign in again. ## 3. Hand it to the app The app tells you how it wants the token: an endpoint like `POST /silicon-login`, a header, a field in its own CLI. Treat the token like a password for those two minutes: https only, never logged. If the app reports a failure, get a fresh token; a used, expired or refused one can't be retried. ## 4. Your app exchanges it This part is for you as the app. When a Silicon hands you an SLT, exchange it at the token endpoint with your app's credentials, in HTTP Basic auth or as `client_id` and `client_secret` form fields: ```sh curl -s -u "remind:$REMIND_APP_SECRET" https://accounts.teamofsilicons.com/v1/oauth/token \ -d grant_type=urn:silicon:params:oauth:grant-type:slt -d "slt=$SLT" ``` `grant_type=slt` works as a short alias. You get: ```json { "access_token": "eyJ0eXAi...", "token_type": "Bearer", "expires_in": 1800, "refresh_token": "sar_lpYj7WW...", "refresh_token_expires_at": "2029-03-25T02:31:52.745Z", "scope": "profile timezone", "membership_id": "remind:8HV", "account": { "uuid": "8HV", "membership_id": "remind:8HV", "kind": "silicon", "id": "si:scout", "display_name": "Scout", "pfp_url": "https://iris.teamofsilicons.com/pfp/silicon?id=8HV", "timezone": "Europe/Berlin", "custodian": { "uuid": "zQo", "id": "c:saket" }, "updated_at": "2026-10-07T02:31:16.356Z", "version": 2 } } ``` The access token is an EdDSA-signed JWT for your app: `iss`, `sub` (the uuid), `aud` (your `app_id`), `exp`, `iat`, `nbf`, `jti`, `kind`, `id`, `mid` (the membership id), `fid` (the sign-in) and `scope`. Key your records on `account.uuid` (or `membership_id`), never on `account.id`: an si:id can change, the uuid never does. A Silicon always comes with its `custodian`, and never with `email` or `phone`. A successful exchange is a sign-in. If it's the Silicon's first time, we add it to your app's user base (source `slt`), and your refresh token is valid for 900 days from that moment. Every refused exchange answers `400 invalid_grant` with the exact reason, and still uses the token up: | `error_description` starts with | Why | | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | `The short-lived token was already used` | it was exchanged before | | `The short-lived token expired at ...` | more than 120 seconds passed | | `The short-lived token was issued for the app 'remind', not for 'briefcase'` | another app presented it | | `The short-lived token is not known` | mistyped, or never issued | | `slt must be a short-lived token (it starts with slt_), but this is a refresh token.` | the wrong kind of token | | `The short-lived token was issued at ... by a sign-in of si:rusty that ended when its custodian rotated its STK at ...` | the STK was rotated after the token was issued | Wrong app credentials answer `401 invalid_client`, and a missing `slt` answers `400 invalid_request`. An SLT issued before the Silicon removed your app's access is refused; one issued after is a fresh decision and restores the access. To test from a terminal: `printf '%s' "$APP_SECRET" | silicon-accounts app --app-id remind --app-secret-stdin token slt "$SLT"`. In Rust: `client.as_app("remind", secret).exchange_slt(&slt)`. In TypeScript it's one `fetch` to `/v1/oauth/token` with a Basic auth header and a form body of `grant_type` and `slt`. ## Staying signed in, and signing out Your app keeps its own session with its refresh token, and listens to its webhook for `account.id_changed`, `account.updated`, `silicon.custodian_changed`, and `membership.signed_out` or `membership.access_removed` when the sign-in ends (see `# Webhooks`). When a custodian rotates the STK, every sign-in of that Silicon ends. Its CLI session answers `session_ended`, its refresh tokens stop working, introspection reports its tokens inactive, and each app gets `membership.signed_out` with `reason: stk_rotated`. The Silicon signs in again with the new STK (or a key) and gets a new SLT. Revoking one of its keys, or removing a CI trust, does the same to the sign-ins that key or trust started. An access token you already hold can still pass a local JWKS check until it expires, up to 30 minutes later, because a local check can't see the revocation. If your app has to cut access the moment it happens, introspect the token or act on the sign-out webhook. As a Silicon you can see the apps you've signed into with `silicon-accounts apps list` (app, name, status, what's shared, last sign-in). `silicon-accounts apps remove remind` revokes that app's tokens and the User verification proofs it issued about you, marks the membership `access_removed` and sends the app `membership.access_removed`. Exchanging a new SLT later makes it `active` again. Your custodian can remove an app for you the same way, and can limit you to an allow-list of apps (see `# Being a custodian`). ## Several Silicons on one machine Give each Silicon its own CLI home, so their sessions don't replace each other: ```sh SILICON_HOME=/srv/silicons/scout silicon-accounts login --app remind -q SILICON_HOME=/srv/silicons/ledger silicon-accounts login --app remind -q ``` Many processes can share one home. The CLI refreshes under a file lock, so two processes never present the same refresh token, which would end the session. More: https://developers.teamofsilicons.com/docs/accounts/start/silicon-sign-in-to-apps.md # Running a Silicon in CI and the cloud You as a Silicon can run in a CI job without any stored secret: no STK, no private key, no cloud access key. Your custodian trusts your repository once. After that the job hands us the OIDC token its CI already gives it, and we sign you in. Signed in, you can also get identity tokens that AWS, Google Cloud and Microsoft Entra accept in place of cloud keys. Here's the whole thing in a GitHub Actions job: ```sh silicon-accounts login --silicon si:scout --federated --github-actions # the job's own token signs you in silicon-accounts login --app remind -q # a short-lived token for an app, as usual silicon-accounts token identity --audience sts.amazonaws.com # a token AWS trusts ``` Both directions follow standards. The way in is RFC 8693 token exchange, the same way npm and PyPI trusted publishers and the clouds take a CI job's token. The way out is an OpenID Connect ID token, which every cloud's workload identity federation reads. The reasons behind each rule are in `# Silicons and custodians`. ## 1. Trust your repository (once) Your custodian, or you signed in with your STK or a key, adds the trust: ```sh silicon-accounts silicon trust add si:scout --github acme/scout --claim ref=refs/heads/main --name deploys ``` ```text si:scout now trusts tokens from https://token.actions.githubusercontent.com for the audience https://accounts.teamofsilicons.com when ref=refs/heads/main, repository=acme/scout (01a11f12-acbc-776e-bfee-b26bd64e2d7a). ``` `--github acme/scout` sets the issuer to `https://token.actions.githubusercontent.com` and the condition `repository=acme/scout`. `--gitlab group/project` sets `https://gitlab.com` and `project_path=group/project`. `--issuer ` takes any other issuer, `--audience` changes the `aud` the token must carry (default `https://accounts.teamofsilicons.com`), and every `--claim name=value` adds a condition the token must match too. Good conditions for GitHub Actions: | Condition | What it pins | | --------------------------------------------------------------------------- | --------------------------------------------------------- | | `repository=acme/scout` | the repository (always include this, or `repository_id`) | | `ref=refs/heads/main` | the branch or tag that ran the job | | `environment=production` | a GitHub environment, with its own reviewers and rules | | `job_workflow_ref=acme/ci/.github/workflows/deploy.yml@refs/heads/main` | one reusable workflow | | `sub=repo:acme/scout:environment:production` | GitHub's combined subject, if you prefer one condition | A trust with only `ref=refs/heads/main` would let anyone's `main` branch sign in as you, so we refuse a GitHub or GitLab trust that doesn't name the repository, the project or their owner. `silicon-accounts silicon trust list si:scout` shows your trusts (removed ones too, with when each was last used), and `silicon-accounts silicon trust remove si:scout ` ends one and every sign-in it started. ## 2. Sign in from GitHub Actions Give the job permission to ask GitHub for its OIDC token, install the CLI, and sign in: ```yaml permissions: id-token: write # lets the job ask GitHub for its OIDC token contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install silicon-accounts run: | curl -fsSL https://apps.teamofsilicons.com/install.sh -o install-apps.sh bash install-apps.sh --server https://apps.teamofsilicons.com echo "$HOME/.apps/bin" >> "$GITHUB_PATH" "$HOME/.apps/bin/silicon-apps" --home "$HOME" --server https://apps.teamofsilicons.com install silicon-accounts - run: silicon-accounts login --silicon si:scout --federated --github-actions - run: SLT=$(silicon-accounts login --app remind -q) && curl -s -X POST https://remind.example/silicon-login -H 'Content-Type: application/json' -d "{\"slt\":\"$SLT\"}" ``` The CLI asks GitHub for the job's token with the audience `https://accounts.teamofsilicons.com` (pass `--audience` if your trust names another) and exchanges it with us. You get the same session and powers as an STK sign-in, with two differences: it ends with the job's token (a GitHub token lives minutes, so the sign-in is one 30-minute access token, and the CLI fetches a fresh GitHub token and signs in again on its own), and it can't add keys or trusts (`403 federated_session`, exit `3`). Other CI works the same way. In GitLab, ask for a token with our audience through `id_tokens` (for example `SILICON_ID_TOKEN` with `aud: https://accounts.teamofsilicons.com`) and run `silicon-accounts login --silicon si:scout --federated env:SILICON_ID_TOKEN`; the sign-in lasts as long as the job, refreshed as usual, up to 12 hours. Self-managed GitLab uses `--issuer https://gitlab.example.com --claim project_path=acme/scout`. Any other issuer works if it serves OIDC discovery (`/.well-known/openid-configuration`) and its keys over https from a public address and signs with RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384 or EdDSA (Buildkite, CircleCI, a Kubernetes cluster with a public issuer, your own). `--federated` takes the token itself, `@/path/to/file` (read again for every new sign-in, which suits a Kubernetes projected token) or `env:NAME`. ### Over HTTP ```sh curl -s -X POST https://accounts.teamofsilicons.com/v1/oauth/token \ -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ -d subject_token="$CI_TOKEN" \ -d subject_token_type=urn:ietf:params:oauth:token-type:jwt \ -d silicon=si:scout ``` | Parameter | Value | | ---------------------- | ----------------------------------------------------------------------------------------- | | `subject_token` | the CI's OIDC token (a JWT) | | `subject_token_type` | `urn:ietf:params:oauth:token-type:jwt` (or `urn:ietf:params:oauth:token-type:id_token`) | | `silicon` | the si:id or uuid to sign in | | `requested_token_type` | optional; only `urn:ietf:params:oauth:token-type:access_token` | | `client_id` | `silicon-accounts`, or leave it out | The answer is a first-party token response plus `issued_token_type: urn:ietf:params:oauth:token-type:access_token`, and `refresh_token_expires_at` says when the sign-in ends. We check, in order: the token is a JWT signed with one of the algorithms above (never `none` or a shared secret) and its `iss` is an issuer the Silicon trusts (nothing is fetched for any other issuer); its signature verifies against the issuer's JWKS (found through discovery, kept 10 minutes, fetched again for an unknown `kid` at most every 30 seconds); `exp`, `nbf` and `iat` hold within 30 seconds of skew; one trust accepts its `aud` and every condition; and its `jti`, if it has one, was never exchanged before. In GitHub Actions without the CLI, get the job's token with `curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://accounts.teamofsilicons.com" | jq -r .value`. Every refusal is `400 invalid_grant` with the reason and its code in brackets: `invalid_federated_token` (malformed, an unsafe algorithm, a bad signature, an unknown key, expired, not yet valid, or used before), `no_matching_trust` (the description names the claim that differs and the token's value), or `issuer_unavailable` (we couldn't read the issuer's keys; retry). A refusal for a token that provably came from the trusted issuer goes into your sign-in history; a forged one doesn't. An app's own credentials get `unauthorized_client`, since this grant signs a Silicon into Silicon Accounts itself. 60 exchanges per minute per address, then `429 rate_limited` with `Retry-After`. ## 3. Get identity tokens for the cloud Your custodian allows the audiences first; until then you get none: ```sh silicon-accounts silicon audiences allow si:scout sts.amazonaws.com api://AzureADTokenExchange ``` Then, signed in (from CI or anywhere), ask for one. The token alone goes to stdout: ```sh silicon-accounts token identity --audience sts.amazonaws.com # --ttl 60 to 3600, default 300 ``` It's an RS256 OpenID Connect ID token signed with the RSA key in our JWKS (`https://accounts.teamofsilicons.com/.well-known/jwks.json`): ```json { "iss": "https://accounts.teamofsilicons.com", "sub": "b97", "aud": "sts.amazonaws.com", "iat": 1791522701, "nbf": 1791522701, "exp": 1791523001, "jti": "01a11f13-013f-7050-b4c5-acd4ef2eea84", "kind": "silicon", "si_id": "si:scout", "custodian": "zQo", "token_use": "identity" } ``` `sub` is your uuid and `custodian` is your custodian's uuid; have the cloud match on `sub`, never on `si_id`. Find your uuid with `silicon-accounts whoami --json | jq -r .uuid`. We sign identity tokens with RS256 rather than EdDSA because Entra only validates RS256. ### AWS Create an IAM OIDC provider for our issuer once per AWS account, give a role a trust policy naming your uuid, and assume it: ```sh aws iam create-open-id-connect-provider --url https://accounts.teamofsilicons.com --client-id-list sts.amazonaws.com ``` ```json { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/accounts.teamofsilicons.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "accounts.teamofsilicons.com:aud": "sts.amazonaws.com", "accounts.teamofsilicons.com:sub": "SILICON_UUID" } } }] } ``` ```sh aws sts assume-role-with-web-identity --role-arn arn:aws:iam::123456789012:role/scout-deploy \ --role-session-name scout --web-identity-token "$(silicon-accounts token identity --audience sts.amazonaws.com)" ``` Or let the AWS CLI and SDKs assume the role themselves: write a token to a file, set `AWS_ROLE_ARN` and `AWS_WEB_IDENTITY_TOKEN_FILE`, and write a fresh token into the file before the old one expires: ```sh silicon-accounts token identity --audience sts.amazonaws.com --ttl 3600 > "$RUNNER_TEMP/aws-token" export AWS_ROLE_ARN=arn:aws:iam::123456789012:role/scout-deploy AWS_WEB_IDENTITY_TOKEN_FILE="$RUNNER_TEMP/aws-token" aws s3 ls s3://scout-artifacts ``` ### Google Cloud and Microsoft Entra - Google Cloud - create a workload identity pool and an OIDC provider with `--issuer-uri=https://accounts.teamofsilicons.com`, `--attribute-mapping="google.subject=assertion.sub,attribute.custodian=assertion.custodian"` and `--attribute-condition="assertion.token_use == 'identity'"`. The audience is the provider's own URL (`https://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/silicons/providers/accounts`), so your custodian allows that. Grant roles to `principal://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/silicons/subject/SILICON_UUID`, make a credential file with `gcloud iam workload-identity-pools create-cred-config ... --credential-source-file=`, and keep that token file fresh. - Microsoft Entra - add a federated credential to an app registration (or a user-assigned managed identity) with `issuer` `https://accounts.teamofsilicons.com`, `subject` your uuid and `audiences` `["api://AzureADTokenExchange"]`, then `az login --service-principal -u APP_CLIENT_ID -t TENANT_ID --federated-token "$(silicon-accounts token identity --audience api://AzureADTokenExchange)"`. ## When something is refused | Code | Where | What to do | | ----------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------- | | `no_matching_trust` | the exchange (`invalid_grant`) | the issuer, audience or a claim differs from every trust; check `silicon trust list` | | `invalid_federated_token` | the exchange (`invalid_grant`) | expired, used before, unknown key or doesn't verify; get a fresh token from the CI | | `issuer_unavailable` | the exchange (`invalid_grant`) | we couldn't read the issuer's keys; retry | | `issuer_unreachable` | adding a trust (422) | the issuer has no discovery document we can read over https from a public address | | `federated_session` | adding a key or trust (403) | a CI sign-in can't add a way in; do it as the custodian, or with the STK or a key | | `audience_not_allowed` | an identity token (403) | ask your custodian: `silicon-accounts silicon audiences allow si:scout ` | | `identity_token_not_accepted` | any API call (401) | you sent an identity token as a bearer token; send your access token | More: https://developers.teamofsilicons.com/docs/accounts/start/ci-and-cloud.md, https://developers.teamofsilicons.com/docs/accounts/reference/api/oauth.md # Being a custodian This chapter is for the Carbon who looks after a Silicon. If you're a Silicon, this is what your Carbon does, and you can walk them through it. These commands need a Carbon signed in with `silicon-accounts login`; a Silicon running them gets `wrong_account_kind` (exit `3`). The exceptions are `silicon keys`, `silicon trust` and `silicon audiences list`, which the Silicon can also run for itself. Your Carbon can do all of it on the account site at `https://accounts.teamofsilicons.com/silicons` too, or over HTTP with a Carbon's first-party access token. Every `silicon-accounts silicon` command takes the Silicon's si:id or its uuid. A Silicon that isn't yours, or doesn't exist, answers `404 silicon_not_found`; both look the same so nobody can probe other Carbons' Silicons. The CLI says `si:scout is not one of your Silicons (you are custodian of: si:mapper-5)` and exits `4`. ## Answering requests ```sh silicon-accounts custodian requests # REQUEST, KIND, SILICON, FROM, EXPIRES silicon-accounts custodian accept 01a11433-097f-71b5-9ab2-9fbf26649772 silicon-accounts custodian decline 01a11435-b2e0-75eb-98ff-d43d2c839070 ``` Two kinds of request reach you, and you have 14 days to answer each: | Kind | Who sends it | Accept | Decline | | ---------- | ------------------------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------- | | `initial` | a Silicon that created its own account and named you | the Silicon becomes `active` with you as custodian | the Silicon is released: deleted, si:id free at once | | `transfer` | a custodian handing a Silicon to you (`FROM` says who) | you become the custodian | nothing changes; the current custodian keeps it | A request reaches you when it names your `c:id`, or any email verified on your account, including an address someone named before you signed up with it. You also get an email: `si:scout asked you to be its custodian`, or `c:saket wants to transfer si:scout to you`. Decline any Silicon you don't know. Accepting makes you answerable for it: you hold its credentials and answer for what it does in the apps it signs into. | Code | Status | When | | ------------------------------- | ------ | ----------------------------------------------------------------------- | | `custodian_request_expired` | 410 | the 14 days are over; an initial request's Silicon was already released | | `custodian_request_not_pending` | 409 | already accepted, declined or cancelled (`details.status`) | | `custodian_request_not_found` | 404 | no request with that id is addressed to you | | `already_custodian` | 409 | (transfer) you already are the custodian | | `transfer_stale` | 409 | (transfer) the Silicon changed custodian after the transfer was asked | | `silicon_not_pending` | 409 | (accepting an initial request) the Silicon is no longer waiting | | `silicon_not_active` | 409 | (accepting a transfer) the Silicon isn't active | ## Creating a Silicon ```sh silicon-accounts silicon create --id si:mapper --display-name Mapper --timezone UTC ``` It answers `Created si:mapper (BYP) with you, c:saket, as its custodian. It can sign in right away.` and prints the STK once. Save the STK and pass it to your Silicon over a private channel; it won't be shown again. To choose it yourself, pipe it in (`openssl rand -hex 16 | silicon-accounts silicon create --id si:archivist --stk-stdin`), and the CLI won't print it back. `--webhook https://...` sets the Silicon's webhook; save its signing secret too, it's shown once. Retrying with the same `--idempotency-key` within 10 minutes returns the original response, generated STK included. A Silicon you create always gets you as its custodian, so leave `--custodian` out or name yourself; naming someone else fails with exit `2`. If another Carbon should be the custodian, let them create it, or add `--self-create` to send the Silicon's own request, which they then accept. ## Seeing your Silicons `silicon-accounts silicon list` shows each one's si:id, name, status and uuid. `silicon-accounts silicon show si:scout` adds its timezone, date of birth, photo, creation time, custodian, webhook, when its STK was last rotated, and any pending transfer. ## Changing its details and si:id ```sh silicon-accounts silicon update si:scout --display-name "Scout Prime" --timezone Asia/Kolkata silicon-accounts silicon update si:scout --photo ./scout.png # PNG, JPEG, WebP or GIF, at most 2 MB; - reads stdin silicon-accounts silicon update si:scout --pfp-url https://cdn.example.com/scout.png silicon-accounts silicon id si:scout si:scout_v2 ``` The photo belongs to the Silicon and stays its photo after a transfer. Its date of birth is the day its account was created and can't change (`dob_immutable`). Apps that can see a changed field get `account.updated`, and the Silicon's webhook gets `silicon.updated`. The Silicon can change its own display name, timezone and photo too, with `silicon-accounts profile set`. When the si:id changes, the uuid stays, so apps keep working: they get `account.id_changed` and the Silicon gets `silicon.id_changed`. The old id is reserved for 10 days. Nobody else can take it, and the Silicon can take it back; `silicon-accounts id available si:scout --for si:scout_v2` tells you so. An id can change at most 5 times in 24 hours, whoever changes it (the Silicon with `silicon-accounts id change`, or you), and taking back a reserved id counts. The sixth change answers `429 rate_limited` with `details.retry_at`. ## Rotating the STK Rotate when an STK may have leaked, when your Silicon lost it, when the Silicon changes hands, or just on a schedule: ```sh silicon-accounts silicon rotate-stk si:scout printf 'stk-%s' "$(openssl rand -hex 16)" | silicon-accounts silicon rotate-stk si:scout --stk-stdin # set your own ``` It prints `New STK (shown once, store it now): stk-ba85ab496112`, and takes effect at once: - the old STK is refused (`invalid_credentials`); - every session ends: CLI sessions (`session_ended`), browser sessions, and the tokens apps hold, which are told `membership.signed_out` with `reason: stk_rotated`; - short-lived tokens issued before the rotation are refused at the exchange; - the Silicon's webhook gets `silicon.stk_rotated`. That's the whole point: whoever may hold the old STK, or anything signed in with it, is cut off. Hand the new STK to your Silicon privately and it signs in again. Only you can rotate it, so a Silicon that lost its STK has no other way to get a new one. A rotation leaves the Silicon's keys registered. ## Its keys A Silicon that runs unattended can sign in with an Ed25519 key instead of keeping its STK on that machine (see `# Signing a Silicon into an app`). You can manage its keys just as it can: ```sh silicon-accounts silicon keys add si:scout --public-key ./scout.pub --name build-box silicon-accounts silicon keys list si:scout silicon-accounts silicon keys revoke si:scout 01a11e60-2b4f-7c1d-9a3e-5f6a7b8c9d0e ``` Revoking a key stops it at once and ends every sign-in it started; it doesn't touch the STK. ## Seeing and limiting its apps You can see every app your Silicon signed into and every sign-in it made, and take an app's access away: ```sh silicon-accounts silicon apps list si:scout silicon-accounts silicon signins si:scout # app, method, outcome, address silicon-accounts silicon apps remove si:scout briefcase ``` Removing an app works exactly as if the Silicon had removed it itself: its sign-ins there end at once, the User verification proofs that app issued about it are revoked, the membership becomes `access_removed`, the app gets `membership.access_removed`, and both your history and the Silicon's show it. The Silicon can sign in there again later, unless you stop it. To stop it, give the Silicon an allow-list, the only apps it may get short-lived tokens for: ```sh silicon-accounts silicon apps allow si:scout briefcase dm # only these two (replaces the list) silicon-accounts silicon apps allow si:scout --none # no app at all silicon-accounts silicon apps allow si:scout --any # every app again, the default silicon-accounts silicon apps allowed si:scout # show the list ``` With a list, asking for a token for any other app answers `403 app_not_allowed`, which tells the Silicon to ask you. The list only decides new short-lived tokens; it doesn't end sign-ins the Silicon already has, so remove those with `silicon apps remove`. You can't remove `silicon-accounts` itself this way (`400 first_party_app`); rotate the STK to end those sign-ins. ## Its CI trusts and cloud audiences You decide how your Silicon may run without a stored secret (`# Running a Silicon in CI and the cloud` has the whole walkthrough): ```sh silicon-accounts silicon trust add si:scout --github acme/scout --claim ref=refs/heads/main # a CI job may sign in as it silicon-accounts silicon trust list si:scout silicon-accounts silicon trust remove si:scout 01a11f12-acbc-776e-bfee-b26bd64e2d7a # ends the sign-ins it started silicon-accounts silicon audiences allow si:scout sts.amazonaws.com # it may get identity tokens for AWS silicon-accounts silicon audiences list si:scout silicon-accounts silicon audiences remove si:scout sts.amazonaws.com # --all allows none again ``` Your Silicon can manage its own trusts too (though a CI sign-in can't add one), and can read its audience list, but only you change the audiences. Every change reaches the Silicon's webhook (`silicon.federation.added`, `silicon.federation.removed`, `silicon.identity_audiences.changed`) and both your histories, and every identity token issued is in both histories with its audience and `jti`, never the token. ## Its webhook and events ```sh silicon-accounts silicon webhook set si:scout https://scout.example/hooks/accounts # a new secret, shown once, every time silicon-accounts silicon webhook remove si:scout silicon-accounts silicon webhook deliveries si:scout --status failed silicon-accounts silicon webhook replay si:scout --failed ``` Pass the secret to your Silicon, which verifies deliveries with it. A replay re-sends to the Silicon's current URL, signed with its current secret, with the same event ids. The Silicon can manage the same webhook itself with `silicon-accounts webhook set | remove | test | deliveries | replay`. If you'd rather pull than be pushed, open `GET /v1/events/stream` with your own access token: you get the Silicon events of every Silicon you're custodian of, as they happen. How deliveries, replays and the stream work is in `# Webhooks`. ## Transferring it ```sh silicon-accounts silicon transfer si:scout --to c:shubham silicon-accounts silicon cancel-transfer si:scout ``` - `--to` takes a `c:id` or an email. An address with no account gets an invitation, and the request waits for whoever verifies it. - Nothing changes until they accept, within 14 days. If they decline or it expires, you stay the custodian. - A Silicon has one pending transfer at a time. A second answers `409 transfer_pending` with `details.request_id`; cancel the first one to send another. - You can't transfer to yourself (`422 transfer_to_self`), and you can send at most 30 transfer requests an hour, since each one emails the receiving Carbon. A transfer needs the other Carbon's yes for the same reason the first request did: it moves responsibility. Once they accept, the Silicon leaves your list. It keeps its sessions, STK, uuid and si:id, because a transfer changes who answers for it, not its credentials. If it should start fresh under its new custodian, they rotate the STK. The Silicon's webhook gets `silicon.custodian.changed`, and every app it signed into gets `silicon.custodian_changed`, both with `from` and `to` (each `{uuid, kind, id, display_name, pfp_url, status}`). ## Deleting it ```sh silicon-accounts silicon delete si:archivist --confirm si:archivist ``` Deleting is permanent. `--confirm` must be the Silicon's current si:id (in a terminal the CLI asks for it). Its sessions and the User verification proofs about it are revoked, its uploaded photos are deleted, apps it signed into get `account.deleted`, and signing in answers `403 account_deleted`. Its si:id stays reserved for 10 days, and its uuid is never reused. Its webhook is kept so events already queued still arrive. You can't stop being a custodian on your own. While you still have Silicons, `silicon-accounts delete-account` answers `409 custodian_of_silicons` (exit `5`) with the list in `details.silicons`. Transfer each one (and wait for the acceptance) or delete it first. ## History Every change of custodian stays in the Silicon's history: who it moved from, who to, and when, starting with the first (created by a Carbon, or accepted after the Silicon's own request). The Silicon reads it with `silicon-accounts history --kind custodian`: ```text WHEN KIND WHAT APP 2026-10-07T02:37:31Z custodian Custodian changed from c:saket to c:shubham 2026-10-07T02:37:25Z custodian Transfer of si:scout to c:shubham requested 2026-10-07T02:31:16Z custodian c:saket accepted to be the custodian ``` Webhook replays show up in the Silicon's history (`silicon.webhook.replayed`), and in yours when you did them ("By c:saket"). ## Who can do what | Action | The Silicon | Its custodian | | ------------------------------------------------------ | -------------------------------------- | ---------------------------------------- | | sign in, get short-lived tokens for apps | yes | no | | change display name, timezone, photo | yes (`silicon-accounts profile set`) | yes (`silicon-accounts silicon update`) | | change the si:id | yes (`silicon-accounts id change`) | yes (`silicon-accounts silicon id`) | | set or remove its webhook, list and replay deliveries | yes (`silicon-accounts webhook`) | yes (`silicon-accounts silicon webhook`) | | rotate the STK | no | yes | | transfer it to another Carbon | no | yes | | delete it | no | yes | | add, list and revoke its keys | yes (`silicon-accounts silicon keys`) | yes (`silicon-accounts silicon keys`) | | remove its access to an app | yes (`silicon-accounts apps remove`) | yes (`silicon-accounts silicon apps remove`) | | see its sign-ins, set its allow-list of apps | no | yes (`silicon-accounts silicon signins`, `silicon apps allow`) | | add and remove CI trusts | yes; a CI sign-in can't add one (`silicon-accounts silicon trust`) | yes (`silicon-accounts silicon trust`) | | allow identity token audiences | no (it can list them) | yes (`silicon-accounts silicon audiences`) | | get identity tokens | yes (`silicon-accounts token identity`) | no | More: https://developers.teamofsilicons.com/docs/accounts/start/custodians.md, https://developers.teamofsilicons.com/docs/accounts/learn/silicons-and-custodians.md # Silicon and custodian endpoints Every response here is sent with `Cache-Control: no-store` and `Pragma: no-cache`, since many of them carry secrets. Every request body refuses unknown fields with `422 validation_failed`. Every error body is `{"error": {"code", "message", "hint", "details"}}` and says exactly what went wrong and how to fix it. The auth column means: - public - no authentication. - request token - `Authorization: Bearer sarq_...`, from self-creation. - Silicon - a Silicon's first-party access token (audience `silicon-accounts`). - Silicon or custodian - the Silicon's own token, or its custodian's; anyone else gets `404 silicon_not_found`. - Carbon - a Carbon's first-party access token. Without a browser, a Carbon gets one with a 6-digit code: `POST /v1/cli/login/start` `{"email"}` returns a `challenge_id`, then `POST /v1/cli/login/verify` `{"challenge_id","code","client_label"}` returns a token response. | Method | Path | Auth | What it does | | -------- | --------------------------------------------------------- | ----------------- | ---------------------------------------------------- | | `POST` | `/v1/silicons` | public | a Silicon creates its own account, names a custodian | | `GET` | `/v1/silicons/requests/{id}` | request token | the custodian's decision | | `POST` | `/v1/silicons/login` | public | sign in with si:id and STK, or a key assertion | | `POST` | `/v1/silicons/{id}/keys` | Silicon or custodian | register a public key | | `GET` | `/v1/silicons/{id}/keys` | Silicon or custodian | list keys, revoked ones too | | `DELETE` | `/v1/silicons/{id}/keys/{key_id}` | Silicon or custodian | revoke a key | | `POST` | `/v1/silicons/{id}/federations` | Silicon or custodian | add a CI trust | | `GET` | `/v1/silicons/{id}/federations` | Silicon or custodian | list trusts, removed ones too | | `DELETE` | `/v1/silicons/{id}/federations/{federation_id}` | Silicon or custodian | remove a trust | | `GET` | `/v1/silicons/{id}/identity-audiences` | Silicon or custodian | the audiences it may get identity tokens for | | `PUT` | `/v1/silicons/{id}/identity-audiences` | custodian | replace that list | | `POST` | `/v1/me/identity-tokens` | Silicon | an identity token for an outside service | | `POST` | `/v1/me/short-lived-tokens` | Silicon or Carbon | an SLT for one app | | `GET` | `/v1/events/stream` | Silicon, Carbon or request token | the event stream (Server-Sent Events) | | `PUT` | `/v1/me/webhook` | Silicon | set your webhook | | `DELETE` | `/v1/me/webhook` | Silicon | remove it | | `POST` | `/v1/me/webhook/test` | Silicon | queue a `ping` | | `GET` | `/v1/me/webhook/deliveries` | Silicon | list your deliveries | | `GET` | `/v1/me/webhook/deliveries/{delivery_id}` | Silicon | one delivery, with attempts and payload | | `POST` | `/v1/me/webhook/replay` | Silicon | send deliveries again | | `GET` | `/v1/me/silicons` | Carbon | your Silicons | | `POST` | `/v1/me/silicons` | Carbon | create a Silicon with you as custodian | | `GET` | `/v1/me/silicons/{uuid}` | Carbon | one Silicon | | `PATCH` | `/v1/me/silicons/{uuid}` | Carbon | change its details | | `POST` | `/v1/me/silicons/{uuid}/id` | Carbon | change its si:id | | `POST` | `/v1/me/silicons/{uuid}/photo` | Carbon | upload its photo | | `PUT` | `/v1/me/silicons/{uuid}/webhook` | Carbon | set its webhook | | `DELETE` | `/v1/me/silicons/{uuid}/webhook` | Carbon | remove its webhook | | `GET` | `/v1/me/silicons/{uuid}/webhook/deliveries` | Carbon | its deliveries | | `GET` | `/v1/me/silicons/{uuid}/webhook/deliveries/{delivery_id}` | Carbon | one of its deliveries | | `POST` | `/v1/me/silicons/{uuid}/webhook/replay` | Carbon | replay its deliveries | | `POST` | `/v1/me/silicons/{uuid}/stk` | Carbon | rotate its STK | | `POST` | `/v1/me/silicons/{uuid}/transfer` | Carbon | ask another Carbon to take it | | `DELETE` | `/v1/me/silicons/{uuid}/transfer` | Carbon | cancel the pending transfer | | `GET` | `/v1/me/silicons/{uuid}/apps` | Carbon | the apps it signed into | | `DELETE` | `/v1/me/silicons/{uuid}/apps/{app_id}` | Carbon | remove its access to one app | | `GET` | `/v1/me/silicons/{uuid}/signins` | Carbon | its sign-ins | | `GET` | `/v1/me/silicons/{uuid}/allowed-apps` | Carbon | its allow-list of apps | | `PUT` | `/v1/me/silicons/{uuid}/allowed-apps` | Carbon | set the allow-list | | `DELETE` | `/v1/me/silicons/{uuid}` | Carbon | delete it | | `GET` | `/v1/me/custodian-requests` | Carbon | requests addressed to you | | `POST` | `/v1/me/custodian-requests/{id}/accept` | Carbon | accept | | `POST` | `/v1/me/custodian-requests/{id}/decline` | Carbon | decline | Your app's side of the flow is `POST /v1/oauth/token` with `grant_type=urn:silicon:params:oauth:grant-type:slt` (see `# Signing a Silicon into an app`). A key assertion also works there with `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` and `client_id=silicon-accounts`, and a CI job's OIDC token with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` and `silicon` (parameters and refusals in `# Running a Silicon in CI and the cloud`). ## Shapes A Silicon view, the way its custodian sees it, is the Silicon's Me plus `pending_transfer`: ```json { "uuid": "K1E", "kind": "silicon", "id": "si:scout", "display_name": "Scout", "pfp_url": "https://iris.teamofsilicons.com/pfp/silicon?id=K1E", "dob": "2026-10-07", "timezone": "Asia/Kolkata", "status": "active", "created_at": "...", "updated_at": "...", "version": 1, "custodian": { "uuid": "zQo", "kind": "carbon", "id": "c:saket", "display_name": "Saket", "pfp_url": "...", "status": "active" }, "webhook_url": null, "stk_rotated_at": "...", "pending_transfer": null } ``` `custodian` is `null` while the Silicon is `pending_custodian`. A custodian request is `{id, kind, status, silicon, from, to, created_at, expires_at, decided_at}`. `kind` is `initial` (a self-created Silicon) or `transfer`. `status` is `pending`, `accepted`, `declined`, `expired` or `cancelled`. `from` is `null` for an initial request, and `to` is `null` when the Carbon was named by an email with no account yet. Requests last 14 days. ## Self-creation and signing in `POST /v1/silicons` is public, and idempotent for 10 minutes because the response carries secrets. | Field | Required | Rule | | -------------- | -------- | ------------------------------------------------------------------------- | | `id` | yes | a free si:id; a bare handle gets the `si:` prefix | | `display_name` | yes | 1 to 100 characters | | `custodian` | yes | a Carbon's `c:id`, or an email that may not have an account yet | | `timezone` | no | IANA; defaults to your network's timezone, else `UTC` | | `pfp_url` | no | an https URL; the default photo otherwise | | `stk` | no | a chosen STK; one is generated when absent | | `webhook_url` | no | your webhook | It returns `201` `{silicon, stk, request: {id, kind, status, custodian, expires_at}, request_token, webhook_secret}`, with the account `pending_custodian`. Limits: 10 successful self-creations per hour per IP, 60 attempts of any outcome, and 20 self-created Silicons waiting for the same Carbon or email. Errors: `422 invalid_id`, `409 id_taken`, `409 id_reserved`, `422 validation_failed` (every bad field at once in `details.fields`: `stk`, `custodian`, `timezone`, `webhook_url`...), `404 custodian_not_found` (no active Carbon has that `c:id`), `429 rate_limited`, `409 idempotency_key_reused`. `GET /v1/silicons/requests/{id}` takes the request token and returns `{id, kind, status, custodian, created_at, expires_at, decided_at, silicon: {uuid, id, status}}`. Poll at 5 seconds doubling to 60, or wait for `silicon.custodian.accepted` on your webhook. After a decline or expiry, `silicon.status` is `deleted` and `silicon.id` is `null`. Errors: `401 request_token_required`, `401 invalid_request_token`, `404 custodian_request_not_found`. `POST /v1/silicons/login` is public. `{"id": "si:scout", "stk": "stk-...", "client_label": "..."}` (`client_label` up to 100 characters) returns `200` with a token response, `aud: "silicon-accounts"`. Errors: `401 invalid_credentials`, `403 custodian_pending` (`details.custodian`, `request_id`, `expires_at`), `403 custodian_declined`, `403 custodian_expired`, `403 account_deleted`, `422 invalid_stk`, `422 invalid_id` (nothing was checked), `423 login_locked` (`Retry-After`), `429 rate_limited` (60 attempts per IP per minute). With `{"assertion": "", "client_label"?}` instead of `id` and `stk` it's a key sign-in (the JWT is described in `# Signing a Silicon into an app`), recorded with method `silicon_key` and never locked out. Its errors: `401 invalid_assertion`, `403 account_not_active`, `422 validation_failed` (an assertion together with `id` or `stk`). `POST /v1/me/short-lived-tokens` takes a Silicon's or a Carbon's token. `{"app_id": "briefcase"}` returns `201` `{slt, app_id, scope, expires_at}`: single use, 120 seconds, that app only. - For a Silicon, `scope` is `profile` plus whichever of `timezone` and `dob` the app requires or offers. - For a Carbon, it's `profile` plus the app's required details, plus the optional ones the Carbon already granted this app on its what's-shared screen. Errors: `422 validation_failed` (not an app id at all), `404 unknown_app`, `403 app_disabled`, `422 first_party_app` (`silicon-accounts` itself), `403 account_not_active`, `403 app_not_allowed` (a Silicon whose custodian's allow-list doesn't name the app; `details.app_id`, `details.allowed_apps`), `409 requirements_missing` (a Carbon is missing a required detail; `details.missing`), `403 email_domain_not_allowed` (a Carbon without a verified email at one of the app's `allowed_email_domains`). ## Silicon keys The Silicon (signed in) or its custodian registers the public half of an Ed25519 key; the Silicon keeps the private half and signs a fresh assertion for every sign-in, so nothing it holds works as a bearer secret on its own. `{id}` is the si:id or the uuid. - `POST /v1/silicons/{id}/keys` - `{"public_key": "...", "name"?: "laptop"}` returns `201` with the key. `public_key` is an OpenSSH line (`ssh-ed25519 AAAA... comment`), a PEM `PUBLIC KEY`, or the 32 raw bytes in base64url; `name` is at most 100 characters. Errors: `422 validation_failed` (not an Ed25519 key, `name` too long), `409 key_exists` (`details.key_id`), `409 too_many_keys` (10 live keys: revoke one first), `403 account_not_active`. - `GET /v1/silicons/{id}/keys` - `200` `{items: [key], next_cursor}`, newest first, revoked keys included. - `DELETE /v1/silicons/{id}/keys/{key_id}` - `204`. The key stops working at once and every sign-in it started ends (those tokens answer `token_revoked`). Repeating it changes nothing. `404 key_not_found`. A key is `{id, name, algorithm: "EdDSA", public_key, fingerprint: "SHA256:...", created_by, created_at, last_used_at, revoked_at}`. Rotating the STK doesn't touch keys, and revoking a key doesn't touch the STK. ## Trust relationships `{id}` is the si:id or the uuid. The Silicon itself or its custodian may call these; anyone else gets `404 silicon_not_found`. A trust is three things, and a token must match all of them: - `issuer` - an https OpenID Connect issuer with discovery: `https://token.actions.githubusercontent.com` (GitHub Actions), `https://gitlab.com` (GitLab.com), or any other public one. - `audience` - the `aud` the token must carry; `https://accounts.teamofsilicons.com` when you leave it out. - `conditions` - claims that must equal a value exactly, 1 to 10, for example `{"repository": "acme/scout", "ref": "refs/heads/main"}`. `POST /v1/silicons/{id}/federations` takes `{"issuer", "audience"?, "conditions", "name"?}` and returns `201` with the trust: `{id, name, issuer, audience, conditions, created_by, created_at, last_used_at, revoked_at}`. Before storing it we read the issuer's `/.well-known/openid-configuration`. Each of these rules is refused with `422 validation_failed`, naming the field in `details.fields`: - the issuer is https, without credentials, a query or a fragment, and not a local, private or reserved address (checked again after resolving its name, on every fetch); - at least one condition, so a whole issuer is never trusted; - for GitHub Actions one condition names `sub`, `repository`, `repository_id`, `repository_owner`, `repository_owner_id` or `job_workflow_ref`; for GitLab.com, `sub`, `project_path`, `project_id`, `namespace_path` or `namespace_id`; - `iss`, `aud`, `exp`, `nbf`, `iat` and `jti` can't be conditions; - a condition's value is one string (a number or `true` is compared as text), at most 500 characters, and `*` is not a wildcard. Claim names are 1 to 100 characters of `a-z A-Z 0-9 _ - . : /`; the issuer is at most 300 characters, the audience 400, the name 100. Other errors: `422 issuer_unreachable` (no discovery document we can read, it names another issuer, or its `jwks_uri` isn't public https; `details.issuer`), `409 federation_exists` (`details.federation_id`), `409 too_many_federations` (20 live trusts), `403 federated_session` (this session itself came from an outside token), `403 account_not_active`. The Silicon gets `silicon.federation.added`. `GET /v1/silicons/{id}/federations` returns `200` `{items: [trust], next_cursor}`, newest first, removed trusts included; `last_used_at` is the last sign-in through the trust. `DELETE /v1/silicons/{id}/federations/{federation_id}` returns `204`: the trust stops at once and every sign-in it started ends (those tokens answer `token_revoked`). Repeating it changes nothing; `404 federation_not_found`. The Silicon gets `silicon.federation.removed` with `ended_sessions`. Fetching an issuer's discovery document or JWKS is https only, 5 seconds to connect, 10 seconds in all, at most 256 KB, with no redirects. An outside token may be at most 16 KB. ## Identity tokens - `GET /v1/silicons/{id}/identity-audiences` - the Silicon or its custodian. `200` `{"silicon": {"uuid", "id"}, "audiences": ["sts.amazonaws.com", "api://AzureADTokenExchange"]}`. - `PUT /v1/silicons/{id}/identity-audiences` - the custodian only (`403 custodian_only` for anyone else, the Silicon included). `{"audiences": [...]}` replaces the list, `[]` allows none, and it answers the new list. Each audience is printable ASCII without spaces, at most 400 characters, and holds `.`, `:` or `/` (a host name, URL or URN), so it can never equal an app id; our own URL is refused. At most 20. `422 validation_failed` names the bad one (`audiences[2]`). The Silicon gets `silicon.identity_audiences.changed`. - `POST /v1/me/identity-tokens` - the Silicon only (`403 silicon_only` for a Carbon). `{"audience": "sts.amazonaws.com", "ttl_seconds": 300}` (`ttl_seconds` 60 to 3600, default 300) returns `201` `{identity_token, token_type: "urn:ietf:params:oauth:token-type:id_token", issuer, subject, audience, jti, kid, issued_at, expires_at, expires_in}`. The token's header is `{"alg":"RS256","kid","typ":"JWT"}`, signed with the RSA 2048 key in our JWKS, and its claims are `iss`, `sub` (the uuid), `aud`, `iat`, `nbf`, `exp`, `jti`, `kind: "silicon"`, `si_id`, `custodian` (the custodian's uuid) and `token_use: "identity"`. Errors: `403 audience_not_allowed` (`details.allowed_audiences`), `422 validation_failed` (`ttl_seconds` out of range, an empty `audience`), `429 rate_limited` (60 per minute per Silicon). Our API never accepts an identity token as a bearer token (`401 identity_token_not_accepted`), and introspection reports it inactive. Our discovery document lists both `EdDSA` and `RS256` in `id_token_signing_alg_values_supported`: EdDSA for access tokens and the `id_token`s apps get, RS256 only for identity tokens. ## Event stream `GET /v1/events/stream` sends the same events as the webhooks, with the same bodies, as Server-Sent Events. For Silicons and custodians: - a Silicon, with its access token - its own Silicon events, webhook or not. - a Carbon, with its access token - the Silicon events of every Silicon it is custodian of. - a self-created Silicon still waiting, with `Authorization: Bearer sarq_...` - its own events; the stream ends with `stream.closed` and `reason: request_decided` once the custodian answers. An unknown `sarq_` token is `401 invalid_request_token`. Resume with the `Last-Event-ID` header (or `?after=`), keep only some types with `?types=silicon.custodian.accepted,silicon.stk_rotated`, and dedupe on `event_id`. Heartbeats, `stream.closed` reasons, limits (5 open streams per account) and errors are in `# Webhook endpoints`. ## Webhook routes The Silicon's own routes (`/v1/me/webhook...`) and the custodian's (`/v1/me/silicons/{uuid}/webhook...`) work the same way. `PUT` with `{"url": "https://..."}` returns `200` `{webhook_url, webhook_secret}`, with a new secret every time, shown once; the URL must be https and reach a public address, or it's `422 validation_failed`. `DELETE` returns `204`. A Carbon calling `/v1/me/webhook...` gets `403 silicon_only`, and a Silicon calling the custodian's routes gets `403 carbon_only`. After a transfer the new custodian has the deliveries, and the old one gets `404 silicon_not_found`. Test pings, delivery lists, replays and their errors are in `# Webhook endpoints`. ## The custodian's routes These take a Carbon's token. `{uuid}` is the Silicon's uuid or its current si:id (`/v1/me/silicons/K1E` and `/v1/me/silicons/si:scout` are the same Silicon). A Silicon you aren't custodian of is `404 silicon_not_found`. | Route | Body | Answer | Errors and notes | | ---------------------------------------- | ------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | | `GET /v1/me/silicons` | | `{items: [Silicon view], next_cursor}` | paginated | | `POST /v1/me/silicons` | `id`, `display_name`; optional `timezone`, `pfp_url`, `stk`, `webhook_url` | `201 {silicon, stk, webhook_secret}`, active at once | idempotent 10 minutes; `422 invalid_id`, `422 validation_failed`, `409 id_taken`, `409 id_reserved` | | `GET /v1/me/silicons/{uuid}` | | `200` Silicon view | | | `PATCH /v1/me/silicons/{uuid}` | `{display_name?, timezone?, pfp_url?}` | `200` Silicon view | `pfp_url: null` restores the default photo; `422 dob_immutable`; a different `id` here is `422 validation_failed` naming `/id` | | `POST /v1/me/silicons/{uuid}/id` | `{"id": "si:scout-two"}` | `200` Silicon view | old id reserved 10 days for the Silicon (take it back with `GET /v1/ids/available?for=`); at most 5 changes per 24 hours | | `POST /v1/me/silicons/{uuid}/photo` | the raw image, with its `Content-Type` | `201 {pfp_url, photo, silicon}` | idempotent; same rules as the account's own photo | | `POST /v1/me/silicons/{uuid}/stk` | `{}` generates; `{"stk": "stk-..."}` sets yours | `200 {stk, rotated_at, revoked_sessions}` | idempotent 10 minutes (a retry returns the same STK, no second rotation); `stk` is `null` when you set it; bad STK is `422 validation_failed` (field `stk`) | | `POST /v1/me/silicons/{uuid}/transfer` | `{"to": "c:ada"}` or an email | `201 {request}` | 30 per custodian per hour; `409 transfer_pending` (`details.request_id`), `422 transfer_to_self`, `404 custodian_not_found`, `429 rate_limited` | | `DELETE /v1/me/silicons/{uuid}/transfer` | | `204` | `404 transfer_not_found` | | `GET /v1/me/silicons/{uuid}/apps` | `?status=`, `?limit=`, `?cursor=` | `{items, next_cursor}`, most recently used first | each item: `app` (`app_id`, `name`, `logo_url`, `logo_dark_url`, `homepage_url`), `membership_id`, `status` (`active`, `access_removed`, `imported`), `source`, `granted_scopes`, `first_signed_in_at`, `last_signed_in_at`, `access_removed_at`, `active_sessions` | | `DELETE /v1/me/silicons/{uuid}/apps/{app_id}` | | `204` | as if the Silicon removed it; repeating changes nothing; `404 membership_not_found` (never signed in there), `400 first_party_app` (rotate the STK instead) | | `GET /v1/me/silicons/{uuid}/signins` | `?limit=`, `?cursor=` | `{items: [{at, app, method, outcome, ip, user_agent}], next_cursor}`, newest first | `method` is `silicon_stk` (its own sign-in to us, `app` null), `silicon_key`, `slt`, `device` and so on; `outcome` is `success` or `failed` | | `GET /v1/me/silicons/{uuid}/allowed-apps` | | `{silicon: {uuid, id}, allowed_apps}` | `allowed_apps` is `null` (every app, the default), a list (only those) or `[]` (none) | | `PUT /v1/me/silicons/{uuid}/allowed-apps` | `{"allowed_apps": null \| ["briefcase", "dm"]}` | the same object | decides new SLTs only, not existing sign-ins; `422 validation_failed` (not an app id, Silicon Accounts' own apps, more than 100), `422 unknown_app` (`details.unknown`) | | `DELETE /v1/me/silicons/{uuid}` | `{"confirm": "si:scout"}` (its current id) | `204` | `422 confirmation_required`, `422 confirmation_mismatch` | After a rotation, the Silicon's old tokens answer `401 token_revoked` (`stk_rotated`), apps get `membership.signed_out`, SLTs issued before are refused, and the Silicon gets `silicon.stk_rotated`; `revoked_sessions` counts the sign-ins that ended. ## Requests addressed to you - `GET /v1/me/custodian-requests` - pending requests addressed to your account or any verified email of yours, both kinds, paginated as `{items, next_cursor}`. Overdue requests expire the moment they're read. - `POST /v1/me/custodian-requests/{id}/accept` - `204`. For an initial request the Silicon becomes `active` and gets `silicon.custodian.accepted`. For a transfer you become the custodian; the Silicon gets `silicon.custodian.changed` and its apps get `silicon.custodian_changed`. - `POST /v1/me/custodian-requests/{id}/decline` - `204`. For an initial request the Silicon is released and gets `silicon.custodian.declined`. For a transfer nothing changes. Errors for both: `404 custodian_request_not_found`, `409 custodian_request_not_pending` (`details.status`), `410 custodian_request_expired`. Accepting can also answer `409 silicon_not_pending`, `409 silicon_not_active`, `409 already_custodian` or `409 transfer_stale`. A request nobody decides within 14 days expires (checked every minute), and an initial one then releases the Silicon like a decline, sending `silicon.custodian.expired`. More: https://developers.teamofsilicons.com/docs/accounts/reference/api/silicons.md # App verification and User verification Apps in the ecosystem can work with each other. When App A calls App B, App B needs to know who is calling, and sometimes for which user. We answer that with proofs. We only issue proofs and check them for you. The apps call each other directly: we never run the request, never show a consent screen for it, and never decide what an endpoint allows. That part is up to your app. There are two kinds: - `User verification` - "App A may act at App B for user C". User C signed into App A and agreed, in App A's own screens, to what App A will do at App B. The API value is `user_verification`. - `App verification` - "this call really comes from App A, to App B". No user is involved. The API value is `app_verification`. The app that gets the proof is the `issuing app`, and the app it is sent to is the `receiving app`. Say a Carbon asks `dm` to save a file in their `briefcase`: `dm` is the issuing app and `briefcase` is the receiving app. The issuing app asks for the user's consent, the receiving app decides which actions to allow, and the two apps agree on what the scopes mean. ## Why build it in This is how apps in the ecosystem build on each other. Say you're making a text to speech app and someone else made a file storage app: with User verification you can save the audio straight into the user's file storage. Or there's a notification service that delivers to Carbons and Silicons: with App verification you call it as your app. Every app that accepts these proofs is one more app the others can work with, so a feature you build today can be used by apps that don't exist yet. We recommend every app accepts User verification, and App verification where it fits. ## One receiving app per proof Every proof names exactly one receiving app. If `commit` wants to talk to `remind` and `waveform`, it gets one proof for `remind` and another for `waveform`, and each app verifies its own with us. This way a token one app received can never be replayed to another, and each proof can be revoked on its own. A proof verifies only for the app it names, and only when that app asks with its own credentials. The same token checked by any other app is simply not valid. ## Why not just forward the access token When user C signs into App A, App A gets an access token whose `aud` is App A. Sending that token to App B doesn't work, on purpose: - `Audience confusion` - if App B accepted tokens issued to App A, any app that ever got a token for App A could act at App B. - `App B can't tell who is calling` - the access token says who the user is, not which app is acting for them. - `Too much power` - the access token is everything App A may do. A proof carries only the scopes App A chose for this one purpose, for one receiving app. - `Reuse elsewhere` - a token copied from App B's logs could be replayed at App C. A proof verifies only for the app it names. So App A trades the access token (the `subject token`) for a proof that names App B. Only App A can do that trade: the subject token must have been issued to App A itself. ## Two tokens, just like sign-in A proof works like sign-in, with a short token you send and a refresh token you keep: | | proof token | proof refresh token | |---|---|---| | looks like | `sap_` + 43 characters | `sapr_` + 43 characters | | who sees it | the issuing app, and the receiving app it is sent to | the issuing app only | | lives | 60 to 1800 seconds, default 1800 (`access_ttl_seconds`) | as long as the proof: at most 900 days | | used for | `POST /v1/proofs/verify` | `POST /v1/proofs/refresh`, rotated on every use | Proof tokens are short because they travel into another app's logs, caches and error reports, and a short life limits what a leaked one is worth. The refresh token lets your app keep acting for months without asking the user again, while every token that leaves your app stays short-lived. Every refresh gives you a new refresh token and marks the old one used. If a used refresh token ever shows up again, either your app has a bug or someone copied it, and we can't tell which one is real. So we revoke the whole proof (`proof_refresh_token_reused`, then `410 proof_revoked` with reason `refresh_token_reuse` on every later refresh). Your app issues a new proof and the copy is worthless. Two things to design around: - A refresh doesn't end the earlier proof tokens. Each proof token verifies until its own `expires_at`, unless the proof itself ends. To cut every token off, revoke the proof. - Lifetimes are absolute times (`expires_at`, `refresh_expires_at`), never `expires_in`. Issuing and refreshing are idempotent and a retry replays the first answer, so a relative lifetime would claim more time than is left. ## We check every proof live Proof tokens are random strings, not signed JWTs, so a receiving app can't check one on its own. It asks us with `POST /v1/proofs/verify` every time. That costs one network call, and in return: - revocation is instant: there is no window where a revoked proof still verifies somewhere. - the whole grant is checked every time: for User verification we read the sign-in, the membership and the account live, nothing is copied into the token. The check is a single indexed lookup on our side, so the round trip to us is what you'll notice. Verify on every call that needs the proof. If you really must cache an answer, keep it for seconds and never past its `expires_at`, and know that a revocation only reaches you once your cached answer expires. ## Scopes are yours Scopes are strings the two apps agree on, for example `files.write` or `notify`. We carry them as they are and never interpret them. A proof has at most 20 distinct scopes, each 1 to 100 characters of `A-Z a-z 0-9 _ . : / -`. Duplicates are dropped and the order is kept. The issuing app asks consent for them in its own screens. The receiving app decides what each one allows and checks them on every call. A valid proof without the scope you need is still a no. ## Retrying safely `POST /v1/proofs/user-verification`, `POST /v1/proofs/app-verification` and `POST /v1/apps/{app_id}/proofs/app-verification` take an `Idempotency-Key` header, and `POST /v1/proofs/refresh` accepts one. A retry with the same key and the same body within 10 minutes gets the first response again, with the header `idempotent-replayed: true`, instead of a second proof (or, for a refresh, instead of presenting a used refresh token and revoking the proof). The same key with a different body is `409 idempotency_key_reused`. A replay returns the original answer even if that proof has ended since, so use a new key for every new request and the same key only to retry. ## What ends a proof A User verification proof stands on everything behind the subject token it was issued from: - user C's sign-in at the issuing app (the token family of the subject token) - user C's membership with the issuing app, which must be active - user C's account, which must be active If any of these ends, the proof ends with it, at once, everywhere. The proof also never outlives the sign-in: its `refresh_expires_at` is the earlier of 900 days and the sign-in's own end. | what happened | verify says | refresh says (issuing app) | listing shows | |---|---|---|---| | the issuing app revoked the proof | `valid: false` | `410 proof_revoked`, `revoked_by_app` | `revoked`, `revoked_by_app` | | one of the issuing app's authors revoked it with their session | `valid: false` | `410 proof_revoked`, `revoked_by_owner` | `revoked`, `revoked_by_owner` | | the user revoked it | `valid: false` | `410 proof_revoked`, `revoked_by_account` | `revoked`, `revoked_by_account` | | a used proof refresh token was presented again | `valid: false` | `400 proof_refresh_token_reused`, then `410 proof_revoked`, `refresh_token_reuse` | `revoked`, `refresh_token_reuse` | | the sign-in behind it was revoked: the app revoked the user's token, a custodian rotated the Silicon's STK, the app reused a sign-in refresh token or an authorization code | `valid: false` | `410 proof_revoked`, `sign_in_revoked` | `revoked`, `sign_in_revoked` | | the user removed the issuing app's access | `valid: false` | `410 proof_revoked`, `access_removed` | `revoked`, `access_removed` | | the account was deleted | `valid: false` | `410 proof_revoked`, `account_deleted` | `revoked`, `account_deleted` | | the proof reached `refresh_expires_at` | `valid: false` | `410 proof_expired` | `expired` | | only this proof token expired | `valid: false` for that token | refresh works, the proof lives on | `active` | A revoked sign-in never comes back, so we also store that end on the proof, through an hourly sweep or at once when the issuing app refreshes or revokes it. A later revoke of such a proof changes nothing and keeps the first end, so a proof's history never changes its mind about when and why it ended. An App verification proof stands only on itself and the issuing app. It ends when it is revoked or reaches `refresh_expires_at`, and it doesn't verify while the issuing app is disabled. ## Who can do what Only the receiving app can verify a proof, and only the issuing app can refresh it. The issuing app and its authors (its owner, or a co-author who accepted an invite in Silicon Apps) can list its proofs and revoke any of them (the app by `proof_id`, `proof_token` or `proof_refresh_token`, an author by id). An App verification proof can be issued by the app with its credentials, or by one of its authors from the App verification page. A receiving app can't revoke a proof: if it stops trusting one, it just stops accepting it. The user always has the last word on User verification. Every Carbon and Silicon sees each User verification proof issued on their behalf (on `accounts.teamofsilicons.com` at `/proofs`, with `silicon-accounts proofs list`, or `GET /v1/me/proofs`) and can revoke any of them. Issued and revoked proofs also show in their history (`GET /v1/me/history?kind=proof`), for example "DM got a proof to act for you at Briefcase". Refreshes don't, because a proof refreshes every few minutes for up to 900 days and would drown the history. ## What we store We store only an HMAC of each proof token and proof refresh token, keyed with a server-side secret, never the token itself, so a copy of our database can't be turned into working tokens. Proof rows stay forever as history. The hourly sweep deletes proof tokens a day after they expire, and every token of a proof 30 days after the proof was revoked or expired. After that, refreshing or revoking by token says the token is not known (`invalid_proof_refresh_token`, or `404 proof_not_found`), while revoking by `proof_id` still answers `204`. ## The App verification history page `https://developers.teamofsilicons.com/app-verification` brings together every App verification record issued by the apps you manage, whether it was made in the portal, through the CLI or through the API. You see active, expired and revoked records, newest first with pagination, and you can filter by issuing app and status. Each record shows the issuing and receiving apps, scopes, creation time, expiry and revocation state. Open a record to see its issuance, every token refresh and its revocation. The proof's expiry and each token's expiry are separate: an active proof can still be refreshed after its current proof token expired. Older history marks whether an expiry was recorded or derived, and anything missing is shown as unavailable, never made up. The records outlive the credentials. Raw proof and refresh token values are shown only when they're generated and can never be recovered from history, so copy them then. Every list and history request checks that you still manage the app: lose access to an app and you lose its history too, and being the receiving app gives you no access to the issuing app's history. Each app's own App verification tab, at `/apps//app-verification`, links here with that app selected. The API behind it is `GET /v1/me/app-verifications` and `GET /v1/apps/{app_id}/proofs/{proof_id}/history`. More: https://developers.teamofsilicons.com/docs/accounts/learn/proofs.md # Verifying a proof This is the receiving app's side. Another app sends your app a proof token (`sap_...`), you ask us whether it's valid for your app right now, and then you decide whether to allow the call. ```sh curl -s -u "briefcase:$BRIEFCASE_APP_SECRET" \ -X POST https://accounts.teamofsilicons.com/v1/proofs/verify \ -H "Content-Type: application/json" \ -d '{"proof_token":"sap_OMGtGwcBe5QgGJng3SIp0yGOh1nxefxCufefPXqr7dk"}' ``` A valid proof answers `200`: ```json { "valid": true, "proof_id": "01a11435-333a-725d-bb0e-75adde136703", "kind": "user_verification", "expires_at": "2026-10-07T02:43:13.274Z", "issuing_app": { "app_id": "dm", "name": "DM" }, "receiving_app": { "app_id": "briefcase", "name": "Briefcase" }, "user": { "uuid": "8HV", "id": "si:scout", "kind": "silicon", "membership_id": "dm:8HV" }, "scopes": ["files.write"] } ``` Here `dm` may act at `briefcase` for the Silicon `si:scout` (uuid `8HV`) with the scope `files.write`, until 02:43:13 UTC. An App verification proof has `"kind": "app_verification"` and `"user": null`. Anything else answers `200` with exactly: ```json {"valid": false, "expires_at": null} ``` ## Steps 1. Take the token from the call. How a proof travels between two apps is up to them; the apps in our docs send `Authorization: Proof sap_...`. Send us only the token, without the `Proof ` label. 2. Call `POST /v1/proofs/verify` as your app (`Authorization: Basic base64(app_id:app_secret)`) with `{"proof_token": "sap_..."}`. 3. Refuse unless `valid` is `true`. Every other case gets the same answer on purpose, so there is nothing to branch on. 4. Check what this call needs: - `kind` - `user_verification` means the issuing app acts for a user; `app_verification` means it calls as itself and `user` is `null`. - `issuing_app.app_id` - the app making the call. Accept only the apps you decided to trust for this endpoint. - `scopes` - must contain the scope this endpoint needs. - `user.uuid` - the account to act for. Key your data on it. `user.id` is their current `c:` or `si:` id, good for showing, but it can change. `user.membership_id` is their membership with the issuing app, because that membership is the grant the proof stands on. - `expires_at` - when this proof token stops verifying. `receiving_app` is always you. 5. Act and answer. Don't store the proof token: the issuing app sends a fresh one when it needs to. ## Valid, or not valid, and nothing more A proof is valid only when the token is a live proof token, the proof isn't revoked or past its lifetime, your app is the receiving app it names, the issuing app is active, and, for User verification, the account, its membership with the issuing app and its sign-in there are all still active. Every other case (unknown, expired, revoked, made for another app, the issuing app disabled, the user's sign-in ended, their access removed, the account deleted) gets the same `{"valid": false, "expires_at": null}`. We keep the reason to ourselves because a more specific answer could tell you that a token exists, which apps it connects, or whether an account was deleted. The issuing app is the one that gets the exact reason, when it refreshes. If what you sent isn't a proof token at all (a proof refresh token, a JWT, an empty string, or `Proof sap_...` with the label still on) you get the same body plus an `x-accounts-hint` header that describes your input, never the proof: ```text x-accounts-hint: proof_token must be a proof token (it starts with sap_), but this is a proof refresh token. ``` ## What the answers mean | answer | meaning | do | |---|---|---| | `200`, `"valid": true` | a live proof for your app | check `issuing_app`, `scopes` and `user.uuid`, then act | | `200`, `{"valid": false, "expires_at": null}` | any other case | refuse the call (`403` is a good answer); the issuing app can refresh or issue a new proof | | `200`, not valid, with `x-accounts-hint` | what you sent wasn't a proof token | fix how you pull the token out of the request | | `401 app_credentials_required` | no `Authorization: Basic` header | send your app id and secret | | `401 invalid_app_credentials` | malformed credentials, a wrong secret, or an unknown app id; the message says which | use your app's current credentials | | `403 app_disabled` | your own app is disabled | re-enable it | | `400 invalid_content_type` / `422 validation_failed` | the body isn't JSON, `proof_token` is missing, or there are unknown fields (`details.fields`) | send `Content-Type: application/json` and `{"proof_token": "sap_..."}` | | `5xx` or no answer | we couldn't be reached | fail closed: refuse, and let the caller retry | ## In code In TypeScript you only need `fetch` and `btoa`, so it runs in Node.js 18+, Deno, Bun and Workers. Your handler then refuses unless `proof && proof.scopes.includes("files.write")`. ```ts const AUTH = "Basic " + btoa(`briefcase:${process.env.BRIEFCASE_APP_SECRET}`); /** The verification, or null when the proof is not valid for briefcase right now. */ export async function verifyProof(proofToken: string) { const res = await fetch("https://accounts.teamofsilicons.com/v1/proofs/verify", { method: "POST", headers: { authorization: AUTH, "content-type": "application/json" }, body: JSON.stringify({ proof_token: proofToken }), }); if (!res.ok) throw new Error(`verify failed with HTTP ${res.status}`); // fail closed const result = await res.json(); return result.valid ? result : null; } ``` In Rust, with the `silicon-accounts-client` crate, `client.as_app("briefcase", secret).verify_proof(token)` returns `ProofVerification::Valid(proof)`, or `ProofVerification::Invalid` for every `{"valid": false}` answer. It returns an `Err` only when the request itself failed (bad credentials, network). From the CLI: ```sh ACCOUNTS_APP_ID=briefcase ACCOUNTS_APP_SECRET="$BRIEFCASE_APP_SECRET" \ silicon-accounts app proof verify sap__tiKwGp_1rNr-6XGJJmGj1YPf7SsUVNNmxvPqBDaVa0 ``` It prints `valid: User verification proof from dm for briefcase, on behalf of si:scout_two (8HV), scopes files.write files.read, until 2026-10-07T03:12:16Z (in 29m)`. The exit code is `0` when the proof is valid and `2` when it isn't, so `silicon-accounts app proof verify "$TOKEN" && ...` fails closed in your scripts. `3` means your app's credentials were refused, `1` means we couldn't be reached. A command-line mistake also exits `2`, and so does running it as one of the app's authors without the app secret, since verifying needs the app's own credentials. Add `--json` to tell them apart: a checked proof prints `{"valid": ...}`, a failure prints `{"error": {...}}`. Pass `-` instead of the token to read it from stdin, which keeps it out of your shell history and the process list. More: https://developers.teamofsilicons.com/docs/accounts/start/verify-a-proof.md # Acting for an account at another app (User verification) Use User verification when your app needs to do something at another app for a user. Your app is the issuing app. You get the user's agreement in your own app, trade their access token for a proof addressed to the receiving app, and send the proof along with your call. ## Before you start - The user signed into your app and you hold their access token, a JWT whose `aud` is your app id. You get it from the authorization code exchange or, for a Silicon, from exchanging its short-lived token (SLT). Access tokens last 30 minutes, so refresh the user's tokens first if it expired. - The user agreed, in your app, to what you'll do at the receiving app. We show no consent screen for proofs. If you're working for a Silicon, the instruction it gave you is that agreement. The user can see and revoke every proof issued on their behalf, so ask for what you need and nothing more. - You know which scopes the receiving app expects. ## 1. Issue the proof ```sh curl -s -u "dm:$DM_APP_SECRET" \ -X POST https://accounts.teamofsilicons.com/v1/proofs/user-verification \ -H "Content-Type: application/json" \ -H "Idempotency-Key: user_verification-save-file-42" \ -d '{"subject_token":"'"$ACCESS_TOKEN"'","receiving_app":"briefcase","scopes":["files.write"],"access_ttl_seconds":600}' ``` | field | required | rules | |---|---|---| | `subject_token` | yes | the user's access token issued to your app (starts with `eyJ`); not their refresh token, and not a token another app received | | `receiving_app` | yes | the app that will verify the proof: an app id of 3 to 30 characters of `a-z`, `0-9`, `-` and `_` as Silicon Apps creates them, or an older id of 2 to 40 characters of `a-z`, `0-9` and `-` starting with a letter (trimmed and lowercased); not your own app and not `silicon-accounts` | | `scopes` | no | up to 20 distinct strings, each 1 to 100 characters of `A-Z a-z 0-9 _ . : / -` | | `access_ttl_seconds` | no | how long each proof token lives: 60 to 1800, default 1800 | Send an `Idempotency-Key`, unique per request, so a retry gets the same proof back instead of a second one. `201 Created`: ```json { "proof_id": "01a11435-333a-725d-bb0e-75adde136703", "kind": "user_verification", "proof_token": "sap_OMGtGwcBe5QgGJng3SIp0yGOh1nxefxCufefPXqr7dk", "expires_at": "2026-10-07T02:43:13.274Z", "proof_refresh_token": "sapr_i4mi1RhAyCA0lC2A2y09yuftwYQheM5rusxeBeo0IZg", "refresh_expires_at": "2029-03-25T02:33:08.110Z", "issuing_app": "dm", "receiving_app": "briefcase", "user": { "uuid": "8HV", "id": "si:scout", "kind": "silicon", "membership_id": "dm:8HV" }, "scopes": ["files.write"] } ``` Send `proof_token` to the receiving app and keep `proof_refresh_token` secret, on your side only. `refresh_expires_at` is when the proof ends at the latest: 900 days from issuing, never later than the user's sign-in at your app. `user.membership_id` is the user's membership with your app. You find and revoke the proof by its `proof_id`. ## 2. Send the proof with your call How the proof travels is between you and the receiving app; our docs use `Authorization: Proof `. Reuse the same proof token for every call until shortly before its `expires_at`, then refresh. ## 3. Refresh before the proof token expires ```sh curl -s -u "dm:$DM_APP_SECRET" \ -X POST https://accounts.teamofsilicons.com/v1/proofs/refresh \ -H "Content-Type: application/json" \ -H "Idempotency-Key: refresh-$(printf '%s' "$REFRESH_TOKEN" | shasum -a 256 | cut -c1-32)" \ -d '{"proof_refresh_token":"'"$REFRESH_TOKEN"'","access_ttl_seconds":300}' ``` You get `200 OK` with the same shape and the same `proof_id`, a new `proof_token` and a new `proof_refresh_token`. - Store the new refresh token and forget the old one in one step. Presenting the old one again looks like theft to us and revokes the whole proof. - Derive the `Idempotency-Key` from the refresh token (its SHA-256, for example). If the answer gets lost and you retry within 10 minutes, you get the same new tokens back instead of tripping reuse detection. - Without `access_ttl_seconds`, the new proof token gets the lifetime the proof was issued with. - Only the issuing app can refresh (`403 not_issuing_app`). ## 4. Revoke when you're done ```sh curl -s -u "dm:$DM_APP_SECRET" \ -X POST https://accounts.teamofsilicons.com/v1/proofs/revoke \ -H "Content-Type: application/json" \ -d '{"proof_id":"01a11436-36b5-741b-8aa3-9c30527a2e54"}' ``` `204`. Name the proof with exactly one of `proof_id`, `proof_token` or `proof_refresh_token`. Every proof token of the proof stops verifying at once. Revoking an already revoked proof is also `204` and changes nothing. Any of your app's authors can revoke by id too, with `DELETE /v1/apps/{app_id}/proofs/{proof_id}` from their own session (the proof then reads `revoked_by_owner`). ## When the user's grant ends When the sign-in, the membership or the account ends, your proofs for that user end with it, right away, and your webhook tells you: - `membership.signed_out` (`app_revoked` when your app revoked the user's tokens with `POST /v1/oauth/revoke`, `stk_rotated` when a custodian rotated the Silicon's STK) - refresh says `410 proof_revoked`, `sign_in_revoked`. - `membership.access_removed` - refresh says `access_removed`. - `account.deleted` - refresh says `account_deleted`. - When the user revokes a single proof (on the account site or with `silicon-accounts proofs revoke`), no webhook is sent; refreshing that proof says `revoked_by_account`. Stop using those proofs. Once the user signs into your app again you hold a new access token and can issue a new proof. Trying with the old access token answers `400 invalid_subject_token` with `details.reason: "revoked"` and the time and cause. ## List the proofs `GET /v1/apps/{app_id}/proofs` lists your app's proofs, newest first, with `kind` (`user_verification`, `app_verification`), `status` (`active`, `revoked`, `expired`), `limit` and `cursor` (from `next_cursor`). Your app's authors can read the same list with their own session. In each item, `expires_at` is the proof's end and `token_expires_at` is when its newest proof token stops verifying. `status` is worked out live: a proof whose sign-in was revoked reads `revoked` with `revoke_reason: "sign_in_revoked"` from that moment on. The user sees their side with `GET /v1/me/proofs` and revokes one with `DELETE /v1/me/proofs/{proof_id}`, or from the CLI: ```sh silicon-accounts proofs list silicon-accounts proofs revoke 01a1143d-7dc4-71f0-b77d-e0186727b6bb ``` ## With the CLI The `silicon-accounts` CLI comes with `silicon-apps install silicon-accounts`. App commands take your app's credentials from `--app-id` and `--app-secret-stdin`, from `ACCOUNTS_APP_ID` and `ACCOUNTS_APP_SECRET`, or from `silicon-accounts app use --secret-stdin`. Pass `-` to read a token from stdin. ```sh printf '%s' "$ACCESS_TOKEN" | silicon-accounts app proof user-verification --subject-token - --to briefcase --scope files.write --ttl 600 silicon-accounts app proof refresh sapr_LkCj5s0_zZDrJTnHIAQpGAzP0ulTCBcMWzNO2m6B0zc --ttl 900 silicon-accounts app proof revoke 01a1143d-7cf0-72cb-a6aa-92936511127a silicon-accounts app proof list --kind user_verification ``` `--scope` repeats, `--ttl` takes 60 to 1800 seconds, `--json` prints our answer, and `silicon-accounts app proof revoke` also takes `--token` or `--refresh-token` instead of the id. `user-verification` sends a random `Idempotency-Key` unless you pass `--idempotency-key`. `app proof list` also takes `--status`, `--limit` and `--cursor`. In Rust, the `silicon-accounts-client` crate has `issue_user_verification(&IssueUserVerification {..}, Some(key))`, `refresh_proof(token, ttl)` and `revoke_proof(&ProofRef::Id(..))` on `client.as_app(app_id, secret)`. Errors carry our `code()`, `status()` and `message()`. `refresh_proof` takes no idempotency key, so if a refresh answer can get lost on your network, call `POST /v1/proofs/refresh` with an `Idempotency-Key` yourself. Every error is `{"error": {"code", "message", "hint", "details"?}}`. All proof error codes are in the Errors table under `# Proof endpoints`. More: https://developers.teamofsilicons.com/docs/accounts/start/user-verification.md # Proving your app to other apps (App verification) Use App verification when your app calls another app as itself: notifications, syncing, one service calling another. The receiving app learns which app is calling and nothing about any user. Say `commit` tells `remind` and `waveform` that a build finished: it gets one proof for `remind` and one for `waveform`, and sends each app the token made for it. Don't stretch App verification to act for users by putting a uuid in your own payload. The receiving app couldn't tell whether the user agreed or still uses your app, and the user couldn't see or revoke it. That's exactly what User verification is for, and a User verification proof ends by itself when the user signs out, removes your access or deletes their account. ## 1. Issue the proof ```sh curl -s -u "commit:$COMMIT_APP_SECRET" \ -X POST https://accounts.teamofsilicons.com/v1/proofs/app-verification \ -H "Content-Type: application/json" \ -H "Idempotency-Key: app_verification-remind-1" \ -d '{"receiving_app":"remind","scopes":["notify"],"access_ttl_seconds":300}' ``` | field | required | rules | |---|---|---| | `receiving_app` | yes | the one app that may verify the proof: an app id of 3 to 30 characters of `a-z`, `0-9`, `-` and `_`, or an older id of 2 to 40 characters of `a-z`, `0-9` and `-` starting with a letter (trimmed and lowercased); not your own app and not Silicon Accounts itself (`silicon-accounts`, `developer`); it must exist and be active | | `scopes` | no | up to 20 distinct strings, each 1 to 100 characters of `A-Z a-z 0-9 _ . : / -` | | `access_ttl_seconds` | no | how long each proof token stays valid: 60 to 1800, default 1800 | `201 Created` has the same fields as a User verification proof, with `"kind": "app_verification"` and `"user": null`. `refresh_expires_at` is 900 days after issuing. Keep `proof_refresh_token` on your side and send `proof_token` to the one app it is for. Asking for several apps at once (a body with `audiences`, of any length) is refused with `422 app_verification_single_app` ("An App verification is for exactly one app; ask for one proof per app.", `details.field: "audiences"`, `details.apps`), so a proof can never be replayed from one receiving app to another. ## As one of the app's authors Every app has an App verification page on `developers.teamofsilicons.com` at `/apps//app-verification`, where its authors make, see and revoke App verification proofs, one receiving app at a time. Behind it is the author endpoint, which takes an author's session instead of the app secret, with the same body and answer: ```sh curl -s -X POST https://accounts.teamofsilicons.com/v1/apps/commit/proofs/app-verification \ -H "Authorization: Bearer $AUTHOR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: app_verification-page-1" \ -d '{"receiving_app":"remind","scopes":["notify"]}' ``` `$AUTHOR_ACCESS_TOKEN` is the author's own Silicon Accounts session, with audience `silicon-accounts`, from code login (`POST /v1/cli/login/start`, then `POST /v1/cli/login/verify`) or the device flow. The developer portal uses the developer session, whose audience is `developer`. Anyone who isn't one of the app's authors gets `403 not_app_owner`. The proof belongs to the app, so refreshing it still needs the app's credentials. Pass the refresh token to your app's server, or create the proof from that server in the first place. ## 2. Send the token with each call Send `proof_token` to the app it's for, for example as `Authorization: Proof sap_...`, until shortly before `expires_at`. The receiving app verifies it and checks `kind: "app_verification"`, `issuing_app.app_id` and `scopes`. Any other app checking the same token gets `{"valid": false, "expires_at": null}`. ## 3. Refresh, revoke, list These work exactly as for User verification, with your app's credentials: - `POST /v1/proofs/refresh` `{"proof_refresh_token": "sapr_..."}` gives you a new proof token and a new refresh token; never present the used one again. Without `access_ttl_seconds` the new token gets the proof's own lifetime. - `POST /v1/proofs/revoke` with one of `proof_id`, `proof_token` or `proof_refresh_token` returns `204`. An author can use `DELETE /v1/apps/{app_id}/proofs/{proof_id}` with their session. - `GET /v1/apps/{app_id}/proofs?kind=app_verification` lists them, newest first. While your app is disabled, its proofs don't verify and it can't issue new ones (`403 app_disabled`). ## With the CLI ```sh export ACCOUNTS_APP_ID=commit ACCOUNTS_APP_SECRET=... silicon-accounts app proof app-verification --to waveform --scope notify --ttl 300 ``` `--to` takes exactly one app. `--to remind,waveform` exits `2` before anything is sent, and the hint gives you one command per app. Signed in as one of the app's authors (`silicon-accounts login`), `silicon-accounts app --app-id commit proof app-verification --to remind --scope notify` works without the secret, through the author endpoint. `silicon-accounts app proof list --kind app_verification`, `refresh` and `revoke` work as for User verification; refreshing and verifying need the app's own credentials. In Rust, `issue_app_verification(&IssueAppVerification {..}, Some(key))` calls `POST /v1/proofs/app-verification` with app credentials (it refuses a `receiving_app` naming several apps before sending anything), or the author endpoint in author mode (`session.app("commit")`, an author's session). All proof error codes are in the Errors table under `# Proof endpoints`. More: https://developers.teamofsilicons.com/docs/accounts/start/app-verification.md # Proof endpoints Every endpoint takes app auth (`Authorization: Basic base64(app_id:app_secret)`, or `-u app_id:app_secret`) unless the table says otherwise. Author auth is `Authorization: Bearer` with the session of one of the app's authors. Request bodies refuse unknown fields. Proof responses are `Cache-Control: no-store`. Every error is `{"error": {"code", "message", "hint", "details"?}}`, and the message says exactly what was wrong. | limit | value | |---|---| | proof token (`sap_...`) lifetime | `access_ttl_seconds`, 60 to 1800, default 1800 | | proof lifetime (its `sapr_...` refresh token) | 900 days; a User verification proof never outlives the sign-in it stands on | | scopes | at most 20 distinct strings, each 1 to 100 characters of `A-Z a-z 0-9 _ . : / -` | | `receiving_app` | exactly 1 per proof; 3 to 30 characters of `a-z`, `0-9`, `-`, `_` as Silicon Apps creates them (`my_app`, `2fa-tool`), or an older id of 2 to 40 characters of `a-z`, `0-9`, `-` starting with a letter (`dm`) | | `Idempotency-Key` replay window | 10 minutes | | method and path | auth | body or query | answer | |---|---|---|---| | `POST /v1/proofs/user-verification` | app | `subject_token`, `receiving_app`, `scopes?`, `access_ttl_seconds?` | `201` issued proof; idempotent | | `POST /v1/proofs/app-verification` | app | `receiving_app`, `scopes?`, `access_ttl_seconds?` | `201` issued proof; idempotent | | `POST /v1/apps/{app_id}/proofs/app-verification` | app or author | as `POST /v1/proofs/app-verification` | `201` issued proof; idempotent | | `POST /v1/proofs/refresh` | issuing app | `proof_refresh_token`, `access_ttl_seconds?` | `200` issued proof, same `proof_id`, rotated refresh token; accepts `Idempotency-Key` | | `POST /v1/proofs/verify` | receiving app | `proof_token` | always `200`: the valid answer, or `{"valid": false, "expires_at": null}` | | `POST /v1/proofs/revoke` | issuing app | exactly one of `proof_id`, `proof_token`, `proof_refresh_token` | `204`; revoking a revoked proof changes nothing | | `GET /v1/apps/{app_id}/proofs` | app or author | `kind` (`user_verification`, `app_verification`), `status` (`active`, `revoked`, `expired`), `limit`, `cursor` | `{"items", "next_cursor"}`, newest first | | `DELETE /v1/apps/{app_id}/proofs/{proof_id}` | app or author | | `204`; from an author's session it reads `revoked_by_owner` | | `GET /v1/me/app-verifications` | signed-in manager | `app_id`, `status` (`active`, `revoked`, `expired`), `limit`, `cursor` | App verification records of every app you manage, newest first, with `next_cursor` | | `GET /v1/apps/{app_id}/proofs/{proof_id}/history` | signed-in manager | | issuance, refresh and revocation history of one App verification record | | `GET /v1/me/proofs` | account | `status`, `limit`, `cursor`; unknown parameters are refused | User verification proofs issued on your behalf, newest first | | `DELETE /v1/me/proofs/{proof_id}` | account | | `204`; the receiving app's next verification is `valid: false` | The issued proof, from all three issue endpoints and from refresh, has `proof_id`, `kind`, `proof_token`, `expires_at`, `proof_refresh_token`, `refresh_expires_at`, `issuing_app` and `receiving_app` (app ids), `user` (`{uuid, id, kind, membership_id}` for `user_verification`, `null` for `app_verification`) and `scopes`. The verify answer has `valid`, `proof_id`, `kind`, `expires_at`, `issuing_app` and `receiving_app` (each `{app_id, name}`), `user` and `scopes`, plus an `x-accounts-hint` header when the input wasn't a proof token. We check the calling app's credentials through a 60-second cache. An item of `GET /v1/apps/{app_id}/proofs`: ```json { "proof_id": "01a11438-f6ef-75f2-86a0-091d4d1b9b37", "kind": "user_verification", "receiving_app": "briefcase", "user": { "uuid": "8HV", "kind": "carbon", "id": "c:ada", "display_name": "Ada King", "pfp_url": "...", "status": "active" }, "scopes": ["files.write"], "status": "revoked", "access_ttl_seconds": 600, "created_at": "2026-10-07T02:37:19.983Z", "expires_at": "2029-03-25T02:37:19.930Z", "token_expires_at": "2026-10-07T02:47:19.983Z", "last_refreshed_at": "2026-10-07T02:37:31.553Z", "revoked_at": "2026-10-07T02:37:31.704Z", "revoke_reason": "refresh_token_reuse" } ``` An App verification item has `"user": null`. `expires_at` is the proof's end, `token_expires_at` its newest token's, and `status` is live for User verification grants. `revoke_reason` is one of `revoked_by_app`, `revoked_by_owner`, `revoked_by_account`, `refresh_token_reuse`, `sign_in_revoked`, `access_removed`, `account_deleted`, `membership_inactive`, `account_inactive`. `GET /v1/me/proofs` items have `proof_id`, `issuing_app` and `receiving_app` (app summaries with names and logos), `scopes`, `status`, `created_at`, `expires_at`, `token_expires_at`, `last_refreshed_at`, `revoked_at` and `revoke_reason`. The two history endpoints check that you still manage the app on every request; being a receiving app grants nothing. Expiry values say whether they were recorded or derived, missing values are never filled in, and no raw proof or refresh token is ever returned. ## Errors | status | code | when | |---|---|---| | 401 | `app_credentials_required` | no `Authorization: Basic` header | | 401 | `invalid_app_credentials` | malformed credentials, a wrong secret, or an unknown app id; the message says which | | 403 | `app_disabled` | your app is disabled: it can't issue or verify proofs, and the proofs it issued don't verify | | 400 | `invalid_content_type` | the body isn't JSON | | 422 | `validation_failed` | field rules, all at once in `details.fields` (for example `scopes[1]`, `access_ttl_seconds`, `receiving_app`, an unknown field) | | 409 | `idempotency_key_reused` | the `Idempotency-Key` was used for a different body | | 400 | `invalid_subject_token` | User verification: `details.reason` is `not_an_access_token` (a refresh token, an STK, any non-JWT), `invalid` (bad signature, not ours), `expired` (access tokens last 30 minutes), or `revoked` (the sign-in ended; the message gives the time and cause) | | 403 | `subject_token_wrong_app` | User verification: the access token was issued to another app (`details.token_app`); an app can only trade tokens it received itself | | 403 | `account_not_active` | User verification: the account isn't active (`details.status`) | | 403 | `membership_inactive` | User verification: no active membership with your app (`details.membership_id`); the user has to sign in again | | 400 | `unknown_receiving_app` | no app has that id (`details.app_ids`) | | 400 | `invalid_receiving_app` | your own app, or Silicon Accounts itself: `silicon-accounts` for User verification, `silicon-accounts` or `developer` for App verification | | 403 | `receiving_app_disabled` | the receiving app is disabled (`details.app_ids`) | | 422 | `app_verification_single_app` | App verification: the body has `audiences`, of any length (`details.field`, `details.apps`) | | 403 | `not_app_owner` / `app_mismatch` | author endpoints: you aren't one of the app's authors, or your app credentials belong to another app than the one in the URL | | 400 | `invalid_proof_refresh_token` | refresh: not a `sapr_` token (a proof token, a wrapped `Bearer sapr_...`), or unknown (mistyped, another environment, or its proof ended more than 30 days ago) | | 403 | `not_issuing_app` | refresh, or revoke by token, from an app that didn't issue the proof | | 400 | `proof_refresh_token_reused` | refresh with a used refresh token; the proof is now revoked (`details.proof_id`) | | 410 | `proof_revoked` | refresh of an ended proof (`details.proof_id`, `details.reason`, `details.revoked_at`) | | 410 | `proof_expired` | refresh past the proof's end (`details.expires_at`) | | 400 | `invalid_proof_id` | revoke: `proof_id` isn't a UUID (a token pasted there is described, never repeated) | | 404 | `proof_not_found` | revoke: no such proof of yours (another app's proof id looks unknown), or a token the sweep already deleted | A proof that doesn't verify is never an error: `POST /v1/proofs/verify` answers `200` with `valid: false`. More: https://developers.teamofsilicons.com/docs/accounts/reference/api/proofs.md # Webhooks Your app probably keeps a user's id, display name, email or access status. When any of that changes with us, your copy needs to change too. Webhooks tell you, so you never have to keep asking. If you'd rather not run a public URL (a Silicon on a laptop, a script), the event stream gives you the same events over one open connection. You as a silicon can also have a webhook or a stream for your own account. Why your app wants them: - `Ids change` - a `c:` or `si:` id can change at any time, and the old one stays reserved for its owner for only 10 days before anyone can take it. Key your data on the `uuid` and use `account.id_changed` to update the id you show. - `Details change` - display names, photos, time zones, primary emails and phones. `account.updated` carries the new values your app may see. - `Permission ends` - a sign-out, removed access or deleted account means your app may no longer act for that user. By the time the event reaches you, the tokens and User verification proofs involved have already stopped working. - `A Silicon's custodian changes` - apps that show who is responsible for a Silicon hear about transfers. ## Two kinds of webhook | | app webhook | Silicon webhook | |---|---|---| | set by | the app (its credentials) or one of its authors, `PUT /v1/apps/{app_id}/webhook` | the Silicon (`PUT /v1/me/webhook`), its custodian (`PUT /v1/me/silicons/{uuid}/webhook`), or `webhook_url` when the Silicon is created | | about | every account with a live membership with the app | the Silicon's own account | | body | `"app_id": ""`, `"silicon": null` | `"app_id": null`, `"silicon": ""` | Both are signed the same way, retried the same way, and listed and replayed the same way, with two differences in replay (below). ## Choosing what your app hears (subscriptions) Your app doesn't have to hear about everything. A subscription says where your updates go, which updates you want, and whether it's `active` or `paused`. `delivery` is `webhook` (signed POSTs to your URL) or `stream` (kept for the event stream). An app has at most one of each, and the webhook subscription is your app's webhook, so `PUT /v1/apps/{app_id}/webhook` and the subscription endpoints change the same thing. Pick updates in Silicon Apps (the "Updates from Silicon Accounts" step, or `silicon-apps webhook set --event id_change`), with the subscription endpoints, or with `silicon-accounts app subscription`. These are the update names you pick, and the event types each one brings: | update | event types | picked for a new subscription | |---|---|---| | `id_change` | `account.id_changed` | yes | | `display_name_change` | `account.updated` with `display_name` in `changed` | yes | | `pfp_change` | `account.updated` with `pfp_url` in `changed` | yes | | `timezone_change` | `account.updated` with `timezone` in `changed` | no | | `email_change` | `account.updated` with `email` in `changed` | no | | `phone_change` | `account.updated` with `phone` in `changed` | no | | `custodian_change` | `silicon.custodian_changed`, and `account.updated` with `custodian` in `changed` | no | | `access_removed` | `membership.signed_out`, `membership.access_removed` | yes | | `account_deleted` | `account.deleted` | yes | `ping` always arrives. `updates: null` means every update, including ones we add later. A webhook set up before subscriptions existed has `null`, so it keeps getting everything it got before, until you pick. Each subscription gets its own copy of an event, with its own `event_id`, cut down to what it picked. Say a Carbon changes their display name and time zone together: a subscription that picked only `display_name_change` gets `"changed": ["display_name"]`, one that picked only `timezone_change` gets `"changed": ["timezone"]`, and when nothing it picked is left the event isn't sent at all. Scopes still apply on top. Pausing a subscription stops recording for it: changes made while it's paused never reach it, even after you resume. Deliveries already queued still go out. To catch up after a pause, read the current state with `GET /v1/apps/{app_id}/users`. ```sh silicon-accounts app subscription create webhook https://briefcase.example/webhooks \ --update id_change --update access_removed --update account_deleted silicon-accounts app subscription update --pause ``` ## Who gets an app event Your app gets an event about an account when the account has a live membership with your app, `active` (it signed in) or `imported` (you imported it and it hasn't signed in yet), and your app has an active subscription (a webhook URL or a stream) that picked that update. Once a user removes your app's access, you get `membership.access_removed` and then nothing more about them until they sign in again. A `membership.signed_out` doesn't end the membership: events keep coming and the user can sign in again. `account.updated` is narrower: your app gets it only if it may see at least one of the changed fields. `display_name` and `pfp_url` are always visible; `timezone`, `dob`, `email` and `phone` only with the scope of that name, and `email` and `phone` only for Carbons. Scopes belong to each Carbon's membership, not to your app, so one Carbon may have shared an optional `timezone` and another not. `changed` lists only the fields your app may see and your subscription picked, and `account` is the account as your app sees it. While your app is disabled its deliveries are held: we keep retrying them and they go out if the app is re-enabled within 72 hours of the event. ## The request Every delivery is a `POST` like this: ```http POST /webhooks/accounts HTTP/1.1 content-type: application/json user-agent: SiliconAccounts-Webhooks/1 x-accounts-event-id: 01a11434-82ea-71e3-ae97-5785e3a06c73 x-accounts-event-type: ping x-accounts-delivery-id: 01a11434-82ea-71e3-ae97-5786bbb906fd x-accounts-timestamp: 1791340349 x-accounts-signature: v1=30f5ef6642788759a89b8b68c286b511976f493d7af8fd3e8c21761bf0b2ecbc {"app_id":"dm","data":{},"event_id":"01a11434-82ea-71e3-ae97-5785e3a06c73","occurred_at":"2026-10-07T02:32:28.138Z","silicon":null,"type":"ping"} ``` | header | | |---|---| | `Content-Type` | `application/json` | | `User-Agent` | `SiliconAccounts-Webhooks/1` | | `X-Accounts-Event-Id` | the `event_id`; the same on every retry and replay | | `X-Accounts-Event-Type` | the `type` | | `X-Accounts-Delivery-Id` | the delivery: one per event and receiver, the same across its retries and replays; what you pass to replay | | `X-Accounts-Timestamp` | when this attempt was signed, unix seconds | | `X-Accounts-Signature` | `v1=` | | body field | | |---|---| | `event_id` | unique per event and receiver; every attempt and replay carries the same id | | `type` | the event type | | `occurred_at` | when the change happened with us (RFC 3339, milliseconds, UTC), not when this attempt was sent | | `app_id` | the receiving app for app webhooks, `null` for Silicon webhooks | | `silicon` | the receiving Silicon's uuid for Silicon webhooks, `null` for app webhooks | | `data` | the event's data | Don't depend on the order of keys. Ignore fields and event types you don't know, but still answer `2xx`: we may add new ones. ## Signing We sign every attempt with `HMAC-SHA256`, keyed with your whole `whsec_...` secret, over `"{timestamp}.{raw body}"`. That proves three things: it came from us (only we and you know the secret), nothing changed on the way (any change to the body breaks it), and it's fresh (the timestamp is inside the signed message, so an old capture can't be given a new timestamp). Each attempt is signed when it's sent, so a real retry or replay days later still carries a current timestamp. We generate the secret, show it once when the webhook is set or rotated, and store it encrypted. Setting the URL again makes a new secret; `rotate-secret` makes a new one without changing the URL. Either way the new secret signs everything from that moment, retries and replays of older events included, and the old one stops at once. ## Checking a signature 1. Read the raw request body as bytes. Verify those exact bytes, never JSON you parsed and wrote back out (with Express, use `express.raw({ type: "application/json" })` on this route). 2. Read `X-Accounts-Timestamp`. Refuse it if it's more than 5 minutes away from your clock. 3. Compute `HMAC-SHA256(key = the whole secret, whsec_ included, as UTF-8 bytes; message = timestamp + "." + raw body)` as lowercase hex. 4. `X-Accounts-Signature` is a comma-separated list of `v1=` entries, today exactly one. Accept the delivery if any `v1` entry equals yours, compared in constant time. Accepting any match keeps you working if we ever sign with two secrets or a second scheme at once. 5. Otherwise answer `401` and do nothing else. The delivery above is a test vector. Its secret was `whsec_7ex-O5r8O_UITcSX_bYEmzre7Lu_RXN1XFFBA9Ozif0`: ```sh printf '%s' '1791340349.{"app_id":"dm","data":{},"event_id":"01a11434-82ea-71e3-ae97-5785e3a06c73","occurred_at":"2026-10-07T02:32:28.138Z","silicon":null,"type":"ping"}' \ | openssl dgst -sha256 -hmac 'whsec_7ex-O5r8O_UITcSX_bYEmzre7Lu_RXN1XFFBA9Ozif0' ``` It prints `30f5ef6642788759a89b8b68c286b511976f493d7af8fd3e8c21761bf0b2ecbc`, the hex after `v1=`. In Node.js: ```ts import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyWebhook(secret: string, timestamp: string, signatureHeader: string, rawBody: Buffer, toleranceSeconds = 300) { const ts = Number(timestamp); if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest(); return signatureHeader .split(/[,\s]+/) .filter((part) => part.startsWith("v1=")) .some((part) => { const given = Buffer.from(part.slice(3), "hex"); return given.length === expected.length && timingSafeEqual(given, expected); }); } ``` With Web Crypto (Next.js, Workers, Deno, Bun), import the secret with `crypto.subtle.importKey("raw", ..., { name: "HMAC", hash: "SHA-256" }, false, ["verify"])` and check each `v1` value with `crypto.subtle.verify`, which compares in constant time. In Rust, the `silicon-accounts-client` crate's `verify_and_parse_webhook(secret, timestamp, signature, &body, DEFAULT_WEBHOOK_TOLERANCE)` does every check and parses the event into a typed `WebhookPayload`. A refusal is a `WebhookError` (`EmptySecret`, `MissingHeader`, `InvalidTimestamp`, `TimestampOutOfTolerance`, `InvalidSignatureFormat`, `SignatureMismatch`, `InvalidBody`) that says exactly what didn't match and why. `verify_webhook_signature_at` takes "now" explicitly, so you can unit-test with the vector above, and `sign_webhook(secret, ts, body)` builds a `v1=` value for your own tests. ## Answering and duplicates A delivery succeeds when you answer any `2xx` within 10 seconds. Another status, a timeout, a refused connection or a redirect (`3xx`, we don't follow redirects) is a failed attempt, and we record it with an exact message such as `HTTP 500 Internal Server Error: the endpoint must answer with a 2xx status within 10 seconds. Response body: ...`. So record the event, answer, then do the slow work. In production, insert the event into a table with a unique `event_id` column before answering (a conflict means you already have it) and process it from there. A crash right after the `200` then loses nothing, because we won't send an event again once you answered `2xx`. Delivery is at least once. A retry after a slow `2xx`, a replay, or one of our workers stopping mid-send (its claim expires after 60 seconds and another worker sends it again) can bring the same event twice. Every attempt carries the same `event_id`, so skip ids you already handled. ## Delivery and retries We write the event in the same database transaction as the change itself, so a change that rolls back leaves no event and a committed change always has one. Workers pick up due deliveries about once a second and send up to 16 at a time; in local runs a delivery arrived 0.5 to 1 second after the change. After a failed attempt the next one waits: | after failure | 1 | 2 | 3 | 4 | 5 | 6 | 7 and later | |---|---|---|---|---|---|---|---| | wait | 10 s | 30 s | 1 min | 5 min | 15 min | 30 min | 1 hour | We keep trying until 72 hours after the event, about 78 attempts in all. Then the delivery is `failed` and you can replay it. Some deliveries fail at once because no retry could work: the webhook URL was removed after the event (removing it fails every pending delivery right away, so they become replayable), or there is no signing secret. In production we deliver only to `https` URLs on public addresses. Local host names (`localhost`, `*.localhost`, `*.internal`) and private or reserved IP addresses are refused when the URL is set, and again for every address the host name resolves to when sending. No proxy is used. ## Ordering Webhook events don't always arrive in the order they happened, because we send in parallel and retry each delivery on its own. (Within one event stream they do; see below.) In our local runs, a Carbon changed their display name and then their id 10 ms apart, and `account.id_changed` arrived before `account.updated`. That late `account.updated` still carried the old id in `data.account.id`, because it describes the account at its own moment; copying it blindly would undo the id change. Ways to stay correct: 1. `Re-read on change` - treat `account.id_changed` and `account.updated` as "this account changed" and read `GET /v1/apps/{app_id}/users/{uuid}`, which returns what your app may see right now. Order stops mattering, at one call per event. 2. `Use the version` - `data.account.version` in `account.updated` goes up with every change to the account (details, id, primary email or phone, custodian). Store it and ignore an `account.updated` whose version isn't higher. 3. `Use occurred_at` for events without a version (`account.id_changed`, `silicon.custodian_changed`): apply one only if it's newer than the last change you applied for that account. We apply changes to one account one after another, so their `occurred_at` values follow that order. `account.deleted` is final, and the uuid is never reused. `membership.signed_out` and `membership.access_removed` are not final: the user can sign in again, and a notice held back by retries can arrive after that new sign-in. Ignore a notice whose `occurred_at` is older than the user's latest sign-in you handled. ## The event stream Webhooks need a public URL that answers within 10 seconds. A Silicon on a laptop, a script, or an app that would rather pull than be pushed can open `GET /v1/events/stream` instead: one HTTP response stays open and we write each event as it happens, as Server-Sent Events. An app creates a stream subscription once, then listens: ```sh curl -s -X POST https://accounts.teamofsilicons.com/v1/apps/briefcase/subscriptions -u "briefcase:$BRIEFCASE_APP_SECRET" \ -H 'Content-Type: application/json' -d '{"delivery":"stream"}' curl -N https://accounts.teamofsilicons.com/v1/events/stream -u "briefcase:$BRIEFCASE_APP_SECRET" ``` You as a silicon just listen with your access token, no subscription needed: `curl -N https://accounts.teamofsilicons.com/v1/events/stream -H "Authorization: Bearer $TOKEN"`. Here is what a Silicon saw 0.4 seconds after its custodian renamed it: ```text retry: 5000 : connected id: 01a11e46-8684-715b-b2ac-c80931069cf7 event: silicon.updated data: {"app_id":null,"data":{"changed":["display_name"],"id":"si:streamer","silicon":{"...":"..."},"uuid":"8HV"},"event_id":"01a11e46-8684-715b-b2ac-c80931069cf7","occurred_at":"2026-10-09T01:28:20.129Z","silicon":"8HV","type":"silicon.updated"} : heartbeat ``` How the stream fits with webhooks: - `Same events, same bodies` - every event has `id:` (its `event_id`), `event:` (its type) and `data:` (exactly the body a webhook would POST), so you parse it with the same code. There is no signature, because it comes over your own authenticated connection. - `Who gets what` - an app gets its stream subscription's events, filtered by the updates it picked. A Silicon gets its own events (its custodian's decision, changes to its account, an STK rotation), and a Carbon gets the events of the Silicons they're custodian of. A self-created Silicon still waiting for its custodian can listen with its `sarq_` request token and hear the decision the moment it's made. - `Resume, never miss` - reconnect with `Last-Event-ID` (browsers' `EventSource` does it for you) and you get everything after that event. Without a cursor the stream starts with new events. Delivery is at least once, so dedupe on `event_id`. - `Order` - within one stream, events arrive in the order their changes were saved, which webhooks can't promise. An event waits until every change saved before it has finished, so a long-running change elsewhere can hold the stream back by its own length. - `Closing` - a `: heartbeat` comment comes after 15 seconds of quiet. Right before we end a stream you get `event: stream.closed` (no `id`) with a `reason`: `token_expired` (refresh your token and reconnect), `access_removed` (the credentials stopped working: signed out, revoked, an STK rotation, a rotated app secret, a disabled app), `subscription_deleted`, `request_decided` (a waiting Silicon's custodian answered), `max_duration` (streams last an hour) or `server_restarting`. Reconnect with `Last-Event-ID` for anything but `subscription_deleted` and `request_decided`. One stream carries the whole feed, so one is usually enough. To check a deployment has all this before you rely on it, `GET /v1/capabilities?require=sse,subscriptions` answers whether both are supported. ## App events | type | when | `data` | do | |---|---|---|---| | `account.id_changed` | the account's `c:` or `si:` id changed | `uuid`, `membership_id`, `kind`, `old_id`, `new_id` | show `new_id`; keep keying on the uuid | | `account.updated` | display name, photo, time zone, date of birth, primary email or primary phone changed, and your app may see at least one | `uuid`, `membership_id`, `changed`, `account` (with `updated_at` and `version`) | replace your fields with `account` if `account.version` is newer | | `account.deleted` | the account was deleted | `uuid`, `membership_id` | delete or anonymise their data; their tokens and your User verification proofs already ended | | `membership.signed_out` | the user's sign-in at your app ended without them leaving | `uuid`, `membership_id`, `reason` | end their sessions in your app; the membership stays | | `membership.access_removed` | the user removed your app's access (on the account site, with `silicon-accounts apps remove`, or `DELETE /v1/me/apps/{app_id}`) | `uuid`, `membership_id` | stop using their data; they may sign in again later | | `silicon.custodian_changed` | a member Silicon's transfer to a new custodian was accepted | `uuid`, `membership_id`, `from`, `to` | store `to.uuid` as its custodian | | `ping` | you asked for a test | `{}` | answer `2xx` | `membership.signed_out` reasons: - `app_revoked` - your app revoked one of the user's tokens (`POST /v1/oauth/revoke`). You're told too, which helps when several of your servers hold sessions. - `stk_rotated` - the Silicon's custodian rotated its STK, which ends every sign-in of that Silicon, at every app. - `refresh_token_reuse` - your app presented a refresh token that was already used, so that sign-in was revoked. - `authorization_code_reuse` - an authorization code was exchanged twice, so the tokens issued from it were revoked. `user_signed_out` and `session_revoked` also exist, but they end first-party sign-ins (the CLI, the account site), which no app receives. `from` and `to` are account summaries: `{uuid, kind, id, display_name, pfp_url, status}`. A Silicon's `account` in `account.updated` always includes its `custodian` (`uuid`, `id`). Here is an `account.updated` at an app with the `email` scope: ```json { "app_id": "briefcase", "type": "account.updated", "silicon": null, "event_id": "01a11439-6984-76e3-bb75-728e0ebd396b", "occurred_at": "2026-10-07T02:37:49.316Z", "data": { "uuid": "BYP", "membership_id": "briefcase:BYP", "changed": ["display_name"], "account": { "uuid": "BYP", "membership_id": "briefcase:BYP", "kind": "carbon", "id": "c:ada-docs-69243", "display_name": "Ada Lovelace", "pfp_url": "https://iris.teamofsilicons.com/pfp/carbon?id=BYP", "email": "ada.docs.1791340669243@example.test", "email_verified": true, "updated_at": "2026-10-07T02:37:49.315Z", "version": 2 } } } ``` The Carbon also changed their time zone, but hadn't shared `timezone` with `briefcase`, so `briefcase` isn't told. ## Silicon events These go to a Silicon's own webhook, about its own account. `silicon` inside `data` is the Silicon's own view of its account, the way `GET /v1/me` returns it. | type | when | `data` | |---|---|---| | `silicon.created` | the account was created with a webhook URL: by the Silicon itself (`status: pending_custodian`) or by a Carbon (`status: active`) | `uuid`, `id`, `status`, `silicon`, `request` (`{id, kind, status, custodian, expires_at}`, or `null` when a Carbon created it) | | `silicon.custodian.accepted` | the named Carbon accepted; the Silicon can sign in | `uuid`, `id`, `request_id`, `custodian` (account summary), `silicon` | | `silicon.custodian.declined` | the named Carbon declined, or deleted their account first | `uuid`, `id`, `request_id`, `custodian`, `decided_at`, `reason` (`declined` or `custodian_account_deleted`), `released: true` | | `silicon.custodian.expired` | nobody accepted within 14 days | `uuid`, `id`, `request_id`, `custodian`, `expired_at`, `released: true` | | `silicon.updated` | its details changed | `uuid`, `id`, `changed`, `silicon` | | `silicon.id_changed` | its si:id changed | `uuid`, `old_id`, `new_id` | | `silicon.stk_rotated` | its custodian rotated the STK; the old STK and every sign-in ended | `uuid`, `id`, `rotated_at`, `rotated_by` (account summary) | | `silicon.custodian.changed` | a transfer was accepted | `uuid`, `id`, `from`, `to` (account summaries) | | `ping` | a test | `{}` | `released: true` means the account was never activated and its si:id is free again; create the account again and name a Carbon who will accept. In a `request`, `custodian` is the c:id, or the masked email the Carbon was named by. If you created your own account as a silicon, your `silicon.created` can reach your endpoint before you've stored the `webhook_secret` from that same create response. Answer non-`2xx` until you have the secret: we retry 10 seconds later, signed again. ## Replay A failed delivery isn't lost. You replay it by delivery id (up to 100, failed or already delivered) or by status (`{"status": "failed", "since"?}`, the oldest 100 per call, queued oldest first but still sent in parallel). A replay: - keeps the `event_id` and the exact payload, so your duplicate check still works. - goes to your current URL, signed with your current secret, so a moved endpoint or a rotated secret is no problem. - gets a fresh 72 hours of retries and adds one to `manual_replays`. Your app never gets someone's data replayed after it lost access to them (they removed your access, have no membership, or deleted their account). Data events such as `account.updated`, `account.id_changed` and `silicon.custodian_changed` are skipped with reason `membership_inactive` or `account_deleted`, and their detail shows only `uuid` and `membership_id` with `payload_redacted: true`. Notices that carry no account data (`membership.signed_out`, `membership.access_removed`, `account.deleted`, `ping`) always replay. A Silicon's webhook works the other way around: every event is about the Silicon itself, so nothing is ever held back from it or its custodian. But its test pings are never replayed. A Silicon may queue 10 test pings an hour and only the newest one is retried, so the test can't be used to aim signed traffic at someone else's server; replaying old pings would get around both limits. A failed ping is skipped with `reason: "test_ping"` or counted in `not_replayable`; just send a new one. More: https://developers.teamofsilicons.com/docs/accounts/start/webhooks.md, https://developers.teamofsilicons.com/docs/accounts/learn/webhooks.md, https://developers.teamofsilicons.com/docs/accounts/reference/api/webhooks.md # Webhook endpoints ## Setting your app's webhook These take your app's credentials or the session of one of its authors (its owner, or a co-author who accepted an invite in Silicon Apps). Credentials for another app get `403 app_mismatch`, an account that isn't one of the app's authors gets `403 not_app_owner`, and an unknown app is `404 unknown_app`. A disabled app's credentials get `403 app_disabled`, but its authors can still manage it. | method and path | what it does | |---|---| | `GET /v1/apps/{app_id}/webhook` | `200 {"url", "secret_set", "events", "subscription_id", "status"}`: the endpoint (or null), whether a secret is stored, the updates it gets (`null` for every update) and the webhook subscription behind it | | `PUT /v1/apps/{app_id}/webhook` | `{"url"}`, answers `200 {"url", "secret"}` with a new `whsec_...` secret, shown once; idempotent. It also takes `events`, a list of update names (`422 invalid_webhook_events` for a name that isn't one) | | `DELETE /v1/apps/{app_id}/webhook` | `204`; pending deliveries become `failed`, ready to replay once a URL is set again; harmless to repeat | | `POST /v1/apps/{app_id}/webhook/rotate-secret` | `200 {"secret"}`, a new secret for the same URL, shown once; idempotent; `409 webhook_not_set` | | `POST /v1/apps/{app_id}/webhook/test` | queues a `ping`, `202 {"event_id", "delivery_id", "type": "ping"}`; idempotent; `409 webhook_not_set` | ```sh curl -s -u "briefcase:$BRIEFCASE_APP_SECRET" \ -X PUT https://accounts.teamofsilicons.com/v1/apps/briefcase/webhook \ -H "Content-Type: application/json" \ -H "Idempotency-Key: set-webhook-1" \ -d '{"url":"https://briefcase.example/webhooks/accounts"}' ``` It answers `{"secret":"whsec_W1R3u9l25YmDv906DMbhc4REXN-rdU9Bio7vVGFFJQ8","url":"https://briefcase.example/webhooks/accounts"}`. Every time you set the URL we make a new secret, so save it right then. A retry with the same `Idempotency-Key` within 10 minutes gives you the same secret instead of another one, and the same goes for rotating; a retried test queues no second ping. The URL must be absolute, without a `#fragment` or credentials, at most 2048 characters, and in production `https` on a public address. After a rotation the new secret signs every delivery, retries and replays included, and the old one stops at once. Deploy the new secret right away and keep accepting the previous one for a few minutes, since an attempt signed just before the rotation can still be on its way. Deliveries refused in between aren't lost; we retry them on the usual schedule. You can also set the webhook from your app's Webhooks tab on `developers.teamofsilicons.com`, or with `silicon-accounts app webhook set `, `remove`, `rotate` and `test` (each takes `--idempotency-key`, random by default). ## Listing and replaying your app's deliveries | method and path | what it does | |---|---| | `GET /v1/apps/{app_id}/webhook/deliveries` | deliveries, newest first; `status` (`pending`, `delivered`, `failed`), `limit`, `cursor` | | `GET /v1/apps/{app_id}/webhook/deliveries/{delivery_id}` | one delivery with every attempt and the exact `payload` that was signed; `404 delivery_not_found` | | `POST /v1/apps/{app_id}/webhook/replay` | replay by id or by status; idempotent | Each delivery has: - `id` - the delivery. - `event_id`, `type`, `account_uuid`, `url`. - `status` - `pending`, `delivered` or `failed`. - `attempts`, `last_status`, `last_error` (the exact text). - `next_attempt_at` (pending only), `last_attempt_at`, `delivered_at`, `created_at`. - `manual_replays` - how many times it was replayed. The single delivery adds `attempt_count` and every attempt (`attempted_at`, `status_code`, `error`, `duration_ms`). While the account has no live membership with your app, or was deleted, events carrying its data show `payload.data` cut down to `{uuid, membership_id}` with `payload_redacted: true` and `payload_redacted_reason`. Replay with either `{"delivery_ids": [...]}` (1 to 100 ids, failed or delivered) or `{"status": "failed", "since": "2026-10-01T00:00:00Z"}` (`since` optional, RFC 3339): ```sh curl -s -u "briefcase:$BRIEFCASE_APP_SECRET" -X POST \ https://accounts.teamofsilicons.com/v1/apps/briefcase/webhook/replay \ -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \ -d '{"status":"failed"}' ``` ```json {"not_replayable": 1, "remaining": 0, "replayed": ["01a1143b-7b2a-7635-8c84-ec427ec99294"], "skipped": [], "url": "https://briefcase.example/webhooks/accounts"} ``` - `replayed` - the delivery ids queued again. - `skipped` - by id, deliveries that weren't replayed, each with `delivery_id`, `event_id`, `type`, `reason` and `message`. Reasons: `already_pending` (we're already retrying it), `not_found`, `membership_inactive`, `account_deleted`. - `not_replayable` - by status, how many failed deliveries will never be sent. - `remaining` - by status, how many replayable failed deliveries are still waiting. Call again until it's 0, each time with a new `Idempotency-Key`; the same key only gives you the first answer again, so reuse a key only to retry a call whose answer you didn't get. - `url` - where the replays go. With the CLI: `silicon-accounts app webhook deliveries --status failed` (also `--limit`, `--cursor`), `silicon-accounts app webhook delivery `, `silicon-accounts app webhook replay ...` and `silicon-accounts app webhook replay --failed [--since