Skip to main content

What is a keeper?

A keeper is any script that calls charge() on the Vowena contract for subscriptions that are due. charge() is permissionless — the contract validates every precondition on-chain, so the keeper just signs an invoke op and submits it. The keeper pays the tiny transaction fee (~$0.00001 in XLM) while USDC flows directly from subscriber to merchant.
You don’t have to build this yourself. The Vowena dashboard ships a managed keeper that runs as a Vercel cron every minute. This page is for teams that want to self-host.

How it works

  1. Keeper enumerates every subscription that might be due (discovery).
  2. For each subscription, it builds a charge() tx via client.buildCharge.
  3. It signs with its own keypair and submits to the network.
  4. If the charge is due and the allowance is sufficient, USDC moves subscriber → merchant and charge_ok is emitted. If not, the contract rejects harmlessly — no funds move, nothing breaks.
The keeper never holds USDC. It only holds a small XLM balance for tx fees.

A minimal keeper loop

The only two non-obvious bits are (a) how to discover subs, and (b) why submissions must be serial.
import {
  VowenaClient,
  NETWORKS,
  Keypair,
  rpc as SorobanRpc,
  TransactionBuilder,
  Networks,
} from "@vowena/sdk";

const keeper = Keypair.fromSecret(process.env.KEEPER_SECRET!);
const client = new VowenaClient({
  contractId: NETWORKS.testnet.contractId,
  rpcUrl: NETWORKS.testnet.rpcUrl,
  networkPassphrase: NETWORKS.testnet.networkPassphrase,
});
const server = new SorobanRpc.Server(NETWORKS.testnet.rpcUrl);

async function runOnce(merchant: string) {
  // 1. Discovery — parallel, read-only
  const planIds = await client.getMerchantPlans(merchant, keeper.publicKey());
  const subLists = await Promise.all(
    planIds.map((id) =>
      client.getPlanSubscribers(id, keeper.publicKey()).catch(() => []),
    ),
  );
  const subIds = Array.from(new Set(subLists.flat()));

  // 2. Submissions — SERIAL with a fresh account each time
  for (const subId of subIds) {
    await chargeOne(subId);
  }
}

async function chargeOne(subId: number) {
  // Fetch a fresh account to pick up the current sequence number. Skipping
  // this and reusing one account snapshot across a batch causes every tx
  // after the first to be rejected as tx_bad_seq.
  const account = await server.getAccount(keeper.publicKey());
  const xdr = await client.buildCharge(keeper.publicKey(), subId);
  const tx = TransactionBuilder.fromXDR(xdr, Networks.TESTNET);
  tx.sign(keeper);
  await server.sendTransaction(tx);
}
Don’t parallelize submissions from one signer. Stellar requires sequence numbers to be the exact next value per source account. Firing charge() transactions in parallel with the same keeper means every tx after the first lands with tx_bad_seq. Serial + fresh server.getAccount() per tx is the safe pattern.

Scheduling

A cron or setInterval call every 60 seconds is plenty for most plans. charge() is idempotent within a period — calling it before the next period is due simply no-ops, so you can’t over-bill by polling too often.
setInterval(() => runOnce("GMERCHANT...ADDR").catch(console.error), 60_000);

Using the dashboard’s managed keeper

If you don’t want to operate your own infra, enable the dashboard’s managed keeper instead:
  • Vercel cron hits /api/cron every minute.
  • The cron signs with VOWENA_ISSUER_SECRET (set once in your Vercel env).
  • Submissions are serial with a fresh account fetch per tx — same pattern as above.
See Keeper setup for step-by-step configuration.

Failure modes

FailureWhat the contract doesWhat the keeper should do
Subscription not yet dueReturns false, emits nothingKeep trying next period
Insufficient balanceEmits charge_fail, sets failed_at, starts the grace windowRetry until grace expires
Grace expired, still failingTransitions subscription to PausedStop trying until subscriber reactivates
Max periods reachedTransitions subscription to ExpiredRemove from the charge list
Subscriber cancelledNo effect (contract already handled it)Remove from the charge list
All of these are handled automatically by calling charge() — the keeper doesn’t need to inspect subscription state first.