SUP · Docs

Subscriptions (Supayment)

Use Supayment when you need recurring on-chain payments on Sui without 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:

AudienceYou do
MerchantCreate Service<CoinType> plans, configure local ApiKey and Webhook records, read revenue from Subscribed and FeeCharged events, read active subscribers from ChargeCap objects.
SubscriberOpen 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:

LayerConfirmed code surfaceWhat it does
Sup WalletSupWallet::wallet::grant_service_coinOwner grants a service witness permission for a CoinType.
SupSubscription::subscriptioncreate_service, subscribe, charge_feeCreates plans, takes the first payment, and charges renewals.
Payment Kitpayment_kit::payment_kit::PaymentRegistryRecords 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.

FieldSource typeMeaning
serviceNamestringHuman plan name passed to create_service.
pricebigint | numberMonthly atomic amount passed as u64.
coinTypestringCoinType type argument for the plan.
serviceOwnerstringPayout address; must equal ctx.sender() in create_service.
yearlyPricePctnumberPercent 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 typeFields
PlanserviceId, name, priceAtomic, coinLabel, coinType, yearlyPricePct, payout, createdAt
ApiKeyid, key, label, createdAt
Webhookid, url, events, secret, enabled, createdAt
WebhookEvent"subscription.created" or "invoice.charged"
MerchantStoreplans, 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.

FunctionSourceOutput
fetchPayments(serviceIds, limit = 50)Sui GraphQL eventsPromise<Payment[]>
fetchSubscribers(payout, serviceIds)gRPC owned objectsPromise<Subscriber[]>

fetchPayments queries events by type:

Event typeMapped 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 paramMeaning
serviceOn-chain Service<CoinType> object id.
coinCoin label resolved through getSubCoins(network).
walletWallet object id to bill from; defaults to SUB_OBJECTS.flowWallet.
nonceOptional payment_nonce; used only when length is <= 36.
planOptional 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 fieldUsed as
priceMonthly atomic price.
service_nameDisplay plan name.
service_ownerPayout owner.
yearly_price_pctYearly 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:

CallPurpose
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:

PropType
planNamestring
monthlyPricenumber
coinLabelstring
yearlyPricePctnumber | undefined
defaultYearboolean | undefined
disabledboolean | undefined
captionReactNode | undefined
onConfirm(isYear: boolean) => Promise<ConfirmResult>
onDone(isYear: boolean) => void
explorerTxBasestring | undefined
accentstring | undefined
radiusnumber | undefined
classNamestring | undefined
styleCSSProperties | 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 pathFile
@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:

ExportSignature
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:

MethodParamsReturns
serviceType()nonestring
authorize(params)GrantParamsTransaction
authorizeMany(params)GrantManyParamsTransaction
createService(params)CreateServiceParamsTransaction
subscribe(params)SubscribeParamsTransaction
authorizeAndSubscribe(params)AuthorizeAndSubscribeParamsTransaction
chargeFee(params)ChargeFeeParamsTransaction
paymentUri(params)SubscriptionPaymentUriParamsstring

Core SDK types:

TypeShape
SuiNetwork"mainnet" | "testnet" | "devnet" | "localnet"
ObjectIdstring
CoinInputObjectId | TransactionObjectArgument
SubscriptionRuntimeConfigpackages, optional shared, optional paymentKit, optional serviceType
ProtocolClientOptionsoptional network, optional url, optional runtime
GrantParamswalletId, coinType
GrantManyParamswalletId, coinTypes
CreateServiceParamscoinType, price, serviceName, serviceOwner, yearlyPricePct
SubscribeParamswalletId, serviceId, coinType, optional paymentRegistryId, paymentNonce, optional isYear
ChargeFeeParamswalletId, chargeCapId, serviceId, coinType, optional paymentRegistryId, paymentNonce
AuthorizeAndSubscribeParamsextends SubscribeParams
SubscriptionPaymentUriParamsreceiverAddress, 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:

ConstantValue
THIRTY_DAYS30 * 24 * 60 * 60 * 1000
THREE_SIX_FIVE_DAYS365 * 24 * 60 * 60 * 1000

Key structs:

StructAbilitiesFields
SubscriptionServicedropnone
ServiceCapkey, storeid, service_id
Service<phantom CoinType>keyid, price, service_name, service_owner, yearly_price_pct
Receipt<phantom CoinType>key, storeid, serviceID, expire_date, receipt_owner, paid_amount
ChargeCapkey, storeid, walletID, serviceID, charge_date, is_year, subscriber

Events:

EventFields
ServiceCreatedservice_id, service_owner, price
Subscribedwallet_id, service_id, payment_registry, payment_nonce, subscriber, is_year, amount_paid, next_charge_date
FeeChargedwallet_id, service_id, payment_registry, payment_nonce, subscriber, amount_paid, next_charge_date

Public functions:

FunctionSignature
create_servicepublic fun create_service<CoinType>(price: u64, service_name: String, service_owner: address, yearly_price_pct: u8, ctx: &mut TxContext)
subscribepublic fun subscribe<CoinType>(wallet: &mut Wallet, service: &Service<CoinType>, registry: &mut PaymentRegistry, payment_nonce: AsciiString, is_year: bool, clock: &Clock, ctx: &mut TxContext)
charge_feepublic 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_pricepublic fun get_service_price<CoinType>(service: &Service<CoinType>): u64
get_service_namepublic fun get_service_name<CoinType>(service: &Service<CoinType>): String
get_service_ownerpublic fun get_service_owner<CoinType>(service: &Service<CoinType>): address
get_yearly_price_pctpublic fun get_yearly_price_pct<CoinType>(service: &Service<CoinType>): u8

create_service asserts:

CheckAbort code
ctx.sender() == service_ownerENotServiceOwner
yearly_price_pct <= 100EYearlyPctOutOfRange

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:

CheckAbort code
ctx.sender() == service.service_ownerENotServiceOwner
clock.timestamp_ms() > charge_cap.charge_dateEChargeDateNotPassed
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:

FieldTypeBehavior
addressstringMust match /^0x[0-9a-fA-F]{2,64}$/.
planstringMust resolve through planById.
cyclestring"year" becomes year; anything else becomes month.
networkstring"testnet" becomes testnet; anything else becomes mainnet.
digeststringRequired; 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):

StatusBody
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.