The merchant back-office for x402 sellers on Stellar
Verify a Receipt · Live Dashboard · Documentation · accensa-contracts
Part of the Accensa merchant back-office for x402 sellers on Stellar. This repo holds the off-chain half — indexer, dashboard, and SDK. The Soroban contracts live in
accensa-contracts.
When you put an x402 paywall in front of an API, payment stops being an event your backend records and becomes something that happens on a ledger you don't control. An agent pays, retries, and gets its data — and your database never hears about it.
So the merchant is left without the things every other payment stack gives them:
- No revenue view. Payments land as SAC transfers on Stellar. Reconstructing "what did I earn today, and from which route" means reading chain data, not querying your own database.
- No attribution. A transfer tells you an amount and a payer. It doesn't tell you which endpoint was bought, which is exactly what you need to price anything.
- No way to answer a dispute. When an agent operator claims they were double charged, both sides are looking at different records.
accensa-app is the back-office that closes this. The indexer reconstructs payment
history directly from Stellar, the SDK attributes each payment to the route that
earned it, and the dashboard turns that into something a merchant can actually read —
backed by receipts anyone can verify against
ReceiptAnchor on-chain.
Sub-cent fees are what make per-request agent payments viable in the first place, and SAC transfer events give the indexer a clean, uniform stream to reconstruct from — the same shape whether a merchant settles in XLM or native USDC. Batched Merkle anchoring on Soroban then makes every receipt independently provable for a fraction of a cent, which is the only way verifiability survives micropayment economics.
agent ──pays──▶ your x402 endpoint
│ attachAccensaHook() tags the route
▼
Stellar ledger (SAC transfer)
│
┌─────────────┴──────────────┐
▼ ▼
/api/sync (indexer) ReceiptAnchor
decodes SAC transfers anchors Merkle roots
│ │
▼ │
PostgreSQL │
│ │
▼ ▼
Next.js dashboard ◀──verify_receipt(leaf, proof)
| Component | Path | What it does |
|---|---|---|
| Indexer | apps/web/src/app/api/sync |
Decodes Stellar Asset Contract transfer events addressed to the merchant and persists them to PostgreSQL. Runs on a schedule; tracks a ledger cursor so it never rescans or double-counts. |
| Dashboard | apps/web/ |
Next.js app showing payments, totals, and receipt verification. |
| SDK | packages/sdk/ |
verifyReceipt() for off-chain Merkle verification, and attachAccensaHook() / createSettleHook() for reporting route-level attribution from your x402 server. |
| Demo merchant | apps/demo-merchant/ |
Minimal paid endpoint for exercising the flow end to end. |
verifyReceipt() mirrors ReceiptAnchor.verify_receipt exactly — sorted-pair
SHA-256, so proofs carry no left/right position flags. An agent can check a receipt
without any network call at all:
import { verifyReceipt } from '@accensa/sdk';
// Batch #1, anchored live on testnet.
const ok = verifyReceipt(
'c476fc0553303ec4275bd4cb50ab7fa8182e343dbc4c721d7e2076fd77a5b56c',
[
'7ca64ee60e2b975f59f2a1f1cc1526d5b001a5c29f70291f316ba1c012a01bd1',
'1733fad16ada0c23d8cdaff52bea66bea308dddddcb79348842acef0065c9615',
],
'c6ccdcdb57896fa4999d9dea6a5ef40523d55e46cf32b621d7ea4a582d90e6ac',
); // trueThe same leaf, proof, and root verify true on-chain against
CBHRJU7C…,
and a forged leaf returns false in both implementations. See
DEPLOYMENTS.md
for the commands.
Node 22+, pnpm 9, and a PostgreSQL instance.
# 1. Database
docker run --name pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres
# 2. Configure apps/web/.env.local
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
MERCHANT_ADDRESS=GCALKSGAZRJLSUEJT3M5W6LN4R7XQOLIRCOS6ZA6EDZVTZDBIIPPFKJ6
STELLAR_RPC_URL=https://soroban-testnet.stellar.org
HOOK_API_KEY=any-shared-secret # required for /api/hook/settle
# 3. Dashboard (schema is created on first request)
cd apps/web
pnpm install
pnpm devThen trigger an index run with curl localhost:3000/api/sync, and the dashboard at
/dashboard will show whatever settled to MERCHANT_ADDRESS. If nothing has, it
says so — the dashboard never invents rows to fill space.
Routes: / is the landing page, /dashboard the merchant view, and /verify the
public receipt verifier, which needs no account.
A SAC transfer event carries the payer, amount, and asset — never the HTTP route
that was paid for. That mapping exists only inside your server, at the moment x402
settles, so it has to be reported rather than indexed.
Set HOOK_API_KEY on both sides, then report settlements from your x402 server:
import { createSettleHook } from '@accensa/sdk';
resourceServer.onAfterSettle(
createSettleHook({ indexerUrl: 'https://your-accensa.vercel.app', apiKey: process.env.HOOK_API_KEY }),
);Or, if you only have the response to work from, mount attachAccensaHook() after your
x402 middleware — it reads the X-PAYMENT-RESPONSE header.
POST /api/hook/settle is the endpoint behind both. It is the only write path into
payments not derived from the ledger, so it fails closed: with no HOOK_API_KEY
configured it accepts nothing. Reported attribution is marked with hook_reported_at,
keeping merchant-reported fields distinguishable from on-chain ones. Settlements for
transfers the indexer has not reached yet are staged and completed on the next run.
apps/demo-merchant/ is a working example.
Testnet IDs are published in
accensa-contracts/deployments/testnet.env.
CI runs ESLint, tsc --noEmit, the SDK and web test suites, and a production build
on every push. Failures fail the build — no suppressed exit codes.
cd apps/web && pnpm lint && pnpm tsc --noEmit && pnpm test
cd packages/sdk && pnpm testThe web suite covers SAC event decoding against a real captured testnet event, and the decimal arithmetic that keeps payment amounts off floating point.
See CONTRIBUTING.md. Security policy in SECURITY.md.
MIT — see LICENSE.
See db-setup.md for database setup instructions, and
DEPLOYMENT.md for the deployed topology — how Vercel, Supabase,
and the GitHub Actions indexer fit together, which environment variables live
where, and the traps that have cost time here (the alias that --prod does not
move, the Hobby cron limit, and the IPv6-only Direct connection string).