Skip to content
Solana Devnet: test network. Documents and acceptances here are for testing, not production evidence.

Docs / Developers / SDK & API

SDK & API

Everything you need to integrate Stele: the widget for your pages, the REST API for your server, webhooks and the verifier. Server samples are in cURL, Node.js, Python, Java, Go, PHP, Ruby and C#: pick a language on any sample and every sample switches to it. New to Stele? Start with Get started.

Integration paths#

WhereUseFor
Your pagesstele.js (script tag), or @stelehq/sdk / @stelehq/sdk/react in bundled appsShowing your Terms and collecting signed acceptances
Your serverThe REST API from any language; on Node.js, @stelehq/sdk/serverLinking acceptances to your users, checking them, proofs
Your serverWebhooksBeing notified when something is recorded
Anyone@stelehq/verifier, stele-verify, the Verify pageChecking evidence against Solana, without Stele

Most integrations combine the widget with two or three server calls.

PackageUse it for
stele.js (script tag)The embeddable widget, with no installation
@stelehq/sdkThe widget for bundled apps (mount), plus lower-level building blocks
@stelehq/sdk/reactThe widget as a React component
@stelehq/sdk/serverNode.js server client: sessions, acceptances, proofs, signers, webhook verification
@stelehq/verifierIndependent verification of acceptances and documents, as a library or a command
bash
npm install @stelehq/sdk          # widget, React component and Node.js server client
npm install @stelehq/verifier     # only if you verify evidence yourself

Browser script (stele.js)#

Load the script once. Every element with data-stele-document becomes a widget, including elements added later by single-page apps.

html
<div data-stele-organization="acme.com" data-stele-document="terms-of-service"></div>
<script src="https://stele.site/sdk/v1/stele.js" defer></script>
Attribute
data-stele-organizationRequired. Your verified domain or your organization's address
data-stele-documentRequired. The document's slug
data-stele-themeauto (default), dark or light
data-stele-sessionAn acceptance session token from your server

The API and receipt addresses default to the origin the script was loaded from.

Events. Both bubble up to document.

Eventevent.detail
stele:accepted{ acceptanceId, transactionSignature, slot, message, receiptPath, signerType, fee }
stele:errorAn Error; read event.detail.message

A user who cancels in their wallet or passkey prompt sees a message in the widget; no event is sent and nothing is recorded.

JavaScript API. window.Stele provides:

Stele.mount(target, options)Renders a widget into an element or CSS selector. Returns { destroy() }
Stele.mountProof(target, { requestId, token })Renders a proof confirmation
Stele.scan()Mounts any new data-stele-document elements immediately

Call Stele.mount after the script has loaded (for example on DOMContentLoaded), on an element without data-stele-* attributes:

js
window.addEventListener("DOMContentLoaded", () => {
  const widget = Stele.mount("#terms", {
    organization: "acme.com",
    document: "terms-of-service",
    onAccepted: (result) => console.log(result.acceptanceId),
  });
  // widget.destroy() removes it
});

Options (Stele.mount, mount from @stelehq/sdk and the React props):

OptionType
organizationstringRequired. Verified domain or organization address
documentstringRequired. Document slug
apiBaseUrlstringStele API: https://stele.site/api. Set automatically by stele.js; required otherwise
appUrlstringWhere receipts open: https://stele.site. Set automatically by stele.js
theme"auto" | "dark" | "light"Default "auto"
sessionTokenstring | () => Promise<string | undefined>Acceptance session; a function is called when the user clicks Accept & sign
onAccepted(result) => voidSame data as stele:accepted
onError(error: Error) => voidSame as stele:error
rpcUrlsstring[]Solana RPC endpoints to verify against (default: those your Stele app advertises)
expected{ programId?, chainId? }Values to pin; loading stops if your Stele app advertises anything else
signer{ address, signMessage(bytes), walletName? }Your own wallet integration instead of the built-in wallet list

Styling. The widget renders in a shadow root: your CSS cannot break it and its CSS stays inside. Customize it with CSS custom properties on the host element: --stele-accent, --stele-on-accent, --stele-bg, --stele-fg, --stele-muted, --stele-border, --stele-surface, --stele-radius, --stele-font, --stele-text-height (height of the scrollable text), --stele-verified, --stele-warning, --stele-danger.

css
[data-stele-document] {
  --stele-accent: #6d28d9;
  --stele-radius: 8px;
}

Content Security Policy#

If your site sends a Content Security Policy, allow your Stele app and the Solana RPC endpoints the widget reads from (publicRpcUrls in GET /v1/network, or your own rpcUrls):

text
script-src  https://stele.site
connect-src https://stele.site <Solana RPC endpoints>
img-src     data:

img-src data: shows wallet icons. The widget's styles need no 'unsafe-inline'.

Bundled JavaScript and React#

The same widget for apps that bundle their own JavaScript. Bundling pins the widget's code to the version you reviewed.

import { mount } from "@stelehq/sdk";

const widget = mount(document.querySelector("#terms"), {
  apiBaseUrl: "https://stele.site/api",
  appUrl: "https://stele.site",
  organization: "acme.com",
  document: "terms-of-service",
  onAccepted: (result) => console.log(result.acceptanceId),
});

Both take the options above; the component also takes className and style for its host element. The lower-level pieces the widget is built from are exported for building your own interface:

Export
loadVerifiedDocument({ client, organization, document, rpcUrls?, expected? })Loads the current version and verifies it against Solana: content hash, fingerprint, chain. Throws IntegrityError on any mismatch
acceptDocument({ client, verified, signer, requestDomain, sessionToken?, onStatus? })Requests a challenge, rebuilds the message from verified chain data, blocks signing on any difference, asks the signer, checks the signature and submits it
watchWallets(chain, onChange), connectWallet(wallet, chain)Find the visitor's Wallet Standard wallets for a network (walletChain from /v1/network) and turn one into a signer
PasskeySigner, passkeySigningSupport()Walletless signing (passkeys)
SteleClientMinimal client for the public endpoints (new SteleClient({ apiBaseUrl }))

