On this page

Documentation

Everything you need to understand, integrate, and run Portal.

What is Portal

Portal is a small piece of infrastructure that turns a Zcash shielded transaction into a credential. That one idea powers three things: signing into a website, unlocking paid content, and requesting a payment from anyone.

Here is the intuition. Every shielded Zcash transaction can carry a private, encrypted note called a memo. Only the person receiving the money can read it. So if a website hands you a one-time code, and a payment arrives at the website's address carrying that exact code, the website knows something useful: whoever it gave the code to has completed a payment. It knows that with certainty, and it knows nothing else. Not your address. Not your balance. Not your name.

That is the entire trick. No new protocol, no browser extension, no wallet changes. It works today with the shielded wallets people already carry: Zashi, Ywallet, and Zingo.

A live instance is running right now at tryportal.xyz. Everything below works against it, and Portal is also yours to self-host, see Self-hosting.

How it works

A complete round trip has four steps. In practice the whole thing usually takes a few seconds.

  1. 01

    Your app requests a challenge. Portal generates a one-time code, something like PT-K8ZS4T, and wraps it into a standard Zcash payment request (a ZIP-321 URI). You get back a QR code for desktop users and a tap-to-open deep link for mobile users. The code expires after ten minutes if unused.

  2. 02

    The user sends the transaction. They scan the QR with their wallet. The address, the amount, and the memo are already filled in. They just hit send. For a plain login the amount is zero: Zcash permits zero-value shielded transactions that still carry a memo, so the only cost is the network's standard transaction fee.

  3. 03

    Portal detects it. A zingolib light client, the same wallet engine behind Zcash's reference wallets, watches the chain and the mempool through a public lightwalletd server. When a new transaction decrypts to a memo containing an active code, it's a match. Because Portal watches the mempool, detection usually happens before the transaction is even mined.

  4. 04

    Your app gets a session. The status endpoint flips from pending to detected and returns a signed JWT. Your app treats that token exactly like any other session token. The first login is Zcash-native and feels a little magical; everything after it is ordinary web development.

One detail worth appreciating: the person paying and the person holding the browser session are linked only by the code. Portal never learns who sent the transaction, because on Zcash, nobody can.

Quickstart

If a Portal server is already running, integrating takes two steps. Include the SDK, then call it. The example below points at the live instance at tryportal.xyz, swap in your own server if you are self-hosting.

<!-- include the SDK from your Portal server -->
<script src="https://tryportal.xyz/sdk/portal.js"></script>
// opens the modal, resolves when the mainnet transaction is detected
const session = await Portal.login({ server: 'https://tryportal.xyz' });

console.log(session.token);  // signed JWT, 24 hour expiry
console.log(session.txid);   // the real mainnet transaction id
console.log(session.code);   // the one-time code that was matched

That's it. The SDK handles the modal, the QR, the mobile deep link, the polling, and the error states. If the user cancels, the promise rejects with cancelled. If the code expires, it rejects with challenge expired.

Login

A login challenge is issued with an amount of zero, and the user's wallet sends a zero-value shielded transaction whose only purpose is to deliver the memo.

When the transaction is detected, Portal issues a JWT whose subject is derived from the transaction id. Each login produces a fresh, unlinkable identity. That's a deliberate choice: it means even Portal cannot correlate two visits by the same person. If your app needs persistent accounts, treat the Portal session as the authentication step and attach your own account system on top, the same way you would with any single sign-on provider.

Sessions last 24 hours. The demo site stores the session in sessionStorage so a page refresh doesn't ask the user to pay the network fee again.

Gates

A Gate is a challenge with a price. Same flow, same modal, same detection, but the server requires a minimum amount before it will issue the token. This is how you build paywalls, memberships, and pay-per-article access with no billing system at all.

