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

Docs / Developers / Get started

Get started

Add verifiable Terms acceptance to your site in a few minutes.

Your visitors read your Terms on your own page and sign them — with a Solana wallet, or with a passkey (Face ID, fingerprint, device PIN) if they have no wallet. Stele records the acceptance on Solana, and your app receives a confirmation it can check. Your visitors never pay a network fee.

  1. 01You publish your Terms in the Stele dashboard
  2. 02You add the Stele widget to your page
  3. 03Your visitor connects a wallet or uses a passkey
  4. 04Your visitor signs the exact version they read
  5. 05The acceptance is recorded on Solana
  6. 06Your server confirms it with Stele

Note

Highlighted values such as acme.com are placeholders: replace them with your own. Addresses such as https://stele.site are this Stele app's own: copy them as they are. Server samples come in cURL, Node.js, Python, Java, Go, PHP, Ruby and C#; pick yours on any sample.

5-minute integration#

1. Publish your Terms. In the dashboard: Documents → New document, then publish the first version (details in step 2).

2. Paste this into your page.

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>

3. Open your page. Stele handles the rest:

  • loading the document and checking it against Solana
  • finding and connecting the visitor's wallet, or creating their passkey signer
  • asking for the signature
  • recording the acceptance
  • showing a receipt

Need to know which of your users accepted? Continue with step 7.

Before you start#

  • A Solana wallet to sign in to the dashboard, such as Phantom, Solflare or Backpack
  • A Stele organization (step 1) and a published document (step 2)
  • Two values from the dashboard: your organization (verified domain or organization address) and your document slug
  • For server-side checks (optional): an API key and a backend in any language

Your visitors need a Solana wallet or a device with passkeys (if you allow them in Signing & fees), and never any SOL.

1. Create your organization#

  1. Open the dashboard and connect your wallet. You sign a short message that proves you control it; this signs nothing on-chain and moves no funds.
  2. Enter your organization name and click Create organization. Your wallet signs one transaction, which creates your organization on Solana and enables acceptances. On test networks the page offers Request test SOL to pay for it.
  3. Verify your domain (recommended): Organization → Verified domain → Start verification. Add the DNS TXT record shown, then click Check DNS & attest.

Without a verified domain, visitors see "Unverified organization". With one, they see your domain with a check mark, both in the widget and in their wallet's signing prompt.

2. Publish your Terms#

  1. Go to Documents → New document.
  2. Choose the document type, a slug (for example terms-of-service) and the language, then click Create on Solana and confirm in your wallet.
  3. The editor for the first version opens. Enter a title, a version label (for example 1.0) and the document text, then click Create draft.
  4. Check the fingerprint shown, click Sign & publish and confirm in your wallet.

After publishing:

  • that version is permanent: nobody can edit or delete it, including you and Stele
  • to change your Terms, click New version on the document page and publish it; visitors are always shown the latest version

Note the two values you need for the next step:

ExampleWhere to find it
Organizationacme.comYour verified domain, or the organization address (Organization page)
Document slugterms-of-serviceThe slug you chose; shown on the document page

Tip

Each document page in the dashboard has an Embed on your site panel with the snippet below, already filled in with your values.

3. Add Stele to your website#

Put the widget where the Terms should appear. With a plain page, the script tag is all you need; apps that bundle their JavaScript install @stelehq/sdk (npm install @stelehq/sdk).

<div
  data-stele-organization="acme.com"
  data-stele-document="terms-of-service"
></div>

<script src="https://stele.site/sdk/v1/stele.js" defer></script>

Change only acme.com (your organization) and terms-of-service (your document slug). Themes, styling, pinning and every other option: SDK & API → Browser script.

Complete example#

Save this as index.html:

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Terms of Service</title>
  </head>
  <body>
    <h1>Terms of Service</h1>

    <div
      data-stele-organization="acme.com"
      data-stele-document="terms-of-service"
    ></div>

    <button id="continue" disabled>Continue</button>

    <script src="https://stele.site/sdk/v1/stele.js" defer></script>
    <script>
      document.addEventListener("stele:accepted", (event) => {
        console.log("Accepted:", event.detail);
        document.querySelector("#continue").disabled = false;
      });

      document.addEventListener("stele:error", (event) => {
        console.warn("Stele:", event.detail.message);
      });
    </script>
  </body>
</html>