The SDK never sends cookies to Stele, so it behaves the same on every site.

REST API#

Base URL#

Your Stele app serves the API under /api/v1: https://stele.site/api/v1.

NetworkBase URL
Solana Devnetthis sitetesthttps://stele.site/api/v1
Solana MainnetNot available

Every Solana network has its own Stele app, with its own organizations, API keys, acceptances and webhooks. Build against a test network, then set the same things up on the Mainnet app; keep the base URL and API key in your configuration so each environment uses its own.

Requests and responses are JSON (UTF-8); timestamps are ISO 8601 in UTC unless a field says Unix seconds. Fields may be added to responses at any time, so ignore fields you don't know; breaking changes ship under a new version prefix. The OpenAPI 3 document, https://stele.site/api/v1/openapi.json, imports into Postman or Insomnia and generates a client for any language with OpenAPI Generator.

Authentication#

CallerHowFor
AnyoneNo authenticationPublic endpoints: documents, content, receipts, acceptance signing. Callable from any website
Your serverAuthorization: Bearer stl_test_…Acceptance sessions, acceptance queries, proofs, signers
The dashboardWallet sign-in session cookieManaging your organization

API keys. Create one under API keys → Create key: give it a name, pick its scopes and optionally an expiry. The key is shown once and Stele stores only a hash; revoke it any time. Keys start with stl_live_ on Mainnet and stl_test_ on test networks.

ScopeAllows
acceptances:writeCreating acceptance sessions
acceptances:readListing and reading acceptances
documents:readReading the latest version of a document
proofs:writeCreating proof requests (purchases, refunds, consents…)
proofs:readListing proofs and reading their receipts (which include the statements)
signers:readListing your passkey signers
signers:writeRetiring a signer after account recovery

Warning

API keys are for servers only. Never put them in browser code, mobile apps or public repositories. A key cannot sign or publish anything on Solana, which always requires your organization's wallet, but it can read your acceptances and proofs and create sessions and proof requests.

Client setup#

Every server sample on this page uses the client below: the base URL plus your API key, read from the STELE_API_KEY environment variable. Node.js uses the server SDK; the other languages need at most one common library. Each helper returns the parsed JSON and raises an error with the HTTP status and error body on failure.

# Every cURL sample reads your key from this variable
export STELE_API_KEY="stl_test_…"

Server SDK (Node.js)#

SteleServerClient wraps the endpoints below (Node.js 18 or later):

MethodScopeReturns
createAcceptanceSession({ document?, externalUserRef?, ttlSeconds? })acceptances:write{ sessionToken, expiresAt }
hasAcceptedLatest(externalUserRef, slug)documents:read, acceptances:readThe user's acceptance of the current version, or null
latestVersion(slug)documents:read{ organization, document, version }
listAcceptances({ externalUserRef?, document?, version?, signer?, from?, to?, limit?, cursor? })acceptances:read{ acceptances, nextCursor }, newest first
getAcceptance(acceptanceId)acceptances:read{ acceptance, receiptUrl }
createProofRequest({ type, reference, summary, details, policies?, relatesTo?, externalUserRef?, ttlSeconds? })proofs:write{ requestId, clientToken, statement, statementHash, expiresAt, confirmUrl }
listProofs({ type?, reference?, externalUserRef?, limit?, cursor? })proofs:read{ proofs, nextCursor }
getProofReceipt(proofId)proofs:read{ receipt, report }, verified against Solana
listSigners(externalUserRef?)signers:read{ signers }
revokeSigner(publicKey, reason)signers:writeRetires a signer (account recovery)

verifyWebhook(secret, signatureHeader, rawBody, toleranceSeconds = 300) checks a webhook's signature and age and returns the parsed event, or throws WebhookVerificationError. See Webhooks.

Network#

Which network and program your Stele app uses. Public; the widget reads it automatically.

bash
curl https://stele.site/api/v1/network
json
{
  "cluster": "mainnet",
  "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
  "label": "Solana Mainnet",
  "walletChain": "solana:mainnet",
  "programId": "6sTKscWmUtBQRMpNCiEpurCJxpjx7GK5rzk7e265kpwV",
  "relayer": "BLECAww6xiFLr89hgXg3eJwiN1VitX4ML8Z6yE8yPgMS",
  "attestor": "BMswzSJscjY62DAyYazdFf6uNeYd6QmQv7pa2figpCqB",
  "hostedAcceptDomain": "stele.site",
  "publicRpcUrls": ["https://api.mainnet-beta.solana.com"],
  "contentMirror": "https://stele.site/api/v1/public/content"
}

label is the network your users' wallets must use. programId and chainId are the values to pin with expected.

Documents#

Public, read-only information about published documents. The responses are claims: the SDK and the verifier check them against Solana before relying on them.

MethodPath
GET/v1/public/organizations/{domain or address}An organization and its published documents
GET/v1/public/organizations/{ref}/documents/{slug}A document with its full version history
GET/v1/public/versions/{address}One version with its document and organization
GET/v1/public/content/{sha256}The exact text of a version (canonical bytes, served only if they hash correctly)
GET/v1/public/content/{sha256}/parsedThe same content as parsed JSON
GET/v1/documents/by-slug/{slug}/latestThe current version of one of your documents (documents:read)

The current version of your document:

curl https://stele.site/api/v1/documents/by-slug/terms-of-service/latest \
  -H "Authorization: Bearer $STELE_API_KEY"