Two properties make Gates trustworthy:

  • Pricing is server-authoritative. A client asks for a purpose, like gate:vip. The server decides what that purpose costs. Sending a request that claims a lower price does nothing; the server ignores it.
  • Underpayment fails loudly. If someone sends less than the Gate requires, the challenge resolves to underpaid with the exact amounts, and the SDK surfaces a clear message. The code is spent either way, so a partial payment can never be replayed into access.
  • Confirmation depth is configurable. By default a Gate unlocks the instant its payment is seen in the mempool, the same speed as Login. For a Gate expensive enough that you don't want to trust an unconfirmed transaction, set requireConfirmation and it waits for a mined block instead. The client experience doesn't change, it just takes a little longer.
await Portal.login({ server: '', purpose: 'gate:vip' });
// resolves only once a payment meeting the Gate's minimum is on mainnet

The Shielded Wall

The Wall is a small demonstration with a big point. If you add any text after the code in your login memo, that text becomes a public post on the demo site. Which means every message on the Wall traveled through the Zcash mainnet inside an encrypted memo, was decrypted by Portal's keys, and was published without anyone knowing who wrote it.

It exists to make the mechanism visible. When you watch a sentence appear on the Wall seconds after you hit send in your wallet, you understand exactly what Portal is doing under the hood.

Creator accounts

Everything so far pays into the single wallet the operator configured with PORTAL_ADDRESS. Creator accounts let other people accept payments through the same running instance, each with their own receiving address, their own Gates, and their own Paylinks, without anyone running a second server or a second wallet.

The trick is diversified addresses: Zcash unified addresses let one wallet seed derive an unlimited number of receiving addresses that are unlinkable on-chain but all spendable by the same keys. Signing in mints one of these the first time a wallet is seen. The existing wallet watcher already reports activity across every address the seed controls, so a creator's payments are detected the same way Login and Gates always were, just filed under a different account.

There's no username or password. A creator account is authenticated exactly the way anyone signs into Portal: send a zero-value shielded memo with a one-time code from your own wallet. The wallet's reply-to address, hashed, is the account key, so the same wallet always resolves back to the same account, and a new wallet is a new account, created on the spot.

// same challenge/status flow as Login, just a different purpose
POST /auth/challenge      { "purpose": "creator-login" }
GET  /auth/status/:id     // → { "status": "detected", "token": "eyJ…", "creator": { "id", "address", … } }

GET  /creators/me         // Authorization: Bearer <token>. Profile, gates, paylinks, totals

Creator tokens are a separate role from the session tokens Login and Gates issue, so one can never be used in place of the other. The SDK's Portal.login() works unmodified for this: pass purpose: 'creator-login' and the returned token is a creator token.

How the identity works: shielded Zcash has no protocol-level sender field, so the reply address lives in the memo text itself. Portal parses any u1… or zs1… address it finds after the code. You can type your address into the memo manually from any wallet, or enable “include reply address” in Ywallet to have it appended automatically. Use the same address every time, because it is your account key. One honest caveat: the address is a claim, not a cryptographic proof, so anyone who knows your public receiving address could assert it. A reply handshake (Portal memos a code back to the claimed address, proving you can decrypt mail sent to it) is the planned v2 hardening.

Configurable multi-tier Gates

Instead of the single hardcoded gate:vip, a signed-in creator can define as many priced tiers as they want.

PUT    /creators/me/gates/:key   { "amount": "0.02", "label": "Premium", "requireConfirmation": false }
GET    /creators/me/gates
DELETE /creators/me/gates/:key

// public: anyone paying into one of a creator's gates
POST   /creators/:creatorId/gates/:key/challenge

Webhooks

Polling GET /auth/status/:id works for a browser tab, but a backend integration usually wants a push. A creator can register one URL and get an HMAC-signed POST the moment a payment into one of their Gates or Paylinks is detected, and again once it's confirmed.

PATCH /creators/me/webhook   { "url": "https://yourapp.com/hooks/portal" }

