Subscription billing for businesses that get paid in stablecoins.
Strimz is a USDC billing layer for businesses that want to charge customers on-chain without building the on-chain plumbing themselves. The platform handles one-shot charges, recurring subscriptions, refunds, invoices, agent escrow, and cross-chain settlement. Everything settles on Arc, Circle's stablecoin-native L1, in roughly 13 seconds.
Customers see a hosted checkout. Developers see an SDK. The contracts enforce idempotency themselves, so a retried charge can never become a double charge, and there is no chargeback window.
This repository is the entire platform: smart contracts, backend services, merchant dashboard, hosted checkout, public SDKs, and the infrastructure tooling that runs them.
Server-side, after issuing an sk_test_ key in the dashboard:
import { Strimz } from '@strimz/sdk'
const strimz = new Strimz({ apiKey: process.env.STRIMZ_KEY! })
const session = await strimz.paymentSessions.create({
amount: '50000000', // 50 USDC, 6 decimals
currency: 'USDC',
description: 'Pro plan, August',
successUrl: 'https://your-site.com/thanks',
})
console.log(session.checkoutUrl)Send the customer to session.checkoutUrl. They connect a wallet,
sign one EIP-3009 authorisation, and the relayer broadcasts the
on-chain transaction. You receive payment.completed over a signed
webhook the moment it confirms.
The full walkthrough lives in
apps/web/content/docs/quickstart.mdx.
- Quick start
- Repository layout
- Tech stack
- System architecture
- Local development
- Environment configuration
- Project structure
- Deployment targets
- Contributing
- License
The monorepo is split into deployables (apps/) and shared libraries (packages/). Every cross-cutting concern lives in a package so that no two apps duplicate logic.
| App | Runtime | Purpose |
|---|---|---|
apps/web |
Next.js 15 | Merchant dashboard, hosted checkout, public marketing, docs |
apps/api |
NestJS (Node 22) | HTTP API: auth, merchants, sessions, subscriptions, refunds, webhooks |
apps/indexer |
Go | Listens to Arc, projects on-chain events into Postgres, publishes domain events to Redis |
apps/scheduler |
NestJS (Node 22) | BullMQ workers: subscription charging, webhook delivery, agent jobs |
apps/agent |
NestJS (Node 22) | Strimz AutoPay Agent. ERC-8004 identity, ERC-8183 commerce, recovery, routing |
| Package | Purpose |
|---|---|
packages/contracts |
Foundry workspace. Solidity contracts, tests, deploy scripts |
packages/sdk |
@strimz/sdk. Server SDK for Node and Edge runtimes |
packages/sdk-react |
@strimz/sdk-react. Drop-in React components and hooks |
packages/db |
Prisma schema and generated client, shared by every Node app |
packages/shared-types |
Zod schemas; TS types inferred from them |
packages/shared-config |
Chain registry, token registry, fee tiers. Single source of truth |
packages/shared-crypto |
HMAC, webhook signing, nonces. Runs in Node and Edge |
packages/ui |
shadcn primitives configured with Strimz brand tokens |
packages/tsconfig |
Shared TypeScript configurations |
packages/eslint-config |
Shared ESLint configurations |
| Path | Purpose |
|---|---|
docker-compose.yml |
Local dev stack (Postgres, Redis, Anvil) with opt-in app profile |
.github/workflows |
CI pipelines (ci.yml, docker.yml) |
.github/dependabot.yml |
Weekly auto-PRs for npm, Go modules, and GitHub Actions |
| Layer | Technology |
|---|---|
| Language | TypeScript 5.7, Solidity 0.8.x, Go 1.25 |
| Smart contracts | Foundry, OpenZeppelin Contracts |
| Backend HTTP | NestJS 11 (Node 22) |
| Backend workers | NestJS standalone + BullMQ; Go for the indexer |
| Frontend | Next.js 15 (App Router), React 19, Tailwind v4, shadcn/ui |
| Wallet & chain | viem 2.x, wagmi 2.x, Privy (merchant auth), Reown AppKit (payer connect) |
| Database | PostgreSQL 16, Prisma 7, Redis 7 |
| Validation | Zod 3 |
| Build | Turborepo 2, pnpm 10, tsup |
| Observability | OpenTelemetry, Sentry, Pino |
| Hosting | Vercel (web), Render (api, indexer, scheduler, agent, Postgres, Redis) |
┌──────────────────────────────────────────┐
│ Arc Blockchain │
│ StrimzRegistry · Payments · Subs · │
│ FeeCollector · AgentRegistry · Escrow │
└───────────┬───────────────────┬──────────┘
│ events │ tx
▼ ▲
┌────────────┐ HTTP ┌────────────┐ Redis ┌────────────┐
│ Merchant │ ────────► │ │ stream │ │
│ web app │ │ apps/api │ ◄─────► │ scheduler │
│ (Next.js) │ ◄──────── │ (NestJS) │ │ (NestJS) │
└─────┬──────┘ webhooks │ │ │ │
│ └────┬───────┘ └────┬───────┘
│ wallet │ rw │ rw
│ (viem/wagmi) ▼ ▼
│ ┌──────────────────────────────────┐
│ │ PostgreSQL 16 (Render) │
│ │ (source of read state) │
│ └──────────────────────────────────┘
│ ▲ rw
│ │
│ ┌────────────┐│
│ │ indexer ├┘
│ │ (Go) │
│ └─────┬──────┘
│ │ event subscribe (JSON-RPC)
▼ ▼
┌────────────┐ ┌──────────────────────────────────┐
│ payer's │ │ Arc │
│ wallet │ ────────► │ (USDC gas, native EURC, USYC) │
└────────────┘ └──────────────────────────────────┘
┌────────────┐
│ agent │ Read-only. Listens to Redis
│ (NestJS) │ events, runs ERC-8004 identity
└────────────┘ + ERC-8183 escrow flows.
The contract is the source of truth for money. Postgres is a read-side projection of the chain. Webhooks are the merchant's view of the projection.
Located in packages/contracts/src. Built and tested with Foundry.
Modules.
| Module | Contracts |
|---|---|
core/ |
StrimzRegistry, StrimzPayments, StrimzSubscriptions |
fees/ |
FeeCollector |
tokens/ |
TokenWhitelist |
access/ |
StrimzAccessControl, Pausable |
agent/ |
StrimzAgentRegistry (ERC-8004), StrimzAgentEscrow (ERC-8183) |
interfaces/ |
One interface per public contract |
Design rules.
- Registry-backed merchants. Merchants are identified by an on-chain id.
StrimzRegistryholds the merchant's payout address, fee bps, and active flag. Payments and subscriptions read from the registry, so payout addresses can rotate without redeploying. - Idempotent subscription charges. Every charge call carries a
bytes32 chargeAttemptId. The contract rejects reused ids. This kills the double-charge class of bugs that polling cron jobs are prone to. - Events as the canonical projection source. Contracts emit rich events (
PaymentExecuted,SubscriptionCharged,FeeAccrued,RefundRecorded). The indexer rebuilds Postgres from these events, so the database is reconstructable from chain history at any time. - Selective upgradeability. The policy contracts (
StrimzRegistry,FeeCollector,TokenWhitelist,StrimzAgentRegistry,StrimzAgentEscrow) are UUPS because policy changes. The value-moving contracts (StrimzPaymentsandStrimzSubscriptions) are deliberately immutable. A new version of either is a fresh deploy that the registry then points to. - Pull payments for fees. Fees accrue to
FeeCollector. The treasury withdraws on a schedule. This shrinks the reentrancy surface and isolates accounting. - Owner-pausable kill switch. Every value-moving function respects
Pausable. If something is wrong, all transfers halt at once.
Three Node services (NestJS) and one Go service. Each runs as its own process. A slow webhook delivery cannot block a payment session, and a stuck cron cannot block the API.
apps/api. HTTP API. Module-per-bounded-context layout. Examples: merchants, api-keys, payment-sessions, subscriptions, refunds, webhooks, compliance, analytics, agents. Common cross-cutting concerns (ApiKeyGuard, JwtGuard, TierGuard, ZodValidationPipe, StrimzError filter) live in common/. Side effects (Prisma, Redis, BullMQ, Resend, viem) live in infra/ and are injected through interfaces. Controllers validate and delegate. Services hold the domain rules. Repositories own persistence. The API never reads from the chain directly. It reads from Postgres, which the indexer keeps current.
apps/indexer. Chain projector (Go). Subscribes to Arc events
through go-ethereum. For each event it parses to a domain event,
writes to Postgres (advancing a per-contract cursor), and publishes
to a Redis stream so the scheduler and agent can react in real
time. The indexer is in Go because the workload is bounded by I/O
concurrency and long-running connections, which Go handles cleanly.
Resumable on restart, idempotent on replay.
apps/scheduler. Workers. NestJS standalone app backed by BullMQ. Three queues:
subscription.due. Cron-driven. Reads due subscriptions from Postgres, derives a deterministicchargeAttemptId = keccak256(subscriptionId, periodEndAt)per sub, and asks the relayer inapps/apito broadcastbatchChargeon the contract.webhook.deliver. Exponential backoff (1m, 5m, 30m, 2h, 12h, five attempts), then dead-lettered with a merchant alert.agent.action. Drives the AutoPay Agent's scheduled actions: subscription recovery retries, daily cashflow digests, CCTP settle.
The scheduler does not hold any signing key. When a worker needs an
on-chain write, it calls into apps/api, which signs through its
KMS provider and broadcasts. This keeps the most sensitive secret
in exactly one process.
apps/agent. The AutoPay Agent. NestJS service that holds the agent's ERC-8004 identity, drives ERC-8183 escrow operations, and orchestrates recovery, routing, cashflow, and commerce flows. Reads the Redis event stream the indexer publishes and reacts within a bounded SLA. Configurable per merchant via AgentMerchantConfig. Holds no signing key.
apps/web is a single Next.js 15 App Router app that serves three distinct audiences:
(marketing). Public landing, pricing, docs.(auth). Merchant sign-in and sign-up.(dashboard). Authenticated merchant surface: overview, transactions, subscriptions, customers, refunds, webhooks, API keys, agent settings, storefront, invoices, treasury, billing, settings.pay/[sessionId]andsub/[planId]. Payer-facing hosted checkout, isolated so it can later be embedded in iframes without dashboard chrome leaking through.invoice/[id]. Public invoice page.
Server components are the default. Interactive leaves (wallet connect, charts, forms) are client components. Forms use React Hook Form with the same Zod schemas the API validates against, so client and server cannot disagree on shape. Server state is owned by TanStack Query. Client state is small and lives in component-local React state or thin Zustand slices.
Two wallet ecosystems coexist by design, each scoped to its own audience:
- Privy. Merchant identity. Email / Google / wallet login on
/signupand/login, embedded wallet for the dashboard owner. Configured with Arc asdefaultChainso the embedded wallet is created on Arc rather than Ethereum mainnet. - Reown AppKit. Anonymous payer wallet connect on the public
/pay/[sessionId]and/sub/[planId]checkout pages. Brings the long tail of WalletConnect-compatible wallets (MetaMask, Rainbow, Coinbase Wallet, Trust, etc.) without requiring the payer to have a Strimz account.
The two domains never share connector state. They live in different routes serving different actors. Both wrap the app in apps/web/src/components/providers.tsx, with Wagmi/AppKit hydrated via cookies in apps/web/src/app/layout.tsx to avoid SSR hydration mismatch on the initial paint.
1. Merchant calls the SDK strimz.paymentSessions.create({ amount, currency, ... })
2. SDK posts POST /v1/payment-sessions apps/api validates, persists, returns { id, checkoutUrl }
3. Merchant redirects the payer apps/web/pay/[sessionId]
4. Payer connects a wallet viem + wagmi + Reown AppKit (any major wallet)
5. Payer signs one EIP-3009 message single wallet prompt, no gas paid by the payer
6. Relayer (apps/api) broadcasts the tx StrimzPayments.payWithAuthorization splits the fee atomically
7. Indexer ingests PaymentExecuted writes Transaction, advances the cursor, publishes to Redis
8. Scheduler runs the webhook job POSTs to the merchant URL, signed with HMAC-SHA256
9. Merchant verifies the signature @strimz/sdk verifyWebhookSignature
Every step after (6) is asynchronous and idempotent. If any step fails, the chain remains the source of truth and the projection rebuilds.
A. Merchant defines a SubscriptionPlan (price, interval, currency, gracePeriod)
B. Customer subscribes (signs an EIP-2612 permit; relayer calls permitAndCreateSubscription)
C. Scheduler runs subscription.due cron (every 15 min; selects subs where nextChargeAt <= now)
D. Worker derives chargeAttemptId (keccak256(subscriptionId, periodEndAt); deterministic, replay-safe)
E. Relayer (apps/api) signs and broadcasts batchCharge(ids[], attemptIds[])
├─ Contract verifies each subscription is active and chargeAttemptId is unused
├─ For each: pulls funds, splits fee, emits SubscriptionCharged
└─ Returns a per-id outcome (charged | insufficient | revoked)
F. Indexer ingests the events (updates SubscriptionCharge status)
G. On insufficient funds:
├─ Worker schedules a retry inside the merchant's grace period
├─ AutoPay Agent emails the payer a low-balance reminder
└─ If grace expires, the subscription flips to lapsed and the subscription.lapsed webhook fires
H. Webhook delivery follows the same retry path as one-shot payments
Two consequences of how the contract is structured:
- A subscription is never double-charged for the same period. The contract's
chargeAttemptIdmapping rejects reuse. - A subscription cancelled on-chain between the cron tick and the broadcast is skipped. The contract rejects the call, and the worker records the outcome.
- Strimz never holds keys. The platform stores wallet addresses only. Merchants and payers sign every value-moving transaction from their own wallet. There are no encrypted seed phrases anywhere in the database.
- API keys. Stored as
prefix + sha256(key). The full key is shown exactly once at creation. Test and live keys are entirely separate; calling a live endpoint with a test key is a typed error. - Webhook signatures. Every webhook is signed with HMAC-SHA256 over
t=<unix>,v1=<hex>. Merchants verify with@strimz/sdk. Signatures older than 5 minutes are rejected to prevent replay. - Role-based access inside merchants. Owner / Admin / Developer / Read-only. Refunds, key rotation, and tier changes are gated by role.
- Rate limiting. Per-API-key in Redis, per-IP at the edge. The limits are tier-aware.
- Transactional audit log. Every mutating action writes an
AuditLogrow. Refunds, key rotations, tier changes, agent enable/disable. - Contract security. Independent third-party audit before any mainnet deployment.
Pausablekill switch on every value-moving function.OwnableandAccessControlfor privileged ops with a multi-sig treasury. - Secrets. No secrets in the repo.
.env.examplefiles are committed; real.envfiles are gitignored. Production secrets live in Render's environment manager.
- Node 22 (
nvm useto match.nvmrc) - pnpm 10 (
corepack enable && corepack prepare pnpm@10 --activate) - Docker (for the local Postgres + Redis + Anvil stack)
- Foundry (
curl -L https://foundry.paradigm.xyz | bash && foundryup) - Go 1.25 (only required to develop the indexer)
git clone <repo-url> strimz
cd strimz
git submodule update --init --recursive # Foundry deps live in packages/contracts/lib
pnpm install
# Optional — copy compose env template if you need to override defaults
cp .env.docker.example .env
# Boot local infra (Postgres, Redis, Anvil)
docker compose up -d
# Apply Prisma migrations against the compose Postgres
pnpm --filter @strimz/db db:migrate
# Run every app in watch mode via Turbo
pnpm devThe default docker compose up brings up three infra services:
| Service | Host port | Purpose |
|---|---|---|
postgres |
5432 |
Source of read state for every Node app |
redis |
6379 |
BullMQ queues, idempotency cache, rate-limit buckets |
anvil |
8545 |
Local EVM devnet (chain id 31337) for contract work |
For an end-to-end smoke test of the build artifacts (rare; mostly pre-deploy), the full profile also rebuilds and boots api, scheduler, agent, and indexer from their Dockerfiles:
docker compose --profile full upApps in the full profile reach Postgres / Redis / Anvil via compose service names; apps run from your shell via pnpm dev use localhost.
| Command | Description |
|---|---|
pnpm dev |
Run all apps in watch mode |
pnpm build |
Build everything via Turbo |
pnpm lint |
Lint everything |
pnpm typecheck |
Typecheck everything |
pnpm test |
Test everything |
pnpm format |
Prettier write |
pnpm format:check |
Prettier verify (CI runs this; same rules as format) |
pnpm changeset |
Create a changeset for an SDK release |
pnpm --filter @strimz/contracts forge:test |
Run Foundry tests |
pnpm --filter @strimz/db db:migrate |
Apply Prisma migrations |
pnpm --filter web dev |
Run only the web app |
Three parallel jobs run on every PR via .github/workflows/ci.yml:
| Job | What it runs |
|---|---|
node |
pnpm install → pnpm build (turbo ^build, generates the Prisma client first) → format:check → lint → typecheck → test |
go |
go vet, go build, race-tested unit tests on apps/indexer |
foundry |
forge fmt --check, forge build --sizes, forge test -vvv. Checks out submodules so forge-std and OpenZeppelin libs resolve. |
A separate docker.yml workflow builds every app's Dockerfile via a matrix, but only when a Dockerfile or docker-compose.yml actually changes. Otherwise it would be a slow tax on every unrelated PR.
Dependabot opens grouped weekly PRs for npm (@nestjs/*, next + react, lint/format tooling each in their own group), Go modules (apps/indexer), and GitHub Actions.
Each app has its own .env.example listing required and optional variables. Copy each one to .env for local use. Production values live in Render and Vercel, never in the repo. Recurring variables across apps:
| Variable | Used by | Purpose |
|---|---|---|
DATABASE_URL |
api, indexer, scheduler, agent | Postgres connection string |
REDIS_URL |
api, scheduler, agent | Redis connection string |
ARC_RPC_URL |
api, indexer, scheduler, agent | Arc JSON-RPC endpoint |
ARC_CHAIN_ID |
all | Chain id (5042002 testnet) |
STRIMZ_REGISTRY_ADDRESS |
all | Deployed StrimzRegistry address |
STRIMZ_WEBHOOK_SIGNING_SECRET |
api, scheduler | HMAC secret for webhook signatures |
WEBHOOK_SECRET_ENCRYPTION_KEY |
api, scheduler | AES-256-GCM key for encrypting per-endpoint webhook secrets at rest |
JWT_SECRET |
api | Merchant session JWT secret |
PRIVY_APP_ID / PRIVY_APP_SECRET |
web, api | Merchant auth (email + embedded wallet) |
NEXT_PUBLIC_REOWN_PROJECT_ID |
web | Reown AppKit project id for payer wallet connect on checkout |
NEXT_PUBLIC_TURNSTILE_SITE_KEY |
web | Cloudflare Turnstile site key for bot-protection on /signup |
TURNSTILE_SECRET_KEY |
api | Cloudflare Turnstile secret for the server-side Siteverify call |
RESEND_API_KEY |
api, scheduler | Transactional email |
SENTRY_DSN |
all | Error tracking (optional in dev) |
strimz/
├── apps/
│ ├── web/ Next.js 15. Dashboard, checkout, marketing.
│ ├── api/ NestJS HTTP API (Dockerfile)
│ ├── indexer/ Go chain projector (Dockerfile)
│ ├── scheduler/ NestJS BullMQ workers (Dockerfile)
│ └── agent/ NestJS AutoPay Agent (Dockerfile)
├── packages/
│ ├── contracts/ Foundry workspace
│ ├── sdk/ @strimz/sdk
│ ├── sdk-react/ @strimz/sdk-react
│ ├── db/ Prisma schema + client
│ ├── shared-types/ Zod schemas
│ ├── shared-config/ Chains, tokens, tiers
│ ├── shared-crypto/ HMAC, webhook verify
│ ├── ui/ shadcn + Strimz brand
│ ├── tsconfig/ Base TS configs
│ └── eslint-config/ Base ESLint configs
├── .github/
│ ├── workflows/
│ │ ├── ci.yml Parallel node / go / foundry jobs
│ │ └── docker.yml Matrix Dockerfile build (paths-filtered)
│ └── dependabot.yml Grouped weekly dependency PRs
├── docker-compose.yml Local infra stack + opt-in `full` app profile
├── .env.docker.example Compose env override template
├── pnpm-workspace.yaml
├── turbo.json
└── package.json
| Surface | Host |
|---|---|
apps/web |
Vercel |
apps/api |
Render (Web Service) |
apps/indexer |
Render (Background Worker) |
apps/scheduler |
Render (Background Worker) |
apps/agent |
Render (Background Worker) |
| Postgres 16 | Render (Managed PostgreSQL) |
| Redis 7 | Render (Managed Key Value) |
| Smart contracts | Arc testnet (5042002), then Arc mainnet |
- Create a branch from
main. Branch naming:feat/<scope>,fix/<scope>,chore/<scope>. - Conventional commits:
feat(api): ...,fix(contracts): ...,chore(repo): .... - Every SDK-touching PR ships a changeset.
pnpm format:check && pnpm lint && pnpm typecheck && pnpm testmust pass. These are the same checks CI runs (see Continuous integration).- Contract changes require Foundry tests, including invariant tests for value-moving functions.
- The root
package.jsoncarries apnpm.overridesblock pinning@wagmi/connectorsto^5.x. Don't remove it without verifying.@reown/appkit-adapter-wagmi's open-ended optional peer otherwise lets pnpm resolve the broken@wagmi/connectors@8.xline, which breaks the web app's build. The_overridesNotefield in that file carries the full reason.
MIT © 2026 Strimz.