# Snapie Auth — LLM Integration Reference # Version: 1.7 | Updated: 2026-07-29 # Support: https://discord.gg/CgJP7t7nWy # Base URL: https://auth.snapie.io/api Snapie Auth is a Hive blockchain account gateway and signing proxy. It authenticates users via Google OAuth, Sign in with Apple, or email/password, creates custodial Hive accounts, and signs Hive operations on behalf of users. ## Authentication Model All user-facing endpoints require a session cookie (`snapieauth_session`) set by the server after login. State-mutating endpoints also require the `x-csrf-token` header, read from the JS-accessible `snapieauth_csrf` cookie. There is NO Bearer token for user APIs. The Internal API (service-to-service) uses `Authorization: Bearer `. Always include `credentials: 'include'` in fetch calls. ## Key Concepts CUSTODIAL: Snapie generates all 4 Hive keypairs server-side (owner, active, posting, memo), derived from a single master password via Hive's fromLogin. The master password and keys are AES-256-GCM encrypted with a server-derived key. The user never sees or holds them until voluntary emancipation, at which point both the master password and all 4 keys are returned. EMANCIPATED: User holds their own keys. Snapie only has posting authority (granted by the user). Active-level ops return: { needsClientSigning: true, unsignedOp: [...], account: "alice", keyType: "active" } Sign-message returns: { needsClientSigning: true, message: "...", account: "alice", keyType: "posting" } The consuming app should hand these fields directly to Aioha or Keychain. SPONSOR TOKEN: A one-time token tied to an email hash. When redeemed, it bypasses the daily free-account quota (global + per-IP) and the "previously had account" restriction. The account still consumes one ACT. Issued by admins via /admin.html or the Admin API, or by external apps via the Internal API. EMANCIPATION THRESHOLD: Configurable USD value. When a custodial account exceeds this value, GET /emancipate/status and GET /auth/me report emancipationRequired: true as a UI nudge — this is informational only and does not block any action server-side. Set EMANCIPATION_THRESHOLD_USD=0 to disable. EMANCIPATION HOLD: Abuse-prevention measure. Custodial accounts cannot emancipate until EMANCIPATION_MIN_AGE_DAYS (default 30) has passed since creation — POST /emancipate/start returns 403 account_too_new with an eligibleAt timestamp until then. Unlike the threshold above, this DOES block the request. Check ageGateMet / eligibleAt from GET /emancipate/status. ACCOUNT DISABLE: Admins can disable an account for abuse via the Admin API (POST /admin/users/:userId/disable). The user can still log in, but every transacting endpoint (/hive/*, /emancipate/start) returns 403 account_disabled with an optional reason field. Check user.disabled / user.disabledReason. FREE ACCOUNT QUOTA: By default 10 accounts/day globally and 2/IP/day. Check remaining slots with GET /quota (public, no auth). Sponsor tokens bypass this limit entirely. ACCOUNT CREATION FLOW: 1. Check GET /quota — if remaining > 0, user can proceed (or if sponsored/paid) 2. POST /account/create → returns jobId 3. Poll GET /account/job/:jobId every 3-5 seconds 4. Status: pending → broadcasting → acked → confirmed (or failed/expired) 5. On confirmed: user.hiveUsername is set PAID ACCOUNT CREATION FLOW (no KYC): Account creation fee is always the live witness median — never hardcoded. Two payment rails, both deposit HIVE to the operator's account: HIVE payment: 1. GET /payment/fee → { amountHive, amountUsd } 2. POST /payment/hive-intent → { memo, receivingAccount, amount, expiresAt } 3. User sends exactly `amount` HIVE to `receivingAccount` with `memo` as transfer memo 4. Poll GET /payment/intent/:memo every 3-5 seconds 5. On confirmed: POST /account/create proceeds (paid flag bypasses daily quota) Lightning payment (via v4v.app — converts sats → HIVE, sends to same account): 1. GET /payment/fee → { amountHive, amountUsd } 2. POST /payment/lightning-intent → { memo, invoice, amountUsd, expiresAt } 3. Display `invoice` as QR code for user's Lightning wallet 4. Poll GET /payment/intent/:memo every 3-5 seconds 5. On confirmed: POST /account/create proceeds Intent statuses: pending | confirmed | expired (TTL 30 min) ## Quick Start Integration ```javascript // 1. Check quota before sending users to register const quota = await fetch('/api/quota').then(r => r.json()) // { total: 10, used: 3, remaining: 7, resetsAt: "2026-06-09T00:00:00.000Z" } if (quota.remaining === 0) { // show "no slots today — request a sponsor invite or try tomorrow" } // 2a. Google login — returns { user: SnapieUser } directly, no extra /auth/me needed async function loginWithGoogle(idToken) { const res = await fetch('/api/auth/google', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ credential: idToken }) }) const { user } = await res.json() // user.hiveUsername is null until account is created return user } // 2b. Apple Sign-In — identityToken from ASAuthorizationAppleIDCredential. // IMPORTANT: iOS only provides name on the very first sign-in. Pass it then; omit on subsequent logins. // The server preserves the stored name if name is absent. async function loginWithApple(identityToken, name = null) { const res = await fetch('/api/auth/apple', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identityToken, name }) // name: { firstName: "John", lastName: "Doe" } | null }) const { user } = await res.json() return user } // Email login works the same way async function loginWithEmail(email, password) { const res = await fetch('/api/auth/email/login', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }) if (res.status === 403) throw new Error('email_not_verified') if (!res.ok) throw new Error((await res.json()).error) const { user } = await res.json() return user } // Call GET /auth/me only when you need email or account value info, // or to re-validate an existing session on page load. async function getMe() { const res = await fetch('/api/auth/me', { credentials: 'include' }) if (!res.ok) return null return (await res.json()).user } // 3. Read CSRF token function getCsrf() { const m = document.cookie.match(/snapieauth_csrf=([^;]+)/) return m ? decodeURIComponent(m[1]) : null } // 4. Authenticated API call async function api(method, path, body) { const res = await fetch(`/api${path}`, { method, credentials: 'include', headers: { 'Content-Type': 'application/json', 'x-csrf-token': getCsrf() }, body: body ? JSON.stringify(body) : undefined }) return res.json() } // 5. Create a custodial Hive account const { jobId } = await api('POST', '/account/create', { username: 'alice', custodyMode: 'custodial' }) // 6. Poll until confirmed let job do { await new Promise(r => setTimeout(r, 3000)) job = await api('GET', `/account/job/${jobId}`) } while (!['confirmed', 'failed', 'expired'].includes(job.status)) ``` ## Internal API — Service-to-Service All /api/internal/* endpoints require: Authorization: Bearer ### Provision an account (e.g., to sell/gift accounts) POST /api/internal/provision-account Body: { username, custodyMode, linkedEmail?, returnKeysOnce?, note? } Returns: { jobId, userId } Poll: GET /api/internal/jobs/:jobId If returnKeysOnce=true and status=confirmed: returns keys exactly once, then wipes. ### Issue a sponsor token (e.g., reward a user in an external app) POST /api/internal/issue-sponsor-token Body: { email, note?, expiresInDays? } Returns: { tokenId, email, expiresAt } The token is redeemed automatically when the user registers with that email and calls POST /api/account/create. They bypass the daily quota and the "previously had account" restriction — but one ACT is still consumed. ## SnapieUser Object Login endpoints (POST /auth/google, POST /auth/email/login) return this directly in the response body — no separate GET /auth/me call needed after login: ``` { id: string — MongoDB ObjectId as hex string name: string|null picture: string|null — URL (Google accounts only) hiveUsername: string|null — null until account is created custodyMode: "custodial"|"emancipated"|null isAdmin: boolean disabled: boolean — true if an admin disabled this account for abuse disabledReason: string|null — set only when disabled is true } ``` GET /auth/me returns the same shape plus: email: string|null — from session JWT (cosmetic, not stored) accountValueUsd: number|null — only present when hiveUsername + custodial emancipationRequired: boolean — only present when above applies (informational, see EMANCIPATION THRESHOLD) Note: after a fresh login, hiveUsername is null until the user creates a Hive account via POST /account/create. Always check hiveUsername before using it. ## Endpoints Summary ### Public (no auth) GET /health → { ok: true } GET /quota → { total, used, remaining, resetsAt } GET /public-config → { googleClientId, hiveNetwork, discordUrl } GET /payment/fee → { amountHive, amountUsd } POST /auth/google → session cookies set + { user: SnapieUser } POST /auth/apple → session cookies set + { user: SnapieUser } POST /auth/email/register → 202 { pending: true } — sends verification email POST /auth/email/login → session cookies set + { user: SnapieUser } POST /auth/email/resend → 200 { ok: true } GET /auth/email/verify → redirect to /manage.html (email verification link) ### Session required (cookie) GET /auth/me → { user: SnapieUser + email + accountValueUsd? + emancipationRequired? } POST /auth/logout → clears cookies GET /account/eligibility → { canCreate, canLinkExisting, reason, sponsored? } GET /account/check-username/:u → { available, reason? } POST /account/create → { jobId, sponsored } [CSRF] GET /account/job/:jobId → { status, txId, error } GET /account/value → { hiveBalance, hbdBalance, vestingShares, hiveValueUsd, hbdValueUsd, vestingValueUsd, totalValueUsd, emancipationRequired } POST /payment/hive-intent → { memo, receivingAccount, amount, amountUsd, expiresAt } (10/hr) POST /payment/lightning-intent → { memo, invoice, amountUsd, expiresAt } (10/hr) GET /payment/intent/:memo → { memo, type, status, txId, amountHive, amountUsd, expiresAt } POST /link/verify-challenge → { challenge, snapieAccount } [CSRF] POST /link/confirm → { ok, hiveUsername } [CSRF] POST /hive/sign-message → custodial: { signature, account } emancipated: { needsClientSigning: true, message, account, keyType: "posting" } [CSRF] POST /hive/limit-order-create → custodial: { txId } emancipated: { needsClientSigning: true, unsignedOp, account, keyType: "active" } [CSRF] POST /hive/limit-order-cancel → custodial: { txId } emancipated: { needsClientSigning: true, unsignedOp, account, keyType: "active" } [CSRF] POST /hive/broadcast → { txId } (posting ops; always proxied for all users) [CSRF] POST /hive/transfer → custodial: { txId } emancipated: { needsClientSigning: true, unsignedOp, account, keyType: "active" } [CSRF] POST /hive/transfer-to-savings → custodial: { txId } emancipated: { needsClientSigning: true, unsignedOp, account, keyType: "active" } [CSRF] POST /hive/transfer-from-savings → custodial: { txId } emancipated: { needsClientSigning: true, unsignedOp, account, keyType: "active" } [CSRF] POST /hive/power-up → custodial: { txId } emancipated: { needsClientSigning: true, unsignedOp, account, keyType: "active" } [CSRF] POST /hive/power-down → custodial: { txId } emancipated: { needsClientSigning: true, unsignedOp, account, keyType: "active" } [CSRF] POST /hive/delegate → custodial: { txId } emancipated: { needsClientSigning: true, unsignedOp, account, keyType: "active" } [CSRF] POST /hive/convert → custodial: { txId } emancipated: { needsClientSigning: true, unsignedOp, account, keyType: "active" } [CSRF] POST /hive/collateralized-convert → custodial: { txId } emancipated: { needsClientSigning: true, unsignedOp, account, keyType: "active" } [CSRF] POST /hive/claim-rewards → { txId } (posting-level; always proxied) [CSRF] GET /emancipate/status → { custodyMode, accountValueUsd, threshold, forcedEmancipation, minAgeDays, eligibleAt, ageGateMet } POST /emancipate/start → { keys: { owner, active, posting, memo }, masterPassword } [CSRF] masterPassword: single secret all keys derive from (Hive fromLogin); null for pre-master-password accounts 403 account_too_new (+ eligibleAt) if ageGateMet is false; 403 account_disabled if the account was disabled ### Internal (Bearer token) POST /internal/provision-account → { jobId, userId } GET /internal/jobs/:jobId → { status, ...keys? } POST /internal/issue-sponsor-token → { tokenId, email, expiresAt } ### Admin (session + isAdmin required) GET /admin/stats → { actCount, pendingJobs, userCount, incompleteOnboarding, heldAccounts } GET /admin/jobs → { jobs: [...] } POST /admin/claim-act → { ok, txId } [CSRF] GET /admin/sponsor-tokens → { tokens: [...] } POST /admin/sponsor-tokens → { tokenId, emailSent, emailError? } [CSRF] Body: { email, note?, expiresInDays?, sendEmail? } sendEmail=true sends an invite email even if user hasn't registered yet. DELETE /admin/sponsor-tokens/:id → { ok: true } [CSRF] GET /admin/admins → { admins: [{ userId, name, provider, createdAt }] } POST /admin/admins → { userId, name, provider } [CSRF] Body: { email } — user must already be registered DELETE /admin/admins/:userId → { ok: true } [CSRF] Cannot demote yourself. GET /admin/users?q= → { users: [{ userId, hiveUsername, name, provider, custodyMode, createdAt, disabled, disabledReason, disabledAt }] } Search by Hive username (prefix) or name, case-insensitive, up to 25 results. GET /admin/held-accounts → { minAgeDays, users: [{ ...same shape, eligibleAt }] } Custodial accounts still inside the emancipation hold window — abuse-review queue. POST /admin/users/:userId/disable → { userId, disabled: true, disabledReason, disabledAt } [CSRF] Body: { reason } — required, 1-500 chars. User can still log in; all transacting blocked. POST /admin/users/:userId/enable → { userId, disabled: false, disabledReason: null, disabledAt: null } [CSRF] ## Error Format All errors: HTTP 4xx/5xx with body { "error": "error_code_string" } Common codes: unauthorized — no valid session admin_required — endpoint requires isAdmin csrf_invalid — missing or wrong x-csrf-token already_linked — user already has a Hive account linked previously_had_account — user used their free account already (sponsor token overrides) global_daily_limit — daily free account quota exhausted (resets midnight UTC) ip_daily_limit — per-IP daily quota exhausted no_acts — Snapie has no pre-claimed account tokens invalid_username — format or on-chain conflict insufficient_rc — HTTP 503; account has no Resource Credits; RC recharges over time For posting ops: the Snapie service account is out of RC (operator issue) For active ops: the user's own account is out of RC emancipation_required — account value exceeded threshold; user must export keys account_too_new — HTTP 403; custodial account hasn't reached EMANCIPATION_MIN_AGE_DAYS yet (response includes eligibleAt); blocks POST /emancipate/start account_disabled — HTTP 403; admin disabled this account for abuse (response includes reason if given); blocks all /hive/* and /emancipate/start not_custodial — action requires custodial mode user_not_found — no account with that email (admin: grant admin) cannot_demote_self — admin tried to revoke their own admin access ## Apple Sign-In (/auth/apple) POST /api/auth/apple Body: { identityToken: string, name?: { firstName: string, lastName?: string } | null } Returns: { user: SnapieUser } identityToken: JWT from ASAuthorizationAppleIDCredential.identityToken (iOS native SDK) name: ONLY send on the user's very first Apple sign-in. iOS only provides name/email once; on subsequent logins both are absent from the token. The server preserves the saved name automatically — sending null/omitting name on re-login does not wipe it. Server validates the token against Apple's JWKS (https://appleid.apple.com/auth/keys), checks iss=https://appleid.apple.com and aud=APPLE_CLIENT_ID (your app's Bundle ID). Apple may issue a relay email (e.g. xyz@privaterelay.appleid.com) — treat it as opaque. The stable unique ID is the `sub` claim in the identity token, stored as providerId. Environment variable: APPLE_CLIENT_ID = your Bundle ID, e.g. "io.snapie.app" Required by App Store: any app offering third-party login (Google, etc.) must also offer Sign in with Apple (App Store Review Guideline 4.8). ## Rate Limits (per IP) /api/auth/* 30 req / 15 min /api/auth/email/resend 5 req / 1 hr /api/account/create 5 req / 1 hr /api/account/check-* 40 req / 1 min /api/hive/sign-message 30 req / 1 min /api/hive/limit-order-create 10 req / 1 min /api/hive/limit-order-cancel 10 req / 1 min /api/hive/broadcast 60 req / 1 min /api/hive/transfer 10 req / 1 min /api/hive/transfer-to-savings 10 req / 1 min /api/hive/transfer-from-savings 10 req / 1 min /api/hive/convert 10 req / 1 min /api/hive/collateralized-convert 10 req / 1 min /api/emancipate/* 5 req / 1 hr ## Asset String Format All monetary amounts are strings — never plain numbers. HIVE / HBD: 3 decimal places + currency → "1.000 HIVE" "0.500 HBD" VESTS: 6 decimal places + currency → "1.000000 VESTS" Examples of WRONG formats: 1.0, "1", "1.0 HIVE", 1.000, { amount: 1, currency: "HIVE" } ## Active-Level Operations All /hive/* endpoints except /hive/broadcast and /hive/sign-message are active-level. All request body fields are camelCase. All endpoints require session cookie + x-csrf-token. Response pattern: Custodial → { "txId": "abc123..." } If the account value exceeds the emancipation threshold, the response also includes { "emancipationRequired": true, "accountValueUsd": 27.50 }. The operation succeeds regardless — this is a warning, not a block. Surface it as: "Your account value has grown. Consider exporting your keys." Emancipated → { "needsClientSigning": true, "unsignedOp": ["op_name", {...}], "account": "alice", "keyType": "active" } Client must sign unsignedOp via Keychain/AIOha and broadcast independently. account and keyType are provided so the app can call: Aioha: aioha.signAndBroadcastTx(account, [unsignedOp], KeyTypes.Active) Keychain: window.hive_keychain.requestBroadcast(account, [unsignedOp], 'active', cb) emancipationRequired is also returned by GET /auth/me and GET /account/value. Check it on page load so you can show a persistent banner without waiting for a transaction. All /hive/* endpoints (and /emancipate/start) return 403 account_disabled if an admin has disabled the account for abuse. Check user.disabled from GET /auth/me on page load to show this state proactively rather than waiting for a 403. ### POST /hive/transfer ```json { "to": "alice", "amount": "1.000 HIVE", "memo": "optional" } ``` to: required, 3–16 chars amount: required, "N.NNN HIVE" or "N.NNN HBD" memo: optional, max 2048 chars (default "") ### POST /hive/transfer-to-savings ```json { "amount": "1.000 HIVE", "to": "alice", "memo": "optional" } ``` amount: required, "N.NNN HIVE" or "N.NNN HBD" to: optional — defaults to the authenticated user's own account memo: optional, max 2048 chars (default "") No lockup on deposit. Savings earn interest. Withdrawals take 3 days. ### POST /hive/transfer-from-savings ```json { "amount": "1.000 HIVE", "to": "alice", "memo": "optional", "requestId": 1717200000 } ``` amount: required, "N.NNN HIVE" or "N.NNN HBD" to: optional — defaults to self memo: optional, max 2048 chars (default "") requestId: optional integer — auto-generated from Math.floor(Date.now()/1000) if omitted Must be unique per account. Re-using an id for the same account will fail on-chain. Funds arrive after 3 days. ### POST /hive/convert (HBD → HIVE) ```json { "amount": "1.000 HBD", "requestId": 1717200000 } ``` amount: required, "N.NNN HBD" only — NOT HIVE requestId: optional integer, auto-generated if omitted Takes ~3.5 days; settles at the median price feed at that time. ### POST /hive/collateralized-convert (HIVE → HBD, instant) ```json { "amount": "1.000 HIVE", "requestId": 1717200000 } ``` amount: required, "N.NNN HIVE" only — NOT HBD requestId: optional integer, auto-generated if omitted HBD delivered to wallet immediately. Excess collateral HIVE returned after ~3.5 days. ### POST /hive/limit-order-create (internal market trade) ```json { "sell": "1.000 HIVE", "receive": "0.450 HBD", "fillOrKill": false, "expiresInSeconds": 3600, "orderId": 1717200000 } ``` sell: required, "N.NNN HIVE" or "N.NNN HBD" receive: required, opposite currency to sell — "N.NNN HIVE" or "N.NNN HBD" fillOrKill: optional boolean (default false) false = resting limit order — sits on book until filled or expired true = instant market swap — cancels immediately if not fully fillable expiresInSeconds: optional integer, default 3600, max 2592000 (30 days). Ignored if fillOrKill=true. orderId: optional uint32, auto-generated from timestamp if omitted. Must be unique per account. IMPORTANT: a fillOrKill=true order that fails to fill still returns { txId } — the transaction went through but no swap occurred. To confirm a fill, verify account balances changed or check dhive client.database.findAccountOrders(username, 0, 10) returns no matching orderId. ### POST /hive/limit-order-cancel ```json { "orderId": 1717200000 } ``` orderId: required integer — must match an open order owned by the authenticated user. ### POST /hive/power-up ```json { "amount": "1.000 HIVE" } ``` Stakes HIVE as Hive Power. Amount must be "N.NNN HIVE" (3 decimals). ### POST /hive/power-down ```json { "amount": "1.000000 VESTS" } ``` Amount must be "N.NNNNNN VESTS" (6 decimals) — NOT HIVE. Unstaking takes 13 weeks. To convert HP → VESTS: vests = hp * total_vesting_shares / total_vesting_fund_hive (from dhive client.database.getDynamicGlobalProperties()) ### POST /hive/delegate ```json { "delegatee": "alice", "amount": "1.000000 VESTS" } ``` delegatee: required, target account username amount: required, "N.NNNNNN VESTS" — set to "0.000000 VESTS" to remove delegation ### POST /hive/claim-rewards No request body. Server reads pending rewards from the chain automatically. Returns { txId } on success. ## Posting-Level Broadcast (/hive/broadcast) Allowed op types: vote, comment, delete_comment, custom_json, claim_reward_balance Request body: { "op": ["op_type", { ...fields }] } The op field is a 2-element array — NOT { op: "comment", ...fields }, NOT [["comment", {...}]]. Example — root post: ```json { "op": ["comment", { "parent_author": "", "parent_permlink": "hive", "author": "alice", "permlink": "my-post-slug", "title": "My Post Title", "body": "Post content here.", "json_metadata": "{\"tags\":[\"hive\"],\"app\":\"myapp/1.0\"}" }] } ``` parent_author: "" for a root post; the replied-to author for a comment json_metadata: must be a JSON-encoded STRING, not an object author: must match the authenticated user's hiveUsername Example — reply: same as above but parent_author = "bob", parent_permlink = "their-post-slug", title = "" Example — vote: ```json { "op": ["vote", { "voter": "alice", "author": "bob", "permlink": "some-post", "weight": 10000 }] } ``` weight: -10000 to 10000 (10000 = 100% upvote, -10000 = full downvote, 0 = remove vote) Example — custom_json: ```json { "op": ["custom_json", { "required_auths": [], "required_posting_auths": ["alice"], "id": "myapp", "json": "{\"action\":\"follow\",\"target\":\"bob\"}" }] } ``` json: must be a JSON-encoded STRING, max 8192 bytes ## Message Signing (/hive/sign-message) ```json { "message": "your-challenge-string" } ``` message: required string, max 512 chars Custodial response: { "signature": "abc...", "account": "alice" } Emancipated response: { "needsClientSigning": true, "message": "...", "account": "alice", "keyType": "posting" } Signs with SHA-256 + secp256k1 (dhive cryptoUtils.sha256 + PrivateKey.sign). Compatible with hive-js / dhive verifySignature. Typical challenge-response flow: 1. Your app generates a nonce (UUID or timestamp string) 2. POST /api/hive/sign-message { message: nonce } 3. Verify: verifySignature(account, nonce, signature, onChainPostingPubKey) 4. On success: grant access Emancipated client signing: Keychain: window.hive_keychain.requestSignBuffer(account, message, 'Posting', cb) AIOha: aioha.signMessage(message, KeyTypes.Posting) The response always includes account and keyType so the app needs no additional context. ## Cookies snapieauth_session httpOnly, Secure, SameSite=Lax — JWT RS256 snapieauth_csrf Secure, SameSite=Lax (JS-readable) — CSRF token