Each delivery carries an x-portal-signature header: hex-encoded HMAC-SHA256 of the raw JSON body, keyed with the creator's own webhook secret. Verify it before trusting the payload. Delivery retries up to three times with backoff and never blocks payment detection for other creators if an endpoint is slow or down. For safety, webhook URLs must resolve to a public address; localhost and private IP ranges are rejected so a creator's webhook can't be used to probe the server's own network.

Anonymous, no-login Paylinks and the built-in single-operator Login/Gate still work exactly as before. Creator accounts are additive.

HTTP API

Everything the SDK does happens through five endpoints, plus the creator-account API above if you need multi-tenant. The write endpoints that don't require an existing session, POST /auth/challenge and POST /paylinks, are rate-limited per IP so a single visitor can't flood your instance.

Create a challenge

POST /auth/challenge
{ "purpose": "login" }   // or "gate:vip"
// response
{
  "id": "53aa96d3-…",        // poll this
  "code": "PT-2HFGH5",       // the one-time code
  "amount": "0",             // server-decided, never client-decided
  "uri": "zcash:u1…?amount=0&memo=…",
  "qr": "data:image/png;base64,…",
  "expiresAt": 1783822655044
}

Poll for the result

GET /auth/status/:id

// pending → detected → confirmed. On detection:
{ "status": "detected", "txid": "…", "token": "eyJhbGciOi…" }

// if a Gate was underpaid:
{ "status": "underpaid", "requiredZats": 5000000, "receivedZats": 100000 }

Paylinks and the Wall

POST /paylinks                    // create a payment request
GET  /paylinks/:slug              // amount, label, payment count
POST /paylinks/:slug/challenge    // fresh challenge for one payment
GET  /wall                        // Shielded Wall posts

JavaScript SDK

The SDK is a single dependency-free file. It exposes four functions on window.Portal.

Portal.login(opts)Opens the modal, resolves with { token, txid, code } once detected. Pass purpose for a Gate.
Portal.pay(opts)Same flow for a Paylink. Requires slug.
Portal.receiptPng(s)Renders a downloadable receipt card as a PNG data URL.
Portal.explorerUrl(txid)Link to the transaction on a public block explorer. Spoiler: the explorer shows nothing, and that's the point.

On desktop the modal shows a QR code. On mobile it shows a tap-to-open button that launches the user's wallet with everything pre-filled. You don't have to think about which is which.

Self-hosting

Portal is two processes: a Node server and a zingolib wallet sidecar. There is no database and no full node. The wallet connects to a public lightwalletd server, so it is synced and useful within a minute of first launch.

1. Build the wallet sidecar

You need Rust and, on Linux, the usual build tools plus protobuf-compiler.

git clone https://github.com/zingolabs/zingolib
cd zingolib
cargo build --release --package zingo-cli

2. Create the wallet

./target/release/zingo-cli --data-dir ~/.portal-wallet --nosync addresses
# prints your shielded receiving address (starts with u1…)

Back up the seed phrase immediately with the recovery_info command. If your Gates or Paylinks collect real money, this wallet is real custody.

3. Run the server

cd server && npm install
PORTAL_ADDRESS=u1yourshieldedaddress… npm start
# Portal auth server listening on http://localhost:8787
VariableWhat it does
PORTAL_ADDRESSYour shielded receiving address. Required.
PORTAL_JWT_SECRETSigning key for session tokens. Random per boot if unset.
PORTAL_GATE_VIP_AMOUNTPrice of the demo Gate in ZEC. Defaults to 0.05.
PORTAL_GATE_VIP_REQUIRE_CONFIRMATIONSet to 1 to require a mined confirmation instead of accepting the Gate at 0-conf. Defaults to off.
PORTAL_DATA_DIRWhere Paylinks, Gates, creator accounts, and Shielded Wall posts are persisted to disk. Defaults to server/data.
PORTAL_ZINGO_ARGSOverride how the server launches zingo-cli, as a JSON array.

