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.