json
{
  "organization": "A1M7r3AyuQ91QNg1GBjs8zffXtV795BXmzm7HEH8V2f6",
  "document": "Hm77xC4schJ1o4eeZhniXBqKRaGfVhU25DYRiF9VX7YT",
  "version": {
    "address": "S7b7qpR354RyU3jFFi32PC7BMyUREdB6jviKdef2SJf",
    "number": 3,
    "label": "3.0",
    "title": "Terms of Service",
    "fingerprint": "db8942fcc55ab23410cfd5805c5719b37ec885df549629cd0fcb2c856555cf69",
    "effectiveAt": null
  }
}

version.address identifies the version on Solana; acceptances refer to it as versionAddress. effectiveAt is a Unix timestamp in seconds, or null when the version takes effect on publication.

Acceptance sessions#

Link an acceptance to a user of your app. Your server creates a session; the widget sends its token with the signing request; Stele stores your user ID with the acceptance, off-chain.

MethodPathAuth
POST/v1/acceptance-sessionsacceptances:write{ document?, externalUserRef?, ttlSeconds? } → { sessionToken, expiresAt }
curl https://stele.site/api/v1/acceptance-sessions \
  -H "Authorization: Bearer $STELE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "document": "terms-of-service", "externalUserRef": "user_12345" }'
json
{
  "sessionToken": "stls_8Myx1ippBc0WT-0hENTtRAhd8RyqETa6m-rM5sepApY",
  "expiresAt": "2026-10-03T10:35:58.355Z"
}
  • document: a slug or document address. If set, the session works only for that document.
  • externalUserRef: your user ID; up to 128 printable characters, no spaces. Not an email address: Stele doesn't need it.
  • ttlSeconds: 60–3600, default 900 (15 minutes).
  • A session is used up by one successful acceptance. Create one per page view and pass the token to the widget (data-stele-session, or the sessionToken option).

With Require acceptance sessions enabled (Organization → Acceptance policy), signing requests without a valid session are refused.

Acceptances#

MethodPathAuth
GET/v1/acceptancesacceptances:readYour acceptances, newest first (below)
GET/v1/acceptances/{acceptanceId}acceptances:readOne of your acceptances, with its receipt URL
GET/v1/public/acceptances/{acceptanceId}publicPublic summary of an acceptance
GET/v1/public/acceptances/{acceptanceId}/receiptpublicThe receipt, verified against Solana
GET/v1/public/transactions/{signature}/acceptancepublicThe acceptance ID recorded by a transaction
POST/v1/acceptance/challengespublicStart signing (used by the SDK)
POST/v1/acceptance/challenges/{id}/submitpublicRecord a signed acceptance (used by the SDK)
GET/v1/acceptance/challenges/{id}x-client-bindingStatus of a challenge

List acceptances#

Filters, all optional: externalUserRef, document (document address), version (version address), signer, from and to (ISO 8601, inclusive), limit (1–200, default 50). The response carries nextCursor: pass it as cursor for the next page; it is null on the last one.

curl "https://stele.site/api/v1/acceptances?externalUserRef=user_12345&limit=50" \
  -H "Authorization: Bearer $STELE_API_KEY"

# Next page: add the nextCursor value from the previous response
curl "https://stele.site/api/v1/acceptances?externalUserRef=user_12345&limit=50&cursor=$CURSOR" \
  -H "Authorization: Bearer $STELE_API_KEY"
json
{
  "acceptances": [
    {
      "acceptanceId": "4ba3b02722d744452be1a9837020dba10691cded7df67c332a3aa4c1527ccf05",
      "transactionSignature": "4Gv4TkZKnWS7gsSDpo9Uv9kWgCp1oo2A3kfk67mZW8KTgB8gMkHNyZfJjQQLNUoKBeE97S77EpjUeqNrUJBo5mi9",
      "slot": 11989,
      "blockTime": "2026-10-03T10:24:21.000Z",
      "signer": "ALrq9rvr887kHwCZET78P5HeGzwAxj52DgdzRZdiqAxV",
      "signerType": "wallet",
      "versionAddress": "S7b7qpR354RyU3jFFi32PC7BMyUREdB6jviKdef2SJf",
      "versionNumber": 3,
      "requestDomain": "shop.acme.com",
      "externalUserRef": "user_12345",
      "commitment": "finalized",
      "documentSlug": "terms-of-service",
      "feePayer": "BLECAww6xiFLr89hgXg3eJwiN1VitX4ML8Z6yE8yPgMS",
      "feeLamports": 10000,
      "reimbursedLamports": null
    }
  ],
  "nextCursor": null
}
Field
signer, signerTypeThe key that signed: a wallet, or a passkey-protected signer
versionAddress, versionNumberExactly which version was accepted
requestDomainThe site that asked for the signature
externalUserRefYour user ID, if a session was used; otherwise null
commitmentconfirmed, then finalized once Solana finalizes the transaction
feePayer, feeLamports, reimbursedLamportsWho paid the network fee and how much; reimbursedLamports is what your sponsorship paid back (null: Stele paid)

To check whether a user accepted the current version, filter by the version address from the latest version with limit=1: that is what hasAcceptedLatest() does, and Get started shows it in every language.

Get one acceptance#

For example to check an acceptanceId your page sent to your server. Stele answers only for your organization's acceptances (404 otherwise). The ID comes from the browser, so check its format before putting it in a URL:

curl "https://stele.site/api/v1/acceptances/$ACCEPTANCE_ID" \
  -H "Authorization: Bearer $STELE_API_KEY"

The response is { "acceptance": { … }, "receiptUrl": "https://stele.site/receipt/<acceptanceId>" }, with acceptance exactly like a list item. Without a session, an acceptance proves that a key accepted, not which of your users holds it: compare externalUserRef with the logged-in user.

Receipts#

