Subscriptions (Supayment)
Use
Supaymentwhen you need recurring on-chain payments onSuiwithout taking custody.
Supayment is the subscription product inside Sup Wallet. It gives merchants a Stripe-like flow for plans, checkout links, webhook config, and revenue reads, while keeping the subscriber self-custodial. The merchant creates an on-chain Service<CoinType>. The subscriber signs a transaction from their own wallet/vault. Helpers build unsigned Transaction objects; they do not hold keys and they do not sign.
This page is for both sides:
| Audience | You do |
|---|---|
| Merchant | Create Service<CoinType> plans, configure local ApiKey and Webhook records, read revenue from Subscribed and FeeCharged events, read active subscribers from ChargeCap objects. |
| Subscriber | Open a checkout link, enter the Wallet object to bill from, review the recurring charge, and sign authorizeAndSubscribe. |
For the wallet policy model behind this, read Delegation. For app setup, read Quickstart, API, and Protocol.
#Model
Supayment is built on three layers:
| Layer | Confirmed code surface | What it does |
|---|---|---|
Sup Wallet | SupWallet::wallet::grant_service_coin | Owner grants a service witness permission for a CoinType. |
SupSubscription::subscription | create_service, subscribe, charge_fee | Creates plans, takes the first payment, and charges renewals. |
Payment Kit | payment_kit::payment_kit::PaymentRegistry | Records registry payments and duplicate-resistant payment_nonce receipts. |
The core ethos is simple: Supayment grants payment authority to a contract path, not signing authority to a helper. SDK functions return unsigned transactions. The owner or current signer still signs every transaction.
#Merchant flow
Create a plan from /supayment.
| Field | Source type | Meaning |
|---|---|---|
serviceName | string | Human plan name passed to create_service. |
price | bigint | number | Monthly atomic amount passed as u64. |
coinType | string | CoinType type argument for the plan. |
serviceOwner | string | Payout address; must equal ctx.sender() in create_service. |
yearlyPricePct | number | Percent of price * 12; 100 means no yearly discount, 90 means 10% off. |
The dashboard creates:
const tx = subscriptionClient.createService({
coinType: coin.coinType,
price: toAtomic(price),
serviceName: name.trim(),
serviceOwner: payoutAddr,
yearlyPricePct: Math.floor(yp),
});
After signing, the app reads the ServiceCreated event and stores the returned service_id in a local Plan cache.
| Local type | Fields |
|---|---|
Plan | serviceId, name, priceAtomic, coinLabel, coinType, yearlyPricePct, payout, createdAt |
ApiKey | id, key, label, createdAt |
Webhook | id, url, events, secret, enabled, createdAt |
WebhookEvent | "subscription.created" or "invoice.charged" |
MerchantStore | plans, apiKeys, webhooks |
ApiKey and Webhook records are local merchant config in localStorage. Plans are on-chain Service<CoinType> objects; the dashboard only caches their ids.
#Revenue and subscribers
Revenue is read from chain state.
| Function | Source | Output |
|---|---|---|
fetchPayments(serviceIds, limit = 50) | Sui GraphQL events | Promise<Payment[]> |
fetchSubscribers(payout, serviceIds) | gRPC owned objects | Promise<Subscriber[]> |
fetchPayments queries events by type:
| Event type | Mapped Payment.kind |
|---|---|
${PKG}::subscription::Subscribed | "new" |
${PKG}::subscription::FeeCharged | "renewal" |
Payment shape:
export type Payment = {
kind: "new" | "renewal";
serviceId: string;
paymentRegistry: string;
paymentNonce: string;
subscriber: string;
amountAtomic: string;
timestampMs: number;
digest: string;
};
fetchSubscribers lists ChargeCap objects owned by the payout address.
export type Subscriber = {
chargeCapId: string;
serviceId: string;
subscriber: string;
isYear: boolean;
chargeDateMs: number;
};
#Checkout flow
A checkout link uses query params read by /supayment/subscribe.
| Query param | Meaning |
|---|---|
service | On-chain Service<CoinType> object id. |
coin | Coin label resolved through getSubCoins(network). |
wallet | Wallet object id to bill from; defaults to SUB_OBJECTS.flowWallet. |
nonce | Optional payment_nonce; used only when length is <= 36. |
plan | Optional Sup plan id for entitlement recording. |
The checkout page reads the service object via grpcClient.getObject({ objectId: serviceId, include: { json: true } }) and expects JSON fields:
| Service JSON field | Used as |
|---|---|
price | Monthly atomic price. |
service_name | Display plan name. |
service_owner | Payout owner. |
yearly_price_pct | Yearly toggle percent. |
The subscriber signs:
const tx = getSubClient(network).authorizeAndSubscribe({
walletId,
serviceId,
coinType: coin.coinType,
paymentNonce: nonceParam && nonceParam.length <= 36 ? nonceParam : createPaymentNonce("sub"),
isYear: year,
});
authorizeAndSubscribe performs two calls in one unsigned Transaction:
| Call | Purpose |
|---|---|
wallet::grant_service_coin<SubscriptionService, CoinType> | Grants the subscription service permission for that CoinType. |
subscription::subscribe<CoinType> | Takes the first payment and creates the recurring ChargeCap. |
The owner signs. The helper does not sign.
#Embed a button
The React export is under @workspace/sup-subscription-sdk/react.
import { SubscriptionConfirm } from "@workspace/sup-subscription-sdk/react";
<SubscriptionConfirm
planName="Pro"
monthlyPrice={5}
coinLabel="USDC"
yearlyPricePct={90}
onConfirm={async (isYear) => {
const tx = client.authorizeAndSubscribe({
walletId,
serviceId,
coinType,
paymentNonce: createPaymentNonce("sub"),
isYear,
});
const result = await signAndExecuteTransaction({ transaction: tx });
return { digest: result.Transaction?.digest };
}}
/>
SubscriptionConfirmProps:
| Prop | Type |
|---|---|
planName | string |
monthlyPrice | number |
coinLabel | string |
yearlyPricePct | number | undefined |
defaultYear | boolean | undefined |
disabled | boolean | undefined |
caption | ReactNode | undefined |
onConfirm | (isYear: boolean) => Promise<ConfirmResult> |
onDone | (isYear: boolean) => void |
explorerTxBase | string | undefined |
accent | string | undefined |
radius | number | undefined |
className | string | undefined |
style | CSSProperties | undefined |
ConfirmResult is:
export type ConfirmResult = { digest?: string } | void;
#QR checkout
The example page builds a QR link from the app origin:
const payLink = `${origin}/supayment/subscribe?service=demo&coin=${coin}`;
For a real plan, use the actual serviceId:
const payLink = `${origin}/supayment/subscribe?service=${serviceId}&coin=${coinLabel}`;
The payer scans the QR, opens checkout, reviews the recurring allowance, and signs from their own wallet.
#SDK package
package.json exposes:
| Export path | File |
|---|---|
@workspace/sup-subscription-sdk | ./src/index.ts |
@workspace/sup-subscription-sdk/types | ./src/types.ts |
@workspace/sup-subscription-sdk/react | ./src/react.tsx |
Main exports from ./src/index.ts:
export * from "./client";
export * from "./types";
Confirmed function exports from ./src/client.ts:
| Export | Signature |
|---|---|
createSubscriptionClient | (options?: ProtocolClientOptions) => SubscriptionClient |
paymentKitDefaultRegistryId | (network: Extract<SuiNetwork, "mainnet" | "testnet">) => string |
createPaymentNonce | (prefix = "sub") => string |
createSubscriptionPaymentUri | (config: SubscriptionRuntimeConfig, params: SubscriptionPaymentUriParams) => string |
grantTx | (config: SubscriptionRuntimeConfig, params: GrantParams) => Transaction |
grantManyTx | (config: SubscriptionRuntimeConfig, params: GrantManyParams) => Transaction |
createServiceTx | (config: SubscriptionRuntimeConfig, params: CreateServiceParams) => Transaction |
subscribeTx | (config: SubscriptionRuntimeConfig, params: SubscribeParams) => Transaction |
authorizeAndSubscribeTx | (config: SubscriptionRuntimeConfig, params: AuthorizeAndSubscribeParams) => Transaction |
chargeFeeTx | (config: SubscriptionRuntimeConfig, params: ChargeFeeParams) => Transaction |
SubscriptionClient methods:
| Method | Params | Returns |
|---|---|---|
serviceType() | none | string |
authorize(params) | GrantParams | Transaction |
authorizeMany(params) | GrantManyParams | Transaction |
createService(params) | CreateServiceParams | Transaction |
subscribe(params) | SubscribeParams | Transaction |
authorizeAndSubscribe(params) | AuthorizeAndSubscribeParams | Transaction |
chargeFee(params) | ChargeFeeParams | Transaction |
paymentUri(params) | SubscriptionPaymentUriParams | string |
Core SDK types:
| Type | Shape |
|---|---|
SuiNetwork | "mainnet" | "testnet" | "devnet" | "localnet" |
ObjectId | string |
CoinInput | ObjectId | TransactionObjectArgument |
SubscriptionRuntimeConfig | packages, optional shared, optional paymentKit, optional serviceType |
ProtocolClientOptions | optional network, optional url, optional runtime |
GrantParams | walletId, coinType |
GrantManyParams | walletId, coinTypes |
CreateServiceParams | coinType, price, serviceName, serviceOwner, yearlyPricePct |
SubscribeParams | walletId, serviceId, coinType, optional paymentRegistryId, paymentNonce, optional isYear |
ChargeFeeParams | walletId, chargeCapId, serviceId, coinType, optional paymentRegistryId, paymentNonce |
AuthorizeAndSubscribeParams | extends SubscribeParams |
SubscriptionPaymentUriParams | receiverAddress, amount, coinType, nonce, optional registryId, optional registryName, optional label, optional message, optional iconUrl |
Runtime config:
const runtime = {
packages: {
SUP_WALLET: "0x...",
SUP_SUBSCRIPTION: "0x...",
},
paymentKit: {
registryId: paymentKitDefaultRegistryId("testnet"),
},
serviceType: "0x...::subscription::SubscriptionService",
};
#On-chain module
Module:
module SupSubscription::subscription
Constants:
| Constant | Value |
|---|---|
THIRTY_DAYS | 30 * 24 * 60 * 60 * 1000 |
THREE_SIX_FIVE_DAYS | 365 * 24 * 60 * 60 * 1000 |
Key structs:
| Struct | Abilities | Fields |
|---|---|---|
SubscriptionService | drop | none |
ServiceCap | key, store | id, service_id |
Service<phantom CoinType> | key | id, price, service_name, service_owner, yearly_price_pct |
Receipt<phantom CoinType> | key, store | id, serviceID, expire_date, receipt_owner, paid_amount |
ChargeCap | key, store | id, walletID, serviceID, charge_date, is_year, subscriber |
Events:
| Event | Fields |
|---|---|
ServiceCreated | service_id, service_owner, price |
Subscribed | wallet_id, service_id, payment_registry, payment_nonce, subscriber, is_year, amount_paid, next_charge_date |
FeeCharged | wallet_id, service_id, payment_registry, payment_nonce, subscriber, amount_paid, next_charge_date |
Public functions:
| Function | Signature |
|---|---|
create_service | public fun create_service<CoinType>(price: u64, service_name: String, service_owner: address, yearly_price_pct: u8, ctx: &mut TxContext) |
subscribe | public fun subscribe<CoinType>(wallet: &mut Wallet, service: &Service<CoinType>, registry: &mut PaymentRegistry, payment_nonce: AsciiString, is_year: bool, clock: &Clock, ctx: &mut TxContext) |
charge_fee | public fun charge_fee<CoinType>(charge_cap: &mut ChargeCap, wallet: &mut Wallet, service: &Service<CoinType>, registry: &mut PaymentRegistry, payment_nonce: AsciiString, clock: &Clock, ctx: &mut TxContext) |
get_service_price | public fun get_service_price<CoinType>(service: &Service<CoinType>): u64 |
get_service_name | public fun get_service_name<CoinType>(service: &Service<CoinType>): String |
get_service_owner | public fun get_service_owner<CoinType>(service: &Service<CoinType>): address |
get_yearly_price_pct | public fun get_yearly_price_pct<CoinType>(service: &Service<CoinType>): u8 |
create_service asserts:
| Check | Abort code |
|---|---|
ctx.sender() == service_owner | ENotServiceOwner |
yearly_price_pct <= 100 | EYearlyPctOutOfRange |
subscribe computes the first payment, calls intent::request_payment, calls intent::validate_and_pay, processes a PaymentRegistry payment, verifies the receipt, transfers ChargeCap to service.service_owner, transfers Receipt<CoinType> to the subscriber, and emits Subscribed.
charge_fee asserts:
| Check | Abort code |
|---|---|
ctx.sender() == service.service_owner | ENotServiceOwner |
clock.timestamp_ms() > charge_cap.charge_date | EChargeDateNotPassed |
charge_cap.walletID == object::id(wallet) | ENotYourWallet |
charge_cap.serviceID == object::id(service) | EWrongServiceId |
#Retired Sup-plan entitlement bridge (historical)
Do not call this endpoint or send funds from an old Sup plan link. The Wallet-address entitlement bridge is retired, the current route always returns
410 Gone, and the checkout page refuses a?plan=link before any signature. Current plans use signed-in SSO account checkout at /pricing.
Before retirement, checkout called:
POST /api/billing/subscribe-confirm
Request body:
{
"address": "0x...",
"plan": "pro",
"cycle": "month",
"network": "mainnet",
"digest": "..."
}
Request fields:
| Field | Type | Behavior |
|---|---|---|
address | string | Must match /^0x[0-9a-fA-F]{2,64}$/. |
plan | string | Must resolve through planById. |
cycle | string | "year" becomes year; anything else becomes month. |
network | string | "testnet" becomes testnet; anything else becomes mainnet. |
digest | string | Required; trimmed before verification. |
The endpoint calls:
verifySubscribeTx({
network,
digest: body.digest.trim(),
payer: body.address,
subscriptionPackage: getRuntime(network).packages.SUP_SUBSCRIPTION,
serviceId: getPlanServiceId(network, plan.id),
isYear: cycle === "year",
})
verifySubscribeTx checks that the transaction succeeded, was signed by payer, and is not older than 30 * 60 * 1000 milliseconds. It then requires a <SUP_SUBSCRIPTION>::subscription::Subscribed event whose service_id is the claimed plan's on-chain Service, whose subscriber is payer, and whose is_year matches the claimed cycle.
The event is the proof of payment: the contract emits it only after taking the fee at the Service's own on-chain price, so matching it to the plan's Service is stronger than any amount comparison this endpoint could make. A plan with no deployed Service cannot be claimed at all.
Historically, the digest was claimed in the shared payment ledger. Those writer
routes are now 410 Gone; their ledgers remain only as protected evidence.
Historical success response (no longer emitted):
{
"ok": true,
"entitlement": {
"address": "0x...",
"plan": "pro",
"network": "mainnet",
"source": "subscription",
"cycle": "month",
"startedAt": 1720000000000,
"expiresAt": 1722592000000,
"tx": "..."
}
}
Historical idempotent response (no longer emitted):
{
"ok": true,
"entitlement": {
"address": "0x...",
"plan": "pro",
"network": "mainnet",
"source": "subscription",
"cycle": "month",
"startedAt": 1720000000000,
"expiresAt": 1722592000000,
"tx": "..."
},
"already": true
}
Historical error responses (the current route always returns 410):
| Status | Body |
|---|---|
400 | { "error": "Invalid JSON body." } |
400 | { "error": "Unknown plan." } |
400 | { "error": "address must be a 0x address." } |
400 | { "error": "Missing subscription digest." } |
402 | { "error": "<verifySubscribeTx reason>" } |
409 | { "error": "That subscription transaction has already been claimed." } |
#Gotchas
Check the paymentNonce. The SDK enforces <= 36 characters and non-empty strings. Use createPaymentNonce("sub") unless you need your own idempotency key.
Check the serviceOwner. create_service requires ctx.sender() == service_owner, so the payout address must sign plan creation.
Check the billing units. The Move module stores price as atomic u64. The UI examples divide by 1e9 for display in the testnet demo.
Check the cancellation path. A subscriber cancels by revoking the wallet allowance granted to SubscriptionService for the CoinType.
