# On-chain verification and SBTs

An [on-chain flow](/docs/self-enterprise/flows/verification-modes/) verifies proofs with a smart contract on **Celo** instead of Self's servers. Each user who passes receives a **soulbound token (SBT)**: a non-transferable token in their wallet that any contract, wallet, or explorer can read as proof they passed your flow.

You never write, deploy, or fund a contract yourself. The dashboard deploys one per flow, and Self pays all gas.

## The contract

When you deploy an on-chain flow, Self deploys a dedicated SBT contract for it, one per environment:

| Environment | Network | Explorer |
| --- | --- | --- |
| Test | Celo Sepolia | Blockscout |
| Live | Celo Mainnet | Celoscan |

Both contracts are created from the same configuration in a single **Deploy** click. Each one enforces your flow's rules (age, country lists, OFAC, security level) directly on-chain, so the rules can't drift from what verifiers see.

The **Test** and **Live** tabs show a contract card per environment with the **contract address**, the **scope seed** (the flow's unique proof scope), and the **deploy transaction**, each linked to the block explorer.

### Deploy status

Deploys usually finish in under a minute, and the dashboard polls automatically. The Configure tab shows one combined status:

| Status | Meaning |
| --- | --- |
| **Deploying** | At least one network's contract is still deploying. |
| **Deployed** | Both contracts are live. |
| **Partially deployed** | One network succeeded, the other failed. Retry the failed one from its Test or Live tab. |
| **Deploy failed** | Both failed. Retry from the Test and Live tabs. |

Deploying and retrying require the **owner** or **admin** role; members see the status but must ask a manager to retry. Your flow's configuration is saved either way, a failed deploy never loses work, and share links plus the **Try this flow** callout simply stay locked until the test contract is live.

## The soulbound token

The SBT is an ERC-721 token named **Self Verification SBT** (symbol `SELFSBT`). The name and symbol are the same for every flow; the contract address is what identifies *your* flow.

Its rules are strict by design:

- **Non-transferable.** It can never be moved, sold, or approved to another address. There is no way to burn it.
- **One per person.** The proof's [nullifier](/docs/self-enterprise/get-started/how-it-works/#one-account-per-person-nullifiers) is recorded on mint. A second verification attempt by the same person fails with *"You have already verified your identity. Only one verification is allowed per person."*
- **One per wallet.** A wallet that already holds the flow's SBT can't receive another.

A rejected attempt (wrong document type, nationality not on your allowlist) doesn't consume the person's one verification, they can retry with a document that meets the rules.

## What your users experience

The [hosted page](/docs/self-enterprise/get-started/how-it-works/#what-your-users-see-the-hosted-page) adds two wallet steps before the usual QR code:

1. **Connect a wallet**: the wallet that will receive the SBT. MetaMask, Rainbow, WalletConnect-compatible wallets, and browser-extension wallets are supported.
2. **Sign a gas-free message**: a signature that confirms this wallet as the recipient. It costs nothing and sends no transaction. Switching accounts mid-flow asks for a fresh signature.

Then the user scans the QR code with the Self app as usual. Self submits the mint transaction; **the user never pays gas**.

:::note
The receiving wallet must be a standard wallet address (an EOA). Smart-contract wallets such as Safe can't receive the SBT.
:::

## Configuration limits

Most rules translate to the contract one-to-one: minimum age, OFAC, security level, and country lists are all enforced on-chain. Two limits apply, checked by the dashboard before you deploy:

- **Maximum age isn't enforceable on-chain.** A flow with a maximum-age rule can't be deployed in on-chain mode.
- **Country lists are capped**: at most **40 excluded countries** or **41 included countries**. These are protocol limits and can't be raised.

If a country allowlist is set, the contract checks the user's disclosed nationality, exactly as in backend mode.

## Reading results

Every surface you already use gains the on-chain details:

- **Webhooks**: `verification.completed` includes `verification_mode: 'onchain'`, and `tx_hash` plus `contract_address` when a token was minted. See the [event catalog](/docs/self-enterprise/webhooks/events/#on-chain-fields).
- **API**: the session detail response includes `verification_mode` and an `onchain` object with the contract address, scope seed, and network.
- **Activity log**: each row shows the mint transaction, linked to the explorer, and the proof-request popover shows the contract address.
- **On-chain**: any contract can check `balanceOf(wallet)` on your flow's SBT contract to gate access.

A `valid` on-chain verification means the transaction confirmed and the token was minted. A verification the contract rejects (an already-verified person, a disallowed document, a nationality outside your allowlist) is `invalid`; infrastructure failures are `error`. Billing follows the same rules as off-chain: `valid` and `invalid` are charged, `error` is not.

:::note
On-chain verifications keep no off-chain proof record: the chain is the record. The session's `storage.state` reports `'skipped'` for them, and no storage events fire.
:::

## Related

- [Verification modes](/docs/self-enterprise/flows/verification-modes/): choosing between backend and on-chain.
- [Test vs. live](/docs/self-enterprise/flows/test-vs-live/): Celo Sepolia vs. Celo Mainnet mirrors the test/live split.
- [Credits and usage](/docs/self-enterprise/billing/credits-and-usage/): when on-chain verifications are charged.
