Subscription Payments¶
Recurring payments at fixed intervals — the bread and butter of SaaS, memberships, and any service with predictable billing.
Overview¶
Subscription policies charge a fixed amount on a fixed schedule (daily, weekly, monthly, etc.). The user sets it up once, and payments execute automatically via Solana's token delegation. No lock-up — funds stay in the user's wallet until each payment is due.
User creates policy --> Delegates token spending authority
|
+----------+-----------+-----------+----------+
| | | |
Payment 1 Payment 2 Payment N ...
$10/month $10/month $10/month
auto-renew auto-renew auto-renew
When to Use¶
| Good For | Not Ideal For |
|---|---|
| SaaS monthly/annual billing | Project-based deliverables |
| Content memberships (streaming, newsletters) | Variable usage (API calls, compute) |
| Recurring donations | One-off payments |
| Software licenses & maintenance | Milestone-based contracts |
| API access with fixed monthly fees | Unpredictable billing amounts |
| Any predictable, repeating payment |
On-Chain Specification¶
PolicyType::Subscription {
amount: u64, // Fixed payment amount per interval (lamports)
auto_renew: bool, // Continue after each payment?
max_renewals: Option<u32>, // Cap on total payments (None = unlimited)
payment_frequency: PaymentFrequency,
next_payment_due: i64, // Unix timestamp for next execution
padding: [u8; 97], // 128-byte alignment
}
PaymentFrequency Enum¶
pub enum PaymentFrequency {
Daily = 0,
Weekly = 1,
BiWeekly = 2,
Monthly = 3,
Quarterly = 4,
SemiAnnually = 5,
Yearly = 6,
}
Account Size¶
Each Subscription variant is exactly 128 bytes, consistent with all other policy types. This enables seamless upgrades without breaking existing policies.
Creating a Subscription¶
Basic Monthly Subscription¶
import { Tributary } from "@tributary-so/sdk";
import { BN } from "@coral-xyz/anchor";
import { createMemoBuffer } from "@tributary-so/sdk";
import { PublicKey, Transaction } from "@solana/web3.js";
const sdk = new Tributary(connection, wallet);
const USDC_MINT = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
const recipient = new PublicKey("BxKpT3mZQ5HgeRZFMfWVBpDCmCN8eYwGmCjL7m9mVq");
const gateway = new PublicKey("6ntm5rWqDFefET8RFyZV73FcdqxPMbc7Tso3pCMWk4w4");
const instructions = await sdk.createSubscription(
USDC_MINT,
recipient,
gateway,
new BN(10_000_000), // $10.00 per month (USDC has 6 decimals)
true, // auto-renew
null, // no max renewals (unlimited)
{ monthly: {} }, // payment frequency
createMemoBuffer("user_123_pro_plan", 64)
);
const tx = new Transaction().add(...instructions);
const signature = await sendAndConfirm(connection, tx, [wallet.payer]);
Available Payment Frequencies¶
const frequencies = {
daily: { daily: {} },
weekly: { weekly: {} },
biweekly: { biWeekly: {} },
monthly: { monthly: {} },
quarterly: { quarterly: {} },
semiAnnually: { semiAnnually: {} },
yearly: { yearly: {} },
};
Annual Subscription with Cap¶
// $100/year, max 3 years, then auto-pauses
const instructions = await sdk.createSubscription(
USDC_MINT,
recipient,
gateway,
new BN(100_000_000), // $100/year
true, // auto-renew
3, // max 3 renewals
{ yearly: {} },
createMemoBuffer("annual_membership_3yr", 64)
);
Custom Start Date¶
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1);
nextMonth.setHours(0, 0, 0, 0);
const instructions = await sdk.createSubscription(
USDC_MINT,
recipient,
gateway,
new BN(5_000_000), // $5/month donation
true,
null,
{ monthly: {} },
createMemoBuffer("charity_monthly", 64),
new BN(Math.floor(nextMonth.getTime() / 1000)) // startTime
);
How It Works¶
Payment Execution Flow¶
- Policy creation — user creates policy, delegates token spending authority
- Waiting —
next_payment_duetimestamp hasn't been reached yet - Execution — when
now >= next_payment_due, anyone (typically a gateway signer) callsexecute_payment - Transfer — amount moves from user's token account to recipient, minus protocol + gateway fees
- Advance —
next_payment_dueadvances by one period,total_paymentsincrements - Repeat — if
auto_renewis true andmax_renewalshasn't been reached
Renewal Behavior¶
| Condition | Result |
|---|---|
auto_renew = true, no max |
Continues indefinitely |
auto_renew = true, max_renewals = 12 |
Pauses after 12 payments |
auto_renew = false |
Pauses after next payment |
Fee Distribution¶
Each payment splits into:
- Protocol fee: 100 bps (1%) -> protocol treasury
- Gateway fee: configurable by gateway operator -> gateway fee recipient
- Net amount: remainder -> recipient
Token Delegation¶
Subscriptions use SPL Token delegation — the user approves the protocol's PDA to spend up to the calculated amount. Funds never leave the wallet until a payment executes.
// SDK calculates approval amount automatically:
// - Unlimited: amount * payments_per_year
// - Limited: amount * max_renewals
// Or provide explicitly:
const instructions = await sdk.createSubscription(
USDC_MINT,
recipient,
gateway,
new BN(10_000_000),
true,
null,
{ monthly: {} },
createMemoBuffer("pro_plan", 64),
null, // startTime (defaults to now)
new BN(120_000_000) // approvalAmount: $120 = $10 x 12 months
);
Users can revoke delegation at any time, effectively cancelling the subscription.
Managing Subscriptions¶
Query Status¶
const policy = await sdk.getPaymentPolicy(policyPda);
const sub = policy.policyType.subscription;
console.log("Amount:", sub.amount.toString());
console.log("Next due:", new Date(sub.nextPaymentDue.toNumber() * 1000));
console.log("Auto-renew:", sub.autoRenew);
console.log("Max renewals:", sub.maxRenewals);
console.log("Payments made:", policy.paymentCount.toNumber());
const isDue = Date.now() / 1000 >= sub.nextPaymentDue.toNumber();
Pause / Resume / Cancel¶
// Pause -- stops payments but keeps the policy
await sdk.changePaymentPolicyStatus(tokenMint, policyId, { paused: {} });
// Resume -- reactivates a paused policy
await sdk.changePaymentPolicyStatus(tokenMint, policyId, { active: {} });
// Cancel -- deletes the policy entirely
await sdk.deletePaymentPolicy(tokenMint, policyId);
List All Active Subscriptions¶
const allPolicies = await sdk.getPaymentPoliciesByUserPayment(userPaymentPda);
const activeSubscriptions = allPolicies.filter(
(p) => "subscription" in p.account.policyType && "active" in p.account.status
);
for (const { publicKey, account } of activeSubscriptions) {
const sub = account.policyType.subscription;
const nextDue = new Date(sub.nextPaymentDue.toNumber() * 1000);
console.log(`${publicKey}: $${sub.amount} due ${nextDue.toLocaleString()}`);
}
Approval Amount Calculation¶
function computePaymentsPerYear(frequency: PaymentFrequency): number {
switch (Object.keys(frequency)[0]) {
case "daily":
return 365;
case "weekly":
return 52;
case "biWeekly":
return 26;
case "monthly":
return 12;
case "quarterly":
return 4;
case "semiAnnually":
return 2;
case "yearly":
return 1;
default:
return 12;
}
}
const paymentsPerYear = computePaymentsPerYear(frequency);
const effectiveRenewals = maxRenewals ?? paymentsPerYear;
const approvalAmount = amount.mul(new BN(effectiveRenewals));
Troubleshooting¶
| Error | Cause | Fix |
|---|---|---|
InsufficientFunds |
Not enough USDC in wallet | Fund the wallet or reduce amount |
PaymentNotDue |
next_payment_due hasn't passed |
Wait for the scheduled time |
InvalidDelegation |
Delegated amount too low | Re-approve with higher amount |
| Subscription not renewing | auto_renew = false or max reached |
Check renewal settings |
Comparison with Other Policy Types¶
| Subscription | Milestone | Pay-as-you-go | |
|---|---|---|---|
| Amount | Fixed per period | Variable per milestone | Variable per claim |
| Timing | Fixed schedule | Event/timestamp based | On-demand |
| Flexibility | Low | Medium | High |
| Predictability | High | Medium | Low |
| Best For | Recurring services | Project deliverables | Variable usage |
| User Control | Set up once | Approve per milestone | Period limits |