Serve the folder over HTTP. Wallet extensions do not run on file:// pages, so double-clicking the file is not enough:

npx serve -l 8080 .

Then open http://localhost:8080.

4. What your user sees#

  1. The Terms appear, with your organization and the document version.
  2. Stele checks that the text is exactly the version published on Solana. If anything was changed, the document is not shown at all.
  3. The user picks their wallet from the list (Phantom, Solflare, Backpack, or any other Solana wallet that supports the Wallet Standard) and connects it — or creates a passkey, if you allow it (step 11).
  4. They tick I have read and agree to the Terms of Service.
  5. They click Accept & sign. Their wallet shows the exact text being signed, including your organization, the version and your site's domain.
  6. The acceptance is recorded on Solana. Stele, or your organization, pays the network fee — never the visitor.
  7. They see Accepted and recorded on Solana with a link to their receipt.

Signing never moves funds and cannot approve a transaction. To learn what is checked and why, see How Stele works and What a signature proves.

5. Handle the acceptance in the browser#

When an acceptance has been recorded, the widget tells your page:

document.addEventListener("stele:accepted", (event) => {
  const { acceptanceId, transactionSignature } = event.detail;
  console.log("Accepted:", acceptanceId);
});

document.addEventListener("stele:error", (event) => {
  console.warn("Stele:", event.detail.message);
});
FieldMeaning
acceptanceIdStele's identifier for this acceptance (64 hex characters). Use it with the API and in receipt links
transactionSignatureThe Solana transaction that recorded it. Anyone can look it up and verify it
slotThe Solana slot it was recorded in
messageThe exact text the user signed
receiptPathPath of the receipt page on your Stele app, for example /receipt/<acceptanceId>
signerTypewallet or passkey

Errors, such as a document that cannot be verified or a failed recording, arrive as stele:error (onError). If the user cancels in their wallet, nothing is recorded; the widget tells them and no event is sent.

6. Don't trust the browser alone#

The stele:accepted event is right for updating your page, for example enabling a "Continue" button. Anyone can fake events in their own browser, though. For anything that matters (checkout, creating an account, payments, access to a service), check on your server before you act:

  • Ask the Stele API whether this user accepted the current version (steps 7 and 8), or
  • receive a webhook from Stele when an acceptance is recorded (step 9).

7. Verify acceptance on your server#

Create an API key under API keys → Create key with the scopes acceptances:read, acceptances:write and documents:read. The key is shown once: store it in your server's environment as STELE_API_KEY.

Warning

Never put API keys in browser or frontend code. Anyone who has the key can create sessions and read your organization's acceptances. Keep it on your server, in an environment variable or a secret manager.

Your base URL is https://stele.site/api/v1; each Solana network has its own (SDK & API → Base URL). Set up a small client once:

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

Now ask whether a user accepted the current version of a document: look up the current version, then that user's acceptance of it. Step 8 shows how Stele knows who "the user" is.

# 1. The current version's address (jq extracts it)
VERSION=$(curl -s https://stele.site/api/v1/documents/by-slug/terms-of-service/latest \
  -H "Authorization: Bearer $STELE_API_KEY" | jq -r .version.address)

# 2. The user's acceptance of that version: an empty list means "not accepted yet"
curl "https://stele.site/api/v1/acceptances?externalUserRef=user_12345&version=$VERSION&limit=1" \
  -H "Authorization: Bearer $STELE_API_KEY"

Tip

Did your page send you an acceptanceId from stele:accepted? Look it up with GET /v1/acceptances/{id}: Stele answers only for your organization's acceptances, and externalUserRef tells you whose it is.

A wallet address tells you which wallet signed. It does not tell Stele which account in your app the wallet belongs to. To record that, your server creates a short-lived acceptance session for the logged-in user and gives its token to the widget.

  1. 01Your user is logged in to your app as user_12345
  2. 02Your server creates an acceptance session for user_12345
  3. 03Your server passes the session token to the page
  4. 04The widget sends the token with the signing request
  5. 05The user signs
  6. 06Stele stores user_12345 with the acceptance (off-chain, never on Solana)
  7. 07Your server asks: has user_12345 accepted the latest version?

externalUserRef is your own user ID, for example user_12345: up to 128 printable characters without spaces. Don't use an email address; Stele doesn't need it.

Backend: create a session when you render the page (one per page view):

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" }'

A session is valid for 15 minutes (ttlSeconds, up to 3600) and is used up by one acceptance.

