Skip to main content

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​

AspectSubscriptionsCredits
TriggerTime intervalUsage exhaustion
Access modelContinuous until lapsePer-unit consumption
Payment timingPredictable scheduleWhen batch depleted
Best forSaaS, membershipsAPIs, 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:

  • lastPaidAt is set to the execution timestamp
  • nextChargeAt advances by one interval
  • remainingExecutions decrements
  • 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
  • remainingExecutions reaches 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 activeURI metadata
  • Expired subscribers see expiredURI metadata
  • 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):

  1. lastPaidAt remains unchanged
  2. Access continues during grace period
  3. After grace period, isActive() returns false
  4. 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:

  1. On resume, nextChargeAt is reset to current time
  2. This prevents "catch-up" charges for missed intervals
  3. Access resumes after the next successful payment

Allowance Expiry​

If the Permit2 allowance expires:

  1. quoteExecution() returns a failure reason (e.g. AllowanceExpired)
  2. Keepers cannot process payment
  3. User must sign a new Permit2 to continue

The user can call updateAllowanceExpiry() to extend without re-subscribing.

Remaining Executions​

remainingExecutions limits total charges:

  1. Each successful payment decrements the counter
  2. When it reaches zero, no more payments execute
  3. 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​

  1. Store the authorizationModule address (SubscriptionModule)
  2. Store the planId to check against
  3. Call isActive(msg.sender, planId) in gated functions
  4. 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.