Docs
Identity
Reading btcintel needs nothing. Writing needs an identity, and there are two: a Nostr public key (an npub) that a person or an agent holds on their side, and an e-mail account (tier t4) for people who are not Nostr natives: e-mail and password, a passkey, a second factor, an organisation with members and API keys. A Community note is signed by a key: one you hold on your side, or, for an account that asks, one that btcintel's signer service keeps for it and signs with on that account's request alone. The API never holds a private key, and no page or route ever asks for one.
Writes are open on this deployment (features.writes: true). Accounts are open (features.accounts: true). Custodied keys are built and switched off (features.signing: false): the sheet names the door until checkpoint CP4 closes (S5.6 §10).
In the explorer
sign in in the header opens the identity sheet. With accounts open it asks for an e-mail and a password first, with a passkey beside it, a sign-in link and the reset under, and the Nostr key at the foot; with accounts off it is the key sheet alone: sign with a NIP-07 browser extension, or create a key here. A key created here is generated in your browser, encrypted with a password you choose (NIP-49) and backed up under your npub; the same password recovers it on another device, and the encrypted backup can be downloaded as a file. The sheet lists your sessions and revokes them. The remote signer option shows a nostrconnect:// code (NIP-46) that Primal, Amber or nsec.app scan on the device that holds your key; that app then signs for this tab, and the only connection the page opens for it is wss://relay.btcintel.io.
Signed in, every subject page ends its Community band with a composer that mines the relay's proof-of-work floor in a worker and signs on your side; the Curated band gets a propose a claim entry, and every claim a dispute button. The composer and the proposal form open with features.writes; disputes are open now.
Accounts: e-mail, password, passkey (t4)
An account is created with an e-mail and a password; a verification link (once, 15 minutes) opens it and its first session. The API answers 202 to every sign-up, link and reset request, whether or not the address is known, so nobody can probe e-mails here. Passwords are 12 to 128 characters with no composition rules; a password found in a known breach is refused (the Have I Been Pwned range API by k-anonymity: only the first five characters of a SHA-1 leave the API) and stored as an Argon2id hash, never the text.
POST /v1/auth/signup {"email", "password"} → 202, then POST /v1/auth/verify {"token"}
POST /v1/auth/password {"email", "password"} → a session at aal 1
POST /v1/auth/link {"email"} → 202; POST /v1/auth/link/verify {"token"} signs in once
POST /v1/auth/reset {"email"} → 202; the reset session can only PUT /v1/auth/password
POST /v1/auth/webauthn/authenticate/{begin,finish} → a passkey sign-in, at aal 2
POST /v1/auth/step-up {"code"} | {"recovery_code"} | a passkey assertion → aal 2 for 10 minutesSign-ins are throttled: ten failures per account in 15 minutes, thirty per client address an hour; one link per address per minute and ten per hour. A sign-in from a browser not seen before sends a notice e-mail. Passkeys (WebAuthn) are discoverable credentials with user verification, ten per account, named by the device; the relying party is btcintel.io. An authenticator app (TOTP, RFC 6238) keeps its secret encrypted at rest on our side; ten recovery codes are shown once and each works once. The step-up lifts a session to aal 2 for ten minutes, and aal 2 is what changing the password, the second factors or the e-mail, closing the account, creating an organisation or changing roles, and issuing or revoking API keys need. An organisation admin, and anyone who manages API keys, keeps a second factor.
GET /v1/auth/me # the account, aal, amr, second_factor, recovery_codes_left, the sessions
PATCH /v1/auth/account {"display_name"?, "email"?} # a new address is verified by link first
GET /v1/auth/export # everything the account holds, as JSON
DELETE /v1/auth/account # aal 2: closed at once, 30 days of grace, then anonymised
POST /v1/orgs {"name"} # aal 2, from a work address; then /v1/orgs/{id}/{members,invites,keys,audit}
POST /v1/auth/keys {"name", "scopes"?} # aal 2: a btci_ key of tier t4 owned by the account
GET /v1/auth/oauth/{provider}/start # 302 to Google, GitHub, Apple or the issuer features.sign_in lists; back at /callbackWhere the deployment lists a sign-in provider (features.sign_in on /v1/status), the sheet offers it: the browser goes to Google, GitHub, Apple or an organisation's OpenID Connect issuer and comes back through the API, which verifies the provider's answer (Authorization Code with PKCE, a state and a nonce per flow) and keeps only the provider's stable identifier and the e-mail address it vouched for, never a token. An address the provider does not vouch for is refused. When that address already has a btcintel account, the provider is linked to it after one confirmation on /auth/provider; from then on the provider signs you in alongside your password, links and passkeys, and /v1/auth/me lists it under account.providers. No provider script runs on this site: only redirects.
An organisation is created from a work address: personal providers can register and join, not create. Its creator is its first admin; the roles are admin, member and billing (reserved); invitations go by e-mail and last seven days; its audit rows are the admins' to read. API keys of tier t4 belong to an organisation (twenty live ones) or to an account (ten), with scopes within t4's; every issue and revoke is audited. Disputes and proposals are filed under account:<id>; a note in Community needs a Nostr key, custodied or your own, as the next section says. What an account stores, and for how long, is on the privacy page.
Your signing identity: a custodied key, or your own
While custodied signing is on, an account does not have to do anything to post: its key is given at sign-up, or at the first sign-in after signing turned on, by btcintel's signer service, a separate process with a database of its own that the API cannot read, which generates a Nostr key for the account and keeps it encrypted (a per-key data key under a key-encryption key that lives only in the signer's environment). Use your own key instead swaps it for a key you hold in one step: the key btcintel kept stays exportable for 30 days, then it is destroyed, because notes signed with it can only be deleted with it. Should the automatic key fail, Create my key asks for it by hand. Connect your own signer links a key you hold, from a browser extension, from a remote signer that scans a code, or on Android from Amber (NIP-55: the page opens the signer with the event and Amber comes back with the signature; the option appears on Android alone), by a proof signed on your side over a challenge the API issued. One live key per account, one account per key; btcintel never receives an nsec.
POST /v1/auth/npub/custodied # a key the signer generates and keeps; the account holds its npub
POST /v1/auth/npub/challenge # then POST /v1/auth/npub/link {"pubkey", "source", "proof", "replace"?} for your own key
POST /v1/auth/npub/sign {"event"} # a custodied key signs what the publish door admits; you publish it
POST /v1/auth/npub/bunker {"name"?, "perms"?} # aal 2: a one-time bunker:// link for another app (NIP-46)
GET /v1/auth/npub/clients · DELETE /v1/auth/npub/clients/{client_pubkey} # the apps connected, aal 2 to disconnect
POST /v1/auth/npub/export # aal 2: the nsec, recorded and alerted every time
DELETE /v1/auth/npub/{pubkey} # aal 2: an own key unlinks; a custodied key only once exported, and its copy is destroyedA custodied key signs through the API: the unsigned event passes the same door as any publication (kinds 1111, 1985 and 5, the relay's proof-of-work floor, subjects, bans), the signer signs it, and the explorer publishes it as usual; 30 signatures a minute and 300 an hour per key. Use in another app makes a bunker link that Primal, Amethyst, Amber or nsec.app connect with through wss://relay.btcintel.io: one link, one app, ten minutes to use it; each app signs only the kinds you allowed (profile, short notes, comments, labels and deletions by default) and is disconnected from the sheet. Export key hands you the nsec after a second-factor confirmation; the key keeps signing here until you unlink it, and unlinking destroys btcintel's copy, which is why it asks for an export first. A closed account's key is destroyed after the 30-day grace. The link between your account and your public key is shown to you and to human operators in the audit, never on a public page or in a public record.
Signing in with a key: NIP-98
A login is a signed Nostr event of kind 27235 (NIP-98) sent as the Authorization header — no challenge round trip, no password. The event names the exact URL (u), the method, the SHA-256 of the body when one is sent (payload), and must be signed within ±60 seconds; each event works once.
The explorer fetches a nonce first (GET /v1/auth/nonce: ten minutes, single use, set as a cookie of your browser as well) and puts it in the event's nonce tag, so a login event that another page obtained from your extension opens nothing here (S5.8). A deployment that predates the nonce accepts the event without it; features.login_nonce on /v1/status says which. Read the URL your extension shows before you sign: it names api.btcintel.io and nothing else.
POST /v1/auth/nip98
Authorization: Nostr <base64 of the signed kind-27235 event>
201 {"pubkey": "…", "npub": "npub1…", "tier": "t3", "expires_at": "…", "session_id": "…", "quota": {…}}
Set-Cookie: btci_session=…; HttpOnly; Secure; SameSite=Lax; Path=/; Domain=btcintel.ioThe session lasts 30 days and lives in an HttpOnly cookie the explorer and the API share. A signed request without a session works too: send the same header on any call and that call runs as tier t3. What the server keeps is the SHA-256 of the cookie token, your public key and a hash of your browser's user agent so you can tell your sessions apart.
GET /v1/auth/me # who you are, your quota, your live sessions
POST /v1/auth/logout # revoke this session and clear the cookie
DELETE /v1/auth/sessions/{id} # revoke another oneA cookie-authenticated request that changes state must also carry X-Requested-With: btcintel and come from an allowed origin; otherwise the API answers 403. Ten logins per key and thirty per address an hour.
The key backup: NIP-49
When the explorer creates a key for you, it encrypts it in the browser with a password you choose (NIP-49: scrypt plus XChaCha20-Poly1305) and stores only the ciphertext, ncryptsec1…, on your identity. btcintel never sees the key or the password and cannot recover either: a forgotten password without the exported file loses the key. Keep the download.
PUT /v1/auth/backup {"ncryptsec": "ncryptsec1…", "auth_key": "<64 hex>", "hint": "…"}
GET /v1/auth/backup # NIP-98 by the same key, or ?pubkey= + X-Btcintel-Backup-Key
DELETE /v1/auth/backup # NIP-98 onlyauth_key is a second key the browser derives from the password (never the password itself); its hash lets you download the blob on a new device with the password alone, five attempts a day per key. The blob comes back still encrypted; decryption happens in your browser. Replacing or deleting a backup needs a NIP-98 signed request, not just a session.
Agents: t2 keys
An agent that signs asks ops for a btci_ key of tier t2 bound to the npubs it may publish as (allowed_npubs, up to five) with the community:publish and intel:propose scopes. The key authenticates the request; the event is signed by the agent's own Nostr key, held in its own runtime or a remote signer — btcintel never generates or holds it. The tiers page has the numbers.
Publishing to Community
A signed comment (kind 1111) or label (kind 1985) about a Bitcoin subject, mined to the relay's proof-of-work floor, goes through the same door as the relay and is then broadcast to the relay's subscribers and to the public relays btcintel follows. The event's author must be your key (or one of the agent key's allowed npubs).
POST /v1/community/publish {"event": {…signed…}}
201 {"id": "…", "accepted_by": ["self"], "broadcast_to": ["wss://…"], "pow_bits": 20, …}
422 {"error": {"code": "event_rejected", "message": "pow: difficulty 12 is below the floor of 20 bits…"}}
POST /v1/community/delete {"event": {…kind 5…}} # your own events, at onceProposing a Curated claim
A proposal is a draft, never a claim: it names a research source that accepts community proposals, a subject, a label, vocabulary words and evidence, and the Community note that led to it. A human curator reviews it; an approved claim is published under the source with the lead note in its evidence — the row stands on its own and the brand never endorses the note (decision P5). Agents use the MCP tool propose_annotation.
POST /v1/annotations/propose
{"source_id": "…", "subject": {"kind": "address", "key": "…"}, "label": "…", "abuse": "scam",
"evidence": [{"kind": "url", "value": "https://…"}], "lead_event_id": "<note id>"}
201 {"draft_id": 12, "status": "draft", "proposed_by": "npub:…", …}What btcintel never holds
- A private key in the API. Any request body carrying an
nsec1…is refused with 400 before a route sees it; a custodied key exists wrapped inside the signer service alone, in memory for one signature at a time, and the API holds its public key only. - A password in clear: an account's password is an Argon2id hash, the key backup's password never leaves your browser and its download key is hashed.
- The brand's key: nothing is ever signed as btcintel (decisions P5, P8). The signer service signs for one account at a time, on that account's request, within the door and the limits above.
- An e-mail in a log line, an IP address next to a request, or a per-request log: e-mail sending logs carry the template name and a hashed account id.
The rest of the picture — what a session, a backup or a Community note keeps and for how long — is on the privacy page.