Frontend: pass the token to the widget. A page rendered by your server writes it into the element; a single-page app can instead hand the widget a function that fetches a fresh token from your server when the user clicks Accept & sign:

<div
  data-stele-organization="acme.com"
  data-stele-document="terms-of-service"
  data-stele-session="SESSION_TOKEN_FROM_YOUR_SERVER"
></div>

(/stele/session stands for a route of yours that requires login and returns a new sessionToken. With the script tag, Stele.mount("#terms", { … }) takes the same options.)

Later: check on your server with the code from step 7, for the logged-in user's ID.

When your Terms change#

The check in step 7 answers for the latest published version only. As soon as you publish a new version, it finds nothing for every user until they accept that version:

Terms v1You publish Terms v2User accepts v2
Step 7 checkacceptance of v1nothingacceptance of v2
Your appallow accessask the user to accept againallow access

The widget always shows the latest version, so showing it again is all it takes to ask for a new acceptance. Earlier acceptances stay on Solana as evidence of what the user agreed to at the time.

Tip

To make sessions mandatory, so nobody can collect acceptances for your organization without your server, enable Organization → Acceptance policy → Require acceptance sessions.

9. Webhooks#

Use webhooks if you want Stele to notify your backend automatically.

  1. Create an endpoint on your server (below).
  2. In the dashboard: Integrations → Add endpoint. Enter its HTTPS URL and choose the events.
  3. Copy the signing secret (shown once) into your server's environment as STELE_WEBHOOK_SECRET.
EventSent when
agreement.acceptedAn acceptance was recorded and confirmed on Solana. Act on it right away
agreement.finalizedThe same transaction reached finalized status, Solana's strongest guarantee, typically seconds later

Each handler verifies the Stele-Signature header over the raw body (re-serialized JSON would not match), rejects deliveries older than 5 minutes, and replies 204 within 10 seconds:

import express from "express";
import { verifyWebhook } from "@stelehq/sdk/server";

const app = express();

// Use the raw body: the signature covers the exact bytes Stele sent.
app.post("/webhooks/stele", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = verifyWebhook(process.env.STELE_WEBHOOK_SECRET!, req.header("stele-signature"), req.body);
  } catch {
    return res.sendStatus(400); // not from Stele, or too old (replay)
  }

  if (event.type === "agreement.accepted") {
    const { acceptanceId, externalUserRef, versionNumber } = event.data;
    // Mark externalUserRef as having accepted version versionNumber.
  }

  res.sendStatus(204);
});

app.listen(4242);

Deliveries can repeat: use the event's id to ignore duplicates. A webhook is a notification, not the evidence itself; the evidence is the Solana transaction it points to. Retries, all events and the payload format are in the SDK & API reference.

10. Test locally#

Stele runs a separate app for each Solana network: Solana Mainnet for production and test networks such as Solana Devnet for development. Use the network switcher in the site header or at the bottom of the dashboard sidebar to move between them. Test networks show a banner on every page. Organizations, documents, API keys and acceptances belong to one network, so create them again on Mainnet when you go live, and use that app's address in your snippets.

The widget shows its network in its Network row. Wallets must use the same network.

  1. Publish your document in the dashboard.
  2. In your wallet, switch to that network. In Phantom: Settings → Developer Settings → Testnet Mode, then choose the network.
  3. Serve your page on http://localhost (see step 3) and open it.
  4. Connect your wallet.
  5. Read the document and tick I have read and agree.
  6. Click Accept & sign and approve the message in your wallet.
  7. Wait for Accepted and recorded on Solana.
  8. Open the receipt.
  9. Check it on Verify (link in the site header) with the transaction signature.

Your test wallet needs no SOL to accept. If your organization has Allowed requesting sites, add localhost while you test.

What you seeCause and fix
The wallet says the chain ID or network does not matchThe wallet is on another network. Switch it to the network shown in the widget
"No compatible wallet was found in this browser"No Solana wallet is installed, or it does not support this network. Install one and reload
The widget stays emptyThe page was opened as file://, or your Content Security Policy blocks the script; serve it over HTTP

11. No wallet? Passkeys#