A receipt is a self-contained file anyone can verify later, without Stele. Public: no key needed. Keep one with your records:

curl "https://stele.site/api/v1/public/acceptances/$ACCEPTANCE_ID/receipt" \
  | jq .receipt > receipt.json

The response holds two objects:

  • receipt: the stele-receipt-v1 file: network, transaction, signer, signed message and signature, nonce, organization, document, version, the full document text, and — when available — signer (for passkey signers: the passkey's evidence, re-verified by every verifier) and fees (fee payer, fee, and who sponsored it).
  • report: the result of verifying it against Solana just now. report.verdict is VALID, VALID_WITH_WARNINGS, INVALID or INCONCLUSIVE, and report.checks lists every check.
json
{
  "receipt": {
    "format": "stele-receipt-v1",
    "acceptanceId": "4ba3b02722d744452be1a9837020dba10691cded7df67c332a3aa4c1527ccf05",
    "network": { "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "label": "Solana Mainnet", "programId": "6sTKscWmUtBQRMpNCiEpurCJxpjx7GK5rzk7e265kpwV" },
    "transaction": { "signature": "4Gv4TkZKnWS7gsSDpo9Uv9kWgCp1oo2A3kfk67mZW8KTgB8gMkHNyZfJjQQLNUoKBeE97S77EpjUeqNrUJBo5mi9", "slot": 11989, "blockTime": 1791023061 },
    "acceptance": { "signer": "ALrq9rvr887kHwCZET78P5HeGzwAxj52DgdzRZdiqAxV", "message": "Stele Protocol v1 - Accept Agreement\n…", "requestDomain": "shop.acme.com" },
    "version": { "number": 3, "label": "3.0", "title": "Terms of Service" }
  },
  "report": {
    "verdict": "VALID_WITH_WARNINGS",
    "summary": "All critical checks passed; review the warnings.",
    "checks": [
      { "id": "tx.status", "label": "Transaction", "status": "warn", "detail": "Confirmed in slot 11989 but not yet finalized.", "critical": false }
    ]
  }
}

(Shortened: the real objects contain more fields and checks.) The report is Stele's own check; for evidence that does not depend on Stele, verify the receipt yourself with the verifier.

Signing (used by the SDK)#

Signing and recording go through two public endpoints. Let the SDK call them: it rebuilds the message from Solana data and refuses to sign if the server's message differs.

EndpointBody → response
POST /v1/acceptance/challenges{ organization, document, signer, sessionToken?, signerType? } → the message to sign, nonce, validity window, a clientBinding secret, feeSponsor, protocol (stele/1 or stele/2), and for passkey signers the passkey challenge. signerType: wallet (default; signer is a base58 address), passkey_p256 (native passkey; signer is p256:<66 hex>), or passkey (PRF-protected key). The requesting site is taken from the Origin header
POST /v1/acceptance/challenges/{id}/submit{ clientBinding, signature?, passkeyAssertion? } (native passkeys send only the assertion) → { acceptanceId, transactionSignature, slot, status, evidenceMode, signerType, fee }. status is confirmed, or pending_batch when your organization anchors acceptances in Merkle batches: transactionSignature is then null until the batch is anchored (seconds)

Signing without a wallet (passkeys)#

If your signing policy allows it (Dashboard → Signing & fees), visitors can sign with a passkey — Face ID, Touch ID, Windows Hello, a phone or a security key — instead of a wallet. The widget and the hosted page do everything below for you; these endpoints are documented for custom integrations.

Native passkeys (stele/2, the default for new signers). The passkey's own P-256 key signs. Its private key never leaves the authenticator — nothing is generated, wrapped, stored or unlocked in JavaScript. Each acceptance is one passkey prompt with user verification; Solana's secp256r1 precompile verifies the signature and the Stele program checks that it covers exactly this message, from a top-level page of the requesting site, for the right relying party. Signers registered before native passkeys keep working as PRF-protected keys (below).

Signing policyWho can sign
WALLET_ONLY (default)Solana wallets
PASSKEY_ONLYPasskey signers
WALLET_OR_PASSKEYEither; the visitor chooses
ACCOUNT_AND_PASSKEYPasskey signers bound to your user account: every request needs an acceptance session created for that externalUserRef; one active signer per account

With ACCOUNT_AND_PASSKEY, a native passkey is enrolled on-chain for the account (a PasskeySigner account holding a salted commitment to your externalUserRef — no personal data). Only the current passkey can hand the account to a new one (rotation); if the user lost every passkey, your admin wallet authorizes a replacement, which every verifier shows as an organization recovery, never as the user's own act.

Public browser endpoints for native passkeys (CORS without cookies; the relying party is the calling site):

MethodPath
POST/v1/passkeys/native/options{ organization, purpose: "register" | "rotate" | "recover", sessionToken?, identity? } → navigator.credentials.create() options (challenge, rpId, user, algorithms: [-7], excludeCredentials, accountBound)
POST/v1/passkeys/native/register{ challengeId, credential, label? } (the WebAuthn registration) → { identity: "p256:…", status: "pending", confirm }: the statement the passkey must sign next (enroll / recover: the new passkey; rotate: the current one), with its challenge and allowCredentials
POST/v1/passkeys/native/confirm{ challengeId, assertion } → { identity, status: "active" | "awaiting_organization", enrollment, transactionSignature, recovery }. Proves possession; in account mode records the enrollment or rotation on-chain (Stele pays)
POST/v1/passkeys/native/identify{ challengeId, assertion } (a discoverable assertion for an identify ceremony from /v1/passkeys/options) → { identity, credentialId, rpId, algorithm }

Signing with a native passkey uses the signing endpoints with signerType: "passkey_p256" and signer: "p256:<key>"; the challenge response adds passkey: { kind: "native", challenge, rpId, allowCredentials } where challenge = SHA-256("stele:v2:passkey-acceptance\0" ‖ message). Submit the WebAuthn assertion as passkeyAssertion (credentialId, authenticatorData, clientDataJSON, signature — the DER signature as returned by the browser, base64url). The API checks it exactly as the program will and returns 400 invalid_passkey_assertion (with the reason) before anything is relayed.

In the browser, with the bundled SDK:

ts
import { NativePasskeySigner, SteleClient, acceptDocument } from "@stelehq/sdk";

const client = new SteleClient({ apiBaseUrl: "https://stele.site/api" });
const signer =
  NativePasskeySigner.remembered(client, organizationAddress) ??
  (await NativePasskeySigner.register({ client, organization: organizationAddress }));
await acceptDocument({ client, verified, signer, requestDomain: location.hostname });

The SDK rebuilds the message from Solana data, computes the WebAuthn challenge itself and checks the assertion locally before submitting. signer.rotate({ sessionToken }) and NativePasskeySigner.requestRecovery({ … }) cover account-bound signers.

PRF-protected Stele keys (stele/1)#

Signers registered before native passkeys: the browser created an Ed25519 signing key, encrypted it with a key derived from the passkey's WebAuthn PRF output, and sent Stele only the ciphertext. To sign, one passkey prompt (with user verification) approves the exact message and unlocks the key for that one signature. Stele never sees the private key, cannot decrypt it, and refuses to record such an acceptance unless the passkey approved that exact message.

Public browser endpoints (CORS without cookies; the passkey's relying party is the calling site):

MethodPath
POST/v1/passkeys/options{ organization, purpose: "register" | "add_passkey" | "identify", sessionToken?, identity? } → WebAuthn options (challenge, rpId, user, excludeCredentials) and the network's chainId
POST/v1/passkeys/registerRegistration: the WebAuthn credential, the new signer's public key, a key statement signed by that key and endorsed by the passkey, the PRF salt, the wrapped key, optionally a recovery kit's wrapped key. add_passkey uses the same body with an ADD_PASSKEY statement
POST/v1/passkeys/key-material{ organization, credentialId } → the wrapped (encrypted) key and PRF salt
POST/v1/passkeys/identifyA discoverable passkey assertion → the signer and its wrapped key (returning visitor on a new device)
POST/v1/passkeys/recovery-kitsAdd a recovery kit (statement signed by the key)
POST/v1/passkeys/recovery-kits/lookup{ organization, kitId } → the kit's wrapped key (the kit ID is derived from the kit's secret, which never leaves the user)
POST/v1/passkeys/revokeRevoke a passkey (statement signed by the key)
GET/v1/public/signers/{publicKey}Public evidence about a signer key: its key statements, passkey endorsements and rotation

Signing with a passkey uses the normal signing endpoints with two additions:

  • POST /v1/acceptance/challenges takes signerType: "passkey"; the response adds passkey: { challenge, rpId, allowCredentials } (the challenge is SHA-256("stele:v1:passkey-signature\0" ‖ message)).
  • POST /v1/acceptance/challenges/{id}/submit takes passkeyAssertion (credentialId, authenticatorData, clientDataJSON, signature, base64url). Without a valid, user-verified assertion over that exact message from an active passkey of the signer, the request fails with 400 passkey_assertion_required / invalid_passkey_assertion.

In the browser, with the bundled SDK:

ts
import { PasskeySigner, SteleClient, acceptDocument } from "@stelehq/sdk";

const client = new SteleClient({ apiBaseUrl: "https://stele.site/api" });
const signer =
  PasskeySigner.remembered(client, organizationAddress) ??
  (await PasskeySigner.register({ client, organization: organizationAddress, recoveryKit: true })).signer;
await acceptDocument({ client, verified, signer, requestDomain: location.hostname });

Managing signers from your server#

MethodPathScope
GET/v1/signer-identities?externalUserRef=signers:readSigners, their passkeys, recovery kits and rotation history
POST/v1/signer-identities/{publicKey}/revokesigners:write{ reason } — retire a signer after the user lost every passkey and recovery kit, once you verified them. Their next signer is a new key, recorded as authorized by your account system (never control of the old key)
curl "https://stele.site/api/v1/signer-identities?externalUserRef=user_12345" \
  -H "Authorization: Bearer $STELE_API_KEY"

curl "https://stele.site/api/v1/signer-identities/$PUBLIC_KEY/revoke" \
  -H "Authorization: Bearer $STELE_API_KEY" -H "Content-Type: application/json" \
  -d '{ "reason": "lost all devices; identity re-verified by support" }'

Proofs (purchases, refunds, consents…)#

A proof records that a customer confirmed a statement: a purchase, a refund, a cancellation, a consent, a quote acceptance… Types: PURCHASE, SALE, ORDER, PAYMENT, RECEIPT, INVOICE, REFUND, CANCELLATION, DELIVERY, RETURN, WARRANTY, SUBSCRIPTION_START, SUBSCRIPTION_CANCEL, QUOTE_ACCEPTANCE, CONTRACT, CONSENT, POLICY_ACCEPTANCE.

Only the statement's salted SHA-256 goes on-chain. The statement itself (items, amounts, your order reference) stays private to you and the customer, in the proof receipt.

  1. 01Your server creates a proof request for the order
  2. 02The customer sees the statement and confirms it with their wallet or passkey
  3. 03The signed proof is recorded on Solana
  4. 04Your server checks the proof before fulfilling

1. Your server creates a request (proofs:write):

curl https://stele.site/api/v1/proof-requests \
  -H "Authorization: Bearer $STELE_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "type": "PURCHASE",
    "reference": "order-1042",
    "summary": "Purchase of 2 items from Acme",
    "details": {
      "currency": "EUR",
      "items": [{ "sku": "MUG-1", "name": "Mug", "quantity": 2, "unitPrice": "9.00", "total": "18.00" }],
      "subtotal": "18.00", "shipping": "3.90", "total": "21.90"
    },
    "externalUserRef": "user_12345"
  }'
json
{
  "requestId": "1d4c…",
  "clientToken": "stlp_…",
  "statementHash": "6760…",
  "statement": { "format": "stele-statement-v1", "type": "PURCHASE", "...": "…", "salt": "…" },
  "expiresAt": "2026-10-04T11:00:00.000Z",
  "confirmUrl": "https://stele.site/confirm/1d4c…#token=stlp_…"
}

Amounts are decimal strings; totals must add up (400 invalid_totals otherwise). Put personal data only as commitments ({ "commitment": "sha256:…" }, see commit() in @stelehq/protocol). The clientToken lets one browser see and confirm this statement: give it only to that customer.

2. The customer confirms. Send them to confirmUrl, or embed the confirmation on your page:

html
<div data-stele-proof-request="1d4c…" data-stele-proof-token="stlp_…"></div>
<script src="https://stele.site/sdk/v1/stele.js" defer></script>
<script>
  document.addEventListener("stele:confirmed", (event) => {
    // event.detail.proofId: tell your server, which checks it (step 3)
  });
</script>

The widget shows the statement only after checking, in the browser, that it hashes to the value the customer will sign.

3. Your server checks before fulfilling (never trust the browser event alone):

curl "https://stele.site/api/v1/proofs?type=PURCHASE&reference=order-1042" \
  -H "Authorization: Bearer $STELE_API_KEY"

# The receipt, verified against Solana (contains the statement: keep it private)
curl "https://stele.site/api/v1/proofs/$PROOF_ID/receipt" \
  -H "Authorization: Bearer $STELE_API_KEY"
MethodPathAuth
POST/v1/proof-requestsproofs:writeCreate a statement to confirm
GET/v1/proof-requests/{id}x-proof-tokenThe statement and its human-readable lines
POST/v1/proof-requests/{id}/challengeclient token{ token, signer, signerType } → the anchor message to sign
POST/v1/proof-requests/{id}/submitclient token{ token, clientBinding, signature, passkeyAssertion? } → { proofId, transactionSignature }
GET/v1/proof-requests/{id}/receiptx-proof-tokenThe stele-proof-v1 receipt, verified against Solana
GET/v1/proofsproofs:readYour proofs, newest first: type, reference, externalUserRef, limit, cursor
GET/v1/proofs/{proofId}proofs:readOne proof with its statement and signer evidence
GET/v1/proofs/{proofId}/receiptproofs:readThe receipt

Proof receipts contain the statement, so Stele serves them only with your API key or the customer's client token — proof IDs alone are public on-chain and are not enough.

Fees (the user never pays)#

Every recording is relayed and paid by Stele's relayer; users never need SOL.

Credits. Each organization records a number of acceptances and proofs free every month (shown on Pricing and Dashboard → Billing). Beyond that, each recording uses one prepaid credit, whoever pays the network fee. Owners and admins buy credits under Billing: their wallet pays SOL straight to Stele's treasury address, with the invoice's reference in the transfer, and Stele adds the credits once it finds that payment on Solana. Credits never expire. When neither free recordings nor credits are left, signing requests are refused before anyone signs (402 credits_exhausted).

MethodPathAuth
GET/v1/billingpublicFree recordings per month, credit packs (credits, lamports), treasury address and network
GET/v1/organizations/{orgId}/billingdashboard sessionThis month's usage, credit balance and purchases

Who absorbs the network fee is chosen under Dashboard → Signing & fees:

  • Stele sponsors (default) — the relayer pays and absorbs it;
  • Your organization sponsors — fund your nonce shard (its address is the deposit address; no private key exists for it). The program reimburses the relayer the exact network fee of each valid recording from it, capped per record and per day, on-chain. Admins can pause and lower limits; only the owner raises limits, resumes or withdraws. With Fall back to Stele off, requests are refused (402 sponsorship_unavailable) while your shard cannot pay.
MethodPathAuth
GET/v1/organizations/{orgId}/sponsorshipdashboard sessionMode, sponsored shards (balance, limits, estimated recordings), fee history

Measured on a local validator: a recording costs 10,000 lamports (0.00001 SOL) without a priority fee — about 100,000 recordings per SOL, the same for wallet and passkey signers, acceptances and proofs. submit responses and webhooks report fee: { payer, lamports, sponsor, reimbursedLamports }.

Evidence modes (direct or Merkle-batched)#

ModeHow each acceptance is anchoredCost per acceptance (measured)Use when
DIRECT_ONCHAIN (default)Its own transaction; the program verifies the signature, consumes the nonce and emits an event10,000 lamportsOther Solana programs must read acceptances; you want per-acceptance on-chain verification
MERKLE_BATCHEDStele verifies the signature off-chain, then anchors the Merkle root of many acceptances in one transaction (every 10 s, up to 1,024)5,000 lamports ÷ batch sizeHigh volume

Set it with PATCH /v1/organizations/{orgId}/settings { "evidenceMode": "MERKLE_BATCHED" } (dashboard session, admin). In batched mode the submit response is status: "pending_batch"; the agreement.accepted webhook follows when the batch is anchored and carries the acceptance's full evidence (keep it: with the anchor transaction it proves the acceptance without Stele). Receipts are stele-receipt/2 with evidenceMode: "MERKLE_BATCHED" and the inclusion proof. Every anchored batch's manifest — each leaf's evidence — is public:

MethodPath
GET/v1/public/batches/{root}The stele-batch-manifest/1 of an anchored batch (hash-addressed in the content store; header x-batch-transaction)

What changes in batched mode: the chain proves that the root existed at its slot; each signature is verified by every verifier rather than by the program; nonces are not consumed on-chain (re-recording the same signed acceptance yields the same ID). See PROTOCOL_SPEC.md §17.9.

Webhooks#

Stele can notify your server when something happens. Set up an endpoint under Integrations → Add endpoint (HTTPS, public address) and choose its events; the signing secret is shown once. Get started → Webhooks has ready-to-use handlers in every language.

Every delivery is a POST with these headers: stele-event, stele-event-id, stele-delivery-id, stele-signature, user-agent: Stele-Webhooks/1.0. The body:

json
{
  "id": "evt_…",
  "type": "agreement.accepted",
  "createdAt": "2026-10-03T10:24:23.500Z",
  "organizationId": "e0b1e582-0f5e-460d-905a-d407bbbc0fb6",
  "data": {
    "acceptanceId": "4ba3b02722d744452be1a9837020dba10691cded7df67c332a3aa4c1527ccf05",
    "transactionSignature": "4Gv4TkZKnWS7gsSDpo9Uv9kWgCp1oo2A3kfk67mZW8KTgB8gMkHNyZfJjQQLNUoKBeE97S77EpjUeqNrUJBo5mi9",
    "slot": 11989,
    "commitment": "confirmed",
    "signer": "ALrq9rvr887kHwCZET78P5HeGzwAxj52DgdzRZdiqAxV",
    "signerType": "wallet",
    "document": "Hm77xC4schJ1o4eeZhniXBqKRaGfVhU25DYRiF9VX7YT",
    "documentSlug": "terms-of-service",
    "version": "S7b7qpR354RyU3jFFi32PC7BMyUREdB6jviKdef2SJf",
    "versionNumber": 3,
    "externalUserRef": "user_12345",
    "receiptUrl": "https://stele.site/receipt/4ba3b02722d744452be1a9837020dba10691cded7df67c332a3aa4c1527ccf05",
    "verify": "Verify independently: the transaction signature and acceptance ID are sufficient. Do not treat this webhook as proof."
  }
}
Eventdata
agreement.acceptedacceptanceId, transactionSignature, slot, commitment, signer (base58 or p256:…), signerType (wallet / passkey_p256 / passkey), protocol, fee, document, documentSlug, version, versionNumber, externalUserRef, receiptUrl; batched mode adds evidenceMode and the leaf's evidence
agreement.finalizedacceptanceId, transactionSignature, slot, commitment (finalized)
document.version_publisheddocument, version, versionNumber, versionLabel, fingerprint, contentHash, transactionSignature, publisher
organization.domain_verifieddomain, attestationTransaction, expiresAt
organization.member_changedmember, memberAccount, roles (admin, publisher), active, compromisedSince, change (added, updated, revoked, compromise_reported)
proof.recordedproofId, type, reference, statementHash, transactionSignature, slot, signer, signerType, externalUserRef, fee
signer.registered / signer.changedsigner (public key or p256:…), change (registered, passkey_added, recovery_kit_added, passkey_revoked, revoked, rotated, recovery_requested, and for on-chain enrollments enroll, rotate, recover, revoke), externalUserRef, enrollment, rotation, transactionSignature
sponsorship.low_balancenonceShard, depositAddress, spendableLamports, thresholdLamports, estimatedRecordsLeft
webhook.testmessage: sent by the Test button in the dashboard

agreement.accepted arrives as soon as the transaction is confirmed; act on it. agreement.finalized follows when Solana finalizes it, typically within seconds.

Verifying signatures. stele-signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256(secret, "<t>.<raw body>") keyed with the whole secret (whsec_…, as UTF-8 bytes):

  1. Read the raw request body: the signature covers the exact bytes, not re-serialized JSON.
  2. Compute the HMAC and compare it with v1 in constant time.
  3. Reject t more than 5 minutes from your clock (replays).
  4. Reply with any 2xx within 10 seconds, and de-duplicate on the event id: deliveries can repeat.

Retries. A delivery that fails (no 2xx within 10 seconds, or a connection error) is retried after 1 minute, 5 minutes, 15 minutes, then 1, 2, 4, 8, 12 and 24 hours: up to 10 attempts over about two days. Redirects are not followed. The dashboard shows each endpoint's delivery log and lets you test, disable or re-enable it, and rotate its secret.

Important

A webhook is a notification, not the cryptographic evidence itself. The evidence is the Solana transaction it names: verify it (for example with @stelehq/verifier) before relying on it in a dispute.

Errors#

Every error has the same shape:

json
{
  "error": {
    "code": "not_found",
    "message": "Document not found"
  }
}

Branch on code, never on message (its wording may change). The most common:

StatusMeaningWhat to do
400Invalid request: message names the fieldFix the request; don't retry it unchanged
401API key missing or invalidSend Authorization: Bearer <key>; check that the key was not revoked or expired
402No free recordings or credits left (credits_exhausted), or your sponsorship cannot pay (sponsorship_unavailable)Buy credits under Billing, or adjust your sponsorship
403Not allowed: missing scope, site not on Allowed requesting sites, a session is required, or a fresh signature is required (reauthentication_required)Read message; adjust the key, policy or session
404Not found, including resources of other organizationsCheck the slug, address or ID
409Conflicting state, for example no_published_version or challenge_expiredPublish a version, or start the signing again
422idempotency_key_reused, or verification_failed for a receipt that did not verifyUse a new idempotency key; check the evidence
429Rate limitedWait for the retry-after header (seconds), then retry
5xxTemporary failureRetry with backoff; use an idempotency key on POST

All codes:

StatusCodes
400invalid_request (schema validation), invalid_address, invalid_signer, invalid_content, invalid_domain, invalid_signature, invalid_origin, invalid_url, invalid_effective_date, invalid_idempotency_key, invalid_cursor, missing_binding, not_a_publication, passkey_required, passkey_assertion_required, invalid_passkey_assertion, invalid_registration, invalid_endorsement, invalid_statement, invalid_ceremony, invalid_wrapped_key, invalid_rotation, invalid_totals, invalid_pack, missing_token, unsupported_algorithm, unsupported_signer, not_account_bound, native_passkey, missing_identity
401unauthorized
402credits_exhausted, sponsorship_unavailable — returned before the user signs
403forbidden, reauthentication_required
404not_found
409no_published_version, chain_state, challenge_expired, challenge_not_open, domain_expiring, domain_already_verified, draft_published, draft_closed, transaction_failed, identity_exists, passkey_exists, kit_exists, proof_request_closed, proof_request_expired, not_recorded, billing_unavailable, payment_not_found, version_superseded, pending_batch, enrollment_mismatch
422idempotency_key_reused, verification_failed
429rate_limited, too_many_open_challenges, enrollment_limit
500internal_error: details are logged by Stele, never returned
502relay_failed (Solana rejected the acceptance; the message says why), relay_inconsistent
503no_acceptance_capacity (no active acceptance shard), nonce_unavailable

Pagination#

List endpoints (/v1/acceptances, /v1/proofs) return the newest records first, limit per page (1–200, default 50), and nextCursor. Pass it unchanged as cursor to get the next page; it is null on the last page. Cursors are opaque: records that share a slot are never skipped or repeated, and records added while you page do not shift the pages you have not read yet. The list sample shows the loop in every language.

Rate limits#

300 requests per minute per IP address. Stricter limits apply to sign-in (20 per minute), acceptance challenges and submissions (30 per minute) and DNS checks (10 per minute). Every response carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset (seconds); a limited request returns 429 rate_limited with a retry-after header.

Idempotency#

Send Idempotency-Key: <1–255 printable ASCII characters> on any authenticated POST, PATCH, PUT or DELETE to make retries safe. The first response is stored for 24 hours, encrypted, and scoped to the caller; a retry with the same key and the same request receives the same response, with the header idempotent-replayed: true. Reusing a key for a different request returns 422 idempotency_key_reused. Server errors (5xx) are not stored, so a retry is processed again.

bash
curl https://stele.site/api/v1/proof-requests \
  -H "Authorization: Bearer $STELE_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-purchase" \
  -d @proof-request.json

Use an ID from your own system (such as the order number) as the key.

Organization management#

The dashboard uses these endpoints with the signed-in wallet's session. They are documented for completeness; integrations normally use the endpoints above.

AreaEndpoints
SessionPOST /v1/auth/challenge, POST /v1/auth/verify, POST /v1/auth/logout, GET /v1/auth/me
OrganizationsGET /v1/organizations, POST /v1/organizations/sync, GET /v1/organizations/{orgId}, POST /v1/organizations/{orgId}/refresh, PATCH /v1/organizations/{orgId}/settings, PATCH /v1/organizations/{orgId}/members/{address} (label only)
CapacityPOST /v1/organizations/{orgId}/shards/sync
DomainsPOST /v1/organizations/{orgId}/domains, POST …/domains/{id}/check, POST …/domains/{id}/revoke
DocumentsGET /v1/organizations/{orgId}/documents, POST /v1/organizations/{orgId}/documents/sync, GET /v1/documents/{documentId}
Drafts & publishingPOST /v1/documents/{documentId}/drafts, GET, PATCH /v1/drafts/{draftId}, POST /v1/drafts/{draftId}/abandon, POST /v1/drafts/{draftId}/prepare, POST /v1/publications/confirm
AcceptancesGET /v1/organizations/{orgId}/acceptances, GET /v1/organizations/{orgId}/stats
Signing & feesGET /v1/organizations/{orgId}/sponsorship
BillingGET /v1/organizations/{orgId}/billing, POST …/billing/invoices ({ pack } → amount, treasury, reference), POST …/billing/invoices/{id}/confirm ({ signature? }; credits once the payment is found on-chain)
API keysGET, POST /v1/organizations/{orgId}/api-keys, DELETE …/api-keys/{keyId}
WebhooksGET, POST /v1/organizations/{orgId}/webhooks, PATCH, DELETE …/webhooks/{id}, POST …/{id}/rotate-secret, POST …/{id}/test, GET …/{id}/deliveries
Audit logGET /v1/organizations/{orgId}/audit-log, GET …/audit-log/verify
  • Sign-in uses a Sign-In With Solana message naming the app, its URI and network, with a single-use nonce valid for 10 minutes. It authorizes nothing on-chain.
  • CSRF: cookie-authenticated POST, PATCH, PUT and DELETE requests must come from the app's origin and carry the x-csrf-token header (the stele_csrf cookie's value).
  • Fresh signature: creating API keys, adding webhooks, rotating webhook secrets, changing the acceptance policy and revoking a domain require a wallet signature within the last 10 minutes; otherwise 403 reauthentication_required.
  • The API never writes to Solana for you. Organizations, documents, versions and shards are created by your wallet; sync, refresh and confirm make the API read the result from Solana.

Verifier#

@stelehq/verifier checks evidence directly against Solana, with no Stele server involved:

ts
import { chainFromUrls, verifyAcceptance } from "@stelehq/verifier";

const report = await verifyAcceptance(
  { transactionSignature: "4Gv4TkZK…" },
  { chain: chainFromUrls(["https://rpc-one.example", "https://rpc-two.example"]) },
);
console.log(report.verdict); // "VALID" | "VALID_WITH_WARNINGS" | "INVALID" | "INCONCLUSIVE"

From any language, run its command and read the exit code (0 valid, 1 valid with warnings, 2 invalid, 3 inconclusive) or its JSON report (--json). See Verification.