Subscriptions
The Subscription Module enables time-based recurring authorization for SaaS billing, memberships, and access control. Subscriptions trigger payments on intervals rather than usage.
What Subscriptions Are
Subscriptions are recurring authorization schedules, not just payments on a timer.
A subscription grants access for a defined period. When that period ends, a keeper executes the next payment. If the payment succeeds, access continues. If it fails, access expires after the grace period.
This exists because:
- SaaS and membership models need predictable billing cycles
- Users expect continuous access without manual renewal
- Merchants need reliable revenue recognition
- Both parties need clear access windows
Subscription vs Credits
| Aspect | Subscriptions | Credits |
|---|---|---|
| Trigger | Time interval | Usage exhaustion |
| Access model | Continuous until lapse | Per-unit consumption |
| Payment timing | Predictable schedule | When batch depleted |
| Best for | SaaS, memberships | APIs, compute, agents |
Subscriptions and credits are complementary. A service might use subscriptions for base access and credits for metered overage.
Plan Structure
Merchants define plans with immutable parameters:
struct Plan {
uint256 price; // Amount per interval
uint256 interval; // Seconds between charges (e.g., 30 days)
uint256 gracePeriod; // Extra time before access revoked
address token; // Payment token (e.g., USDC)
bytes32 metadataHash; // Hash of plan metadata (name, features, etc.)
bool active; // Can new subscribers join?
}
Plans are immutable after creation. Only the active flag can be toggled. This prevents merchants from changing prices or intervals on existing subscribers.
Subscription Lifecycle
1. Subscribe
User signs a Permit2 authorization and subscribes in a single transaction:
User signs Permit2 → subscribeWithPermit() → Subscription created
The subscription starts with lastPaidAt = 0 (never paid) and nextChargeAt = now (ready for first charge).
2. First Payment
A keeper calls PaymentProcessor.execute(). On success:
lastPaidAtis set to the execution timestampnextChargeAtadvances by one intervalremainingExecutionsdecrements- Access is granted
3. Access Window
Access is determined by:
accessEnd = lastPaidAt + interval + gracePeriod
isActive = (now <= accessEnd)
The grace period provides buffer for payment failures. A 7-day grace period means users retain access for a week after a missed payment before losing access.
4. Recurring Payments
Each interval, keepers detect nextChargeAt <= now and execute payment. The cycle continues until:
- User pauses the subscription
remainingExecutionsreaches zero- Permit2 allowance expires
- Payment fails and grace period lapses
5. Pause and Resume
Users can pause their subscription at any time. A paused subscription:
- Retains all state
- Blocks keeper execution
- Does not consume the access window
On resume, if the subscription has fallen outside its payment window, nextChargeAt is reset to allow the next charge.
Authorization Checking
The isActive(address, planId) function returns whether a subscriber currently has access:
function isActive(address subject, uint256 scope) external view returns (bool) {
// Must have paid at least once
if (subscription.lastPaidAt == 0) return false;
// Must be within access window
uint256 accessEnd = lastPaidAt + interval + gracePeriod;
return block.timestamp <= accessEnd;
}
This is the building block for on-chain gating.
On-Chain Gating
Subscriptions implement the IAuthorizationModule interface, enabling any contract to gate functionality based on subscription status.
The Pattern
interface IAuthorizationModule {
function isActive(address subject, uint256 scope) external view returns (bool);
}
Any contract can call isActive() to check if an address has an active subscription to a specific plan.
Example: Gated NFT
The SubscriptionGatedNFT demonstrates this pattern. It's an ERC721 where:
- Minting requires an active subscription
- Token metadata changes based on subscription status
- No subscription state is stored locally—it's always pulled from the module
contract SubscriptionGatedNFT is ERC721 {
address public immutable authorizationModule;
uint256 public immutable planId;
string public activeURI;
string public expiredURI;
function mint() external {
// Gate minting on active subscription
if (!IAuthorizationModule(authorizationModule).isActive(msg.sender, planId)) {
revert NOT_ACTIVE_SUBSCRIBER();
}
if (balanceOf(msg.sender) != 0) {
revert ALREADY_OWNS_TOKEN();
}
_safeMint(msg.sender, _nextTokenId++);
}
function tokenURI(uint256 tokenId) public view override returns (string memory) {
address owner = ownerOf(tokenId);
// Dynamic metadata based on current subscription status
bool active = IAuthorizationModule(authorizationModule).isActive(owner, planId);
return active ? activeURI : expiredURI;
}
}
Dynamic Metadata
The NFT's tokenURI() queries subscription status on every call. This means:
- Active subscribers see
activeURImetadata - Expired subscribers see
expiredURImetadata - Metadata updates automatically when subscription status changes
- No manual token updates required
This pattern enables "living" NFTs that reflect real-time subscription state.
Other Gating Patterns
The same isActive() check works for:
Smart contract functions:
function premiumFeature() external {
require(subscriptionModule.isActive(msg.sender, PREMIUM_PLAN_ID), "Subscription required");
// ... premium logic
}
API access (off-chain):
const isSubscribed = await subscriptionModule.isActive(userAddress, planId);
if (!isSubscribed) {
return res.status(403).json({ error: "Subscription required" });
}
Token-gated content:
function accessContent(uint256 contentId) external view returns (string memory) {
require(subscriptionModule.isActive(msg.sender, contentPlans[contentId]), "Not subscribed");
return contentURIs[contentId];
}
Failure Modes
Missed Payments
When a payment fails (insufficient balance, revoked allowance):
lastPaidAtremains unchanged- Access continues during grace period
- After grace period,
isActive()returnsfalse - Keepers stop attempting execution after the payment window closes
Payment Window Recovery
If a keeper misses the payment window (block.timestamp > nextChargeAt + interval), the subscription becomes un-executable because quoteExecution() returns PaymentWindowViolation. The subscriber or merchant can call recoverSubscription(subId) to fast-forward nextChargeAt to the current timestamp, allowing the next keeper cycle to charge. This is not permissionless—only the subscriber or merchant can recover, preventing griefing.
Paused Subscriptions
Pausing blocks execution but preserves state. If paused for longer than one interval:
- On resume,
nextChargeAtis reset to current time - This prevents "catch-up" charges for missed intervals
- Access resumes after the next successful payment
Allowance Expiry
If the Permit2 allowance expires:
quoteExecution()returns a failure reason (e.g.AllowanceExpired)- Keepers cannot process payment
- User must sign a new Permit2 to continue
The user can call updateAllowanceExpiry() to extend without re-subscribing.
Remaining Executions
remainingExecutions limits total charges:
- Each successful payment decrements the counter
- When it reaches zero, no more payments execute
- User can call
updateRemainingExecutions()to add more
This prevents unbounded spending if a user forgets about a subscription.
Integration Notes
Checking Access
import { createPublicClient, http, parseAbi } from 'viem';
import { baseSepolia } from 'viem/chains';
const client = createPublicClient({ chain: baseSepolia, transport: http() });
const isActive = await client.readContract({
address: moduleAddress,
abi: parseAbi(['function isActive(address,uint256) view returns (bool)']),
functionName: 'isActive',
args: [userAddress, BigInt(planId)],
});
Building Gated Contracts
- Store the
authorizationModuleaddress (SubscriptionModule) - Store the
planIdto check against - Call
isActive(msg.sender, planId)in gated functions - Do not cache subscription state—always query fresh
Grace Period Considerations
Choose grace periods based on your use case:
- 0 days: Immediate cutoff, strict billing
- 3-7 days: Standard SaaS, allows retry window
- 14+ days: Consumer-friendly, tolerates payment issues
Longer grace periods improve UX but delay revenue recognition for failed payments.
Non-Goals
The following are intentionally not supported:
-
Prorated refunds: Cancellation stops future charges but does not refund the current period.
-
Plan upgrades/downgrades: Users must cancel and resubscribe. Future versions may add migration paths.
-
Subscription transfers: Subscriptions are bound to the subscriber address. Transfer the tokens instead.
-
Automatic retry of failed payments: The keeper/retry engine handles this off-chain. The module only tracks state.