Many visitors have no Solana wallet. In Dashboard → Signing & fees, choose Wallet or passkey (or Passkey only). The same widget then offers Create a passkey:

  1. The browser creates a signing key and protects it with the visitor's passkey (Face ID, Touch ID, Windows Hello, a phone or a security key). Stele receives only the encrypted key — it cannot sign for the visitor, and neither can you.
  2. The widget offers a recovery kit (a code shown once) for the day the device is lost.
  3. To sign, one passkey prompt approves the exact text and unlocks the key for that one signature.

Nothing changes for your server: the step 7 check, webhooks and receipts work the same. Receipts additionally carry the passkey's evidence, which every verifier checks.

OptionUse it when
Wallet or passkeyPublic sites: visitors choose
Passkey onlyAudiences without crypto wallets
Your account + passkeyThe passkey signer must belong to a user of yours: every request needs an acceptance session for that user (step 8), and each account has one signer

Important

Passkey signing uses the passkey's own key (secp256r1), verified on-chain by Solana — any current browser, phone or password manager with passkeys works. Where passkeys are unavailable, the widget offers a wallet instead — Stele never falls back to a key it would hold itself.

A user lost every device and the recovery kit

Nobody can recover that key — by design. After verifying the user yourself, retire their signer (POST /v1/signer-identities/{publicKey}/revoke, or Signing & fees → Passkey signers → Retire). Their next signer is a new key, recorded as authorized by your account system. Their past acceptances stay valid and still name the old key.

12. Confirm purchases and other statements#

Acceptances cover documents. For purchases, refunds, consents, quote acceptances and similar statements, your server creates a proof request (POST /v1/proof-requests), the customer confirms it in the widget or on the hosted page, and your server checks the proof before fulfilling. Only the statement's salted hash goes on-chain — the order details stay between you and the customer, in the proof receipt. Code for every step and language: SDK & API → Proofs.

13. Who pays the fees#

Your visitors never pay. Each organization records a number of acceptances and proofs free every month; beyond that, each recording uses one credit, bought under Dashboard → Billing with SOL from your wallet (see Pricing). Network fees are included: Stele's relayer pays them.

To pay network fees yourself instead, open Signing & fees → My organization sponsors, enable sponsorship on your nonce shard and deposit SOL to its address. The Solana program reimburses each valid recording's exact fee (about 0.00001 SOL) from it, within the per-record and daily limits you set; only your owner wallet can withdraw.

14. Production checklist#

Before going live:

  • Your domain is verified (Organization → Verified domain).
  • Allowed requesting sites lists your production hostnames, for example acme.com, app.acme.com, and no longer localhost.
  • You use the Solana Mainnet app (network switcher): your organization, documents, API keys and webhooks are recreated there, and your snippets and base URL use its address. The widget's Network row says Solana Mainnet.
  • API keys live only on your server, with only the scopes you use.
  • Webhook endpoints use HTTPS, verify signatures and ignore duplicate event IDs.
  • Your site is served over HTTPS.
  • You tested publishing a new version: the step 7 check finds nothing until users accept again.
  • You verified one receipt independently, on Verify or with the command-line verifier.
  • If your site sends a Content Security Policy, it allows the widget (see SDK & API reference).
  • For the strongest guarantees: bundle @stelehq/sdk instead of loading the script, and pin expected (program ID and chain ID) and rpcUrls.
  • If you allow passkeys: you chose the signing policy deliberately, and your support process for lost devices verifies users before retiring a signer.
  • If your organization sponsors fees: limits fit your volume, a low-balance alert is set, and the sponsorship.low_balance webhook reaches someone.

Troubleshooting#

You seeWhat to do
"No compatible wallet was found in this browser"Install a Solana wallet that supports the network shown, then reload
The wallet refuses: chain ID or network does not matchSwitch the wallet to the network shown in the widget
"This organization does not accept signing requests from …"Add your site's hostname to Organization → Acceptance policy → Allowed requesting sites
"This organization requires an acceptance session issued by its own server"Pass a session token (step 8), or turn off Require acceptance sessions
"Invalid or expired acceptance session"Sessions last 15 minutes and are used up by one acceptance: create a new one per page view
"This agreement could not be verified, so it is not shown"The text or its details did not match Solana; the message says which check failed
"Acceptances are not enabled yet" (dashboard)Open Organization → Acceptance capacity and add a shard for the Stele relayer
Nothing appearsCheck the browser console. Common causes: a file:// page, a Content Security Policy, or a placeholder element other than a <div>
401 unauthorized from the APIThe key is missing, revoked or expired, or belongs to another network's Stele app
403 forbidden from the APIThe key lacks the scope the endpoint needs (scopes)
"Passkeys are not available here"The browser or device has no passkey support: use a wallet, or another browser
"This organization accepts passkey signing only" / "wallet signing only"Change the signing policy in Signing & fees, or use the other method
402 credits_exhaustedThis month's free recordings and your credits are used up: buy credits under Billing
402 sponsorship_unavailableYour sponsorship is paused, empty or at its daily limit: top it up, raise the limit, or enable Fall back to Stele