Creator sign-in shares the same POST /auth/challenge rate limit as Login and Gates. There's no separate account-creation endpoint to abuse, and minting a new identity requires a real broadcast transaction either way.

Privacy and security

It helps to be precise about what each party learns.

  • The website using Portal learns one bit: a valid payment carrying the code it requested arrived on mainnet. Plus the amount, if it set one.
  • Portal's server learns the same, and sees the memo contents, because the memo was addressed to its wallet. It never learns the sender's address. Zcash's shielded protocol hides that from the recipient by design.
  • The public chain shows that a shielded transaction happened. Sender, receiver, amount, and memo are all encrypted. Look up any Portal login on a block explorer and you will find nothing.

Codes are single-use and expire after ten minutes. A replayed or reused code matches nothing. Gate prices live only on the server, so no client can talk the price down. All of this is ordinary, boring security design, which is exactly what you want.

One honest limitation: Portal's server holds the wallet's viewing and spending keys, so treat the host machine the way you would treat any hot wallet host.

Trust model and roadmap

Portal is built on real cryptography, and the parts that are not yet trustless are stated here rather than hidden. Each one has a designed fix, and together they are the roadmap.

Custody

Creator receiving addresses are diversified addresses derived from this instance's own wallet seed. They are unlinkable on chain, but the operator's keys can spend them, so today a creator trusts the operator to forward their funds, the same arrangement as an early hot wallet payment processor. The fix is Unified Full Viewing Keys: a creator hands Portal a viewing key for their own wallet, Portal can then see payments arrive but can never spend them, and money lands directly in the creator's wallet with no custody at all.

Identity

Shielded Zcash has no protocol level sender field, so a creator's identity is the reply address in their login memo. Portal verifies that a real shielded transaction carried the claim, but not that the sender owns the claimed address. The fix is a reply handshake: Portal sends an encrypted memo containing a secret back to the claimed address, and only the true owner can read it and echo it back, because a shielded memo can only be decrypted by its recipient. That proves ownership while revealing nothing on chain, since fully shielded transactions hide sender, receiver, amount, and memo from every observer.

Availability

One wallet process watches the chain for the whole instance. Commands are serialized and the process is crash hardened, but a production deployment would run redundant watchers behind a health check.

What comes next

  • The reply handshake, so a claimed address becomes a proven address.
  • Viewing key based creator accounts, removing custody entirely.
  • Pay from any chain and settle in ZEC through NEAR Intents, so a payer does not need to hold ZEC to pay a Paylink.
  • An automated test suite over the memo parser, the challenge lifecycle, and webhook signing.

These tradeoffs were the right ones for version one because they made a complete, working product possible on mainnet. Knowing exactly where the seams are is how they get closed.

Troubleshooting

The modal says "watching mainnet" forever.

Most often the wallet sent the payment without the memo. The memo is the credential; without it, Portal sees an anonymous payment and correctly matches it to nothing. Use the QR or deep link rather than typing the address by hand, and confirm your wallet shows a memo field before sending.

My wallet has no memo field.

Some multi-coin wallets support shielded sending but never built memo entry. Unstoppable is a known example. Use a Zcash-native wallet: Zashi and Ywallet both show the memo pre-filled when they scan a Portal QR.

The challenge expired before I paid.

Codes live for ten minutes. Close the modal and start again; a fresh code costs nothing.

I paid a Gate but sent too little.

The status resolves to underpaid with the exact required and received amounts. That code is now spent. Request a new challenge and send the full amount. The earlier partial payment is not lost; it arrived at the server's wallet, but it cannot be upgraded into access.

How fast is detection, really?

Portal polls the mempool through its light client every few seconds, so detection typically lands well before the transaction is mined. End to end, expect a few seconds from wallet send to session token. Zcash mainnet blocks arrive roughly every 75 seconds; you are not waiting for one.