Complete example: require Terms before checkout#

  1. 01The user opens checkout
  2. 02Your server creates an acceptance session for the user
  3. 03The widget shows the latest Terms
  4. 04The user accepts
  5. 05Stele records the acceptance
  6. 06Your server checks that the latest version was accepted
  7. 07Checkout continues

A complete server, with the checkout page it renders. requireLogin and req.user (Express), or Flask-Login's login_required and current_user, stand for your existing login:

import express from "express";
import { SteleServerClient } from "@stelehq/sdk/server";

const stele = new SteleServerClient({
  apiBaseUrl: "https://stele.site/api",
  apiKey: process.env.STELE_API_KEY!,
});
const app = express();

app.get("/checkout", requireLogin, async (req, res) => {
  const { sessionToken } = await stele.createAcceptanceSession({
    document: "terms-of-service",
    externalUserRef: req.user.id,
  });
  res.send(checkoutPage(sessionToken));
});

app.post("/checkout", requireLogin, async (req, res) => {
  const accepted = await stele.hasAcceptedLatest(req.user.id, "terms-of-service");
  if (!accepted) {
    return res.status(403).send("Please accept the current Terms of Service first.");
  }
  // … place the order
  res.redirect("/thank-you");
});

const checkoutPage = (sessionToken: string) => `<!doctype html>
<html lang="en">
  <head><meta charset="utf-8" /><title>Checkout</title></head>
  <body>
    <h1>Checkout</h1>
    <div
      data-stele-organization="acme.com"
      data-stele-document="terms-of-service"
      data-stele-session="${sessionToken}"
    ></div>
    <form method="post" action="/checkout">
      <button id="pay" disabled>Place order</button>
    </form>
    <script src="https://stele.site/sdk/v1/stele.js" defer></script>
    <script>
      document.addEventListener("stele:accepted", () => {
        document.querySelector("#pay").disabled = false;
      });
    </script>
  </body>
</html>`;

app.listen(4242);

The button is enabled by the browser event, but the order is placed only after your server confirmed the acceptance with Stele.

Go deeper#

How the document is verified before it is shown

The widget downloads the document text and computes its SHA-256 hash. It reads the version's fingerprint from Solana and recomputes it from the on-chain fields and that hash. The text is shown only if everything matches, so an altered copy is never displayed or offered for signing.

Why the signing message is rebuilt from Solana

Stele's server prepares the message the wallet signs. Before asking the wallet, the widget rebuilds the message itself from data it read on Solana and refuses to continue if a single byte differs. A compromised server therefore cannot get a user to sign something they were not shown.

Domain binding and Allowed requesting sites

The signed message ends with Requested by: <your hostname>, taken from the browser's Origin, which page scripts cannot change. A copy of your widget on another site therefore produces acceptances naming that site. Allowed requesting sites goes further: Stele refuses to issue signing requests for any hostname not on your list.

Replay protection

Every signing request carries a single-use number (a nonce) and a validity window of a few minutes. The Solana program records each nonce once, so a signature can never be recorded twice, reused for another version, or replayed on another network.

Pinning the program, network and RPC endpoints

By default the widget learns the program ID, network and Solana RPC endpoints from your Stele app. To rely on Stele's servers even less, pass expected: { programId, chainId } (the values from GET /api/v1/network) and your own rpcUrls. Loading stops if anything else is advertised.

Script tag or bundled package

Loading stele.js from your Stele app means trusting that server to deliver the widget's code. Installing @stelehq/sdk and bundling it with your app pins the code to the version you reviewed.

What a signature proves

Stele proves that a specific key (a wallet, or a passkey-protected signer) signed acceptance of a specific, unaltered document version, once, at a recorded time. It does not prove who controls the key; link acceptances to your own users (step 8) when that matters. Whether an agreement is enforceable depends on the applicable law and circumstances. Stele does not provide legal advice.