pay.sh docs
Building with paySubscriptions

Protocol & internals

The on-chain accounts, the 402 handshake, the activation transaction, and how renewals work under the hood.

The plain-English version lives on the Concept page. This one zooms in on what actually happens on the wire and on-chain.

The protocol is the Solana profile of the MPP subscription intent — draft-solana-subscription-00 — implemented against an audited on-chain program.

Agent summary

  • Activation is one HTTP round-trip with one signed transaction. Three on-chain instructions execute atomically.
  • Renewal is server-driven; no 402, no client signature.
  • Cancellation is purely on-chain. The server stops pulling at the next period boundary.
  • The subscriptionId returned in Payment-Receipt is the base58 of the SubscriptionDelegation PDA.

The three on-chain accounts

A subscription is held by an audited on-chain program. The program defines three PDAs that together encode the recurring authorization:

PDAPurposeCreated by
PlanThe merchant's published terms: mint, per-period amount, period length, authorized recipients, authorized pullers. Core fields are immutable once published.Merchant, off the critical path of any 402 challenge.
Subscription DelegationPer-subscriber snapshot of the plan terms plus the current billing-period accounting (start timestamp, amount already pulled in this period). The base58 of this PDA is the subscriptionId.Subscriber, atomically with the first-period charge at activation time.
Subscription AuthorityPer-(subscriber, mint) account holding the SPL Token delegate authority over the subscriber's token account. Reusable across multiple subscriptions on the same mint.Subscriber, the first time they subscribe to anything on that mint.

The Plan is the source of truth. The challenge the server emits at 402 time MUST match the plan's snapshotted terms exactly; clients verify the match before signing.

Activation

When an unauthenticated client hits a subscription endpoint, the server returns 402 Payment Required with intent="subscription". The client signs a single activation transaction that creates the delegation and collects the first-period charge atomically.

Client / Agentpay curlYour APIpay serverSolanadevnet / sandbox1GET /api/pro/feed2402 Payment Requiredintent=subscription, externalId=<plan PDA>3Authorization: Paymentsigned activation transaction4co-sign + broadcastsubscribe + first transfer5confirmedSubscriptionDelegation created6200 OK + Payment-ReceiptsubscriptionId returned
Activation: one HTTP round-trip, one signed transaction, three on-chain instructions.

The 402 challenge

The challenge WWW-Authenticate header carries a base64url-encoded JSON request object. Decoded, it looks like:

{
  "amount": "10000000",
  "currency": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
  "periodUnit": "day",
  "periodCount": "30",
  "subscriptionExpires": "2027-01-15T12:00:00Z",
  "recipient": "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin",
  "externalId": "8tWbqLkUJoYy7zXc5h2EvCRoaQEv2xnQjUuYhc3rzCgT",
  "description": "Pro feed — monthly access",
  "methodDetails": {
    "programId": "De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44",
    "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
    "tokenProgram": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
    "decimals": 6,
    "puller": "5fKb5cF22cFybZB1H4hLDydFhwoQy9JzKzRWaSbMkB6h",
    "network": "mainnet",
    "feePayer": true,
    "feePayerKey": "5fKb5cF22cFybZB1H4hLDydFhwoQy9JzKzRWaSbMkB6h"
  }
}
FieldMeaning
amountPer-period charge in mint base units (10000000 = 10 USDC).
currencyMint address. Must equal methodDetails.mint.
periodUnit / periodCountTogether specify the billing period. month is not accepted.
recipientThe wallet authorized to receive the charge. Must appear in plan.destinations.
externalIdBase58 of the on-chain Plan PDA. Required for this intent (elevated from the shared spec).
methodDetails.programIdCanonical subscriptions program. The client must pin this before signing.
methodDetails.pullerThe server wallet authorized to pull. Must be plan.owner or listed in plan.pullers.
methodDetails.feePayerWhen true, the server sponsors fees and co-signs the activation transaction.
subscriptionExpiresOptional HTTP-layer cap. After this point the server stops renewing and serves fresh 402s.

The client MUST fetch the on-chain Plan at externalId and verify that every snapshotted term (mint, per-period amount, mapped period) matches the challenge before signing.

The activation transaction

The signed transaction contains three subscriptions-program instructions in this order:

Instruction 0  (optional) SetComputeUnitLimit, SetComputeUnitPrice
Instruction 1  (conditional) subscriptions.initialize_subscription_authority
               — only if the (subscriber, mint) authority doesn't yet exist on-chain
Instruction 2  subscriptions.subscribe
               — creates SubscriptionDelegation snapshotting plan terms
Instruction 3  subscriptions.transfer_subscription
               — pulls first-period charge from subscriber ATA to recipient ATA
Fee payer:     methodDetails.feePayerKey when feePayer=true, otherwise the subscriber
Signers:       subscriber (always), puller (added by the server at co-sign time)

Any deviation — a stray SPL Approve, a transfer to an unauthorized recipient, instructions from other programs — and the server rejects the credential before broadcasting.

Pull mode vs push mode

The activation credential can take two shapes:

Modepayload.typeWhat the client submitsWho broadcasts
Pull (default)transactionStandard-base64 of the signed txServer
PushsignatureBase58 of the confirmed signatureClient

Pull mode is the default and the only mode compatible with server-sponsored fees. Push mode requires feePayer: false; the server has no opportunity to co-sign a transaction the client already broadcast.

The receipt

On success the server returns 200 OK with a Payment-Receipt header. The receipt payload decodes to:

{
  "method": "solana",
  "intent": "subscription",
  "status": "success",
  "reference": "5J8...base58 transaction signature...Kt",
  "subscriptionId": "BXQGmO5VwTrl5RfFr6Y8XQZ4nPj9QqMOiKkRn3pZ4ZE",
  "externalId": "8tWbqLkUJoYy7zXc5h2EvCRoaQEv2xnQjUuYhc3rzCgT",
  "periodIndex": "0",
  "periodStartTs": "2026-01-15T12:03:10Z",
  "periodEndTs": "2026-02-14T12:03:10Z",
  "expiresAt": "2027-01-15T12:00:00Z",
  "timestamp": "2026-01-15T12:03:10Z"
}

The subscriptionId is stable across renewals. The client retains it locally; the server uses it as the lookup key for renewal accounting.

Renewal

Renewals are server-driven. There is no 402, no client signature, no HTTP traffic from the subscriber. When the renewal worker detects a billing-period boundary, it submits a single instruction to the subscriptions program.

Client / Agentpay curlYour APIpay serverSolanadevnet / sandbox1transfer_subscriptionone instruction, signed by the puller2confirmedcurrent_period_start_ts advanced
Renewal: server-side pull. The client lifeline is intentionally idle.

The on-chain program enforces the rules:

  • At most one period's worth of value can be pulled per billing period.
  • current_period_start_ts advances by whole multiples of the period length when a transfer occurs after the period has elapsed.
  • Missed periods do not accumulate. If periods 3 and 4 elapse without a charge, the next successful transfer_subscription collapses to period 4 — the skipped period 3 is forfeit, not bankable.

The server SHOULD submit at most one transfer_subscription per billing period per subscription. If the transaction fails (insufficient balance, on-chain error), the server records the failure and serves 402 Payment Required on the next gated request.

Cancellation

Cancellation is out-of-band — the subscriber submits cancel_subscription directly to the program; no HTTP credential is involved. The program sets delegation.expires_at_ts to the end of the current billing period.

  • Until that timestamp, the server continues to honor access.
  • At that timestamp, transfer_subscription starts failing with SubscriptionCancelled.
  • Subsequent gated requests receive a fresh 402.

Subscribers can additionally revoke every subscription on a given mint at once by closing their SubscriptionAuthority — the authority is shared across all subscriptions on that mint, so closing it invalidates them simultaneously.

When the server returns 402 again

A previously-active subscription stops working under any of these conditions. In every case the server returns 402 Payment Required with a fresh challenge:

ConditionCause
subscriptionExpires reachedHTTP-layer cap hit, server stops renewing.
On-chain cancellation effectivedelegation.expires_at_ts ≤ now.
SubscriptionAuthority closedSubscriber revoked all subscriptions on that mint.
Current billing period unpaid and transfer_subscription failedInsufficient balance or other on-chain error.
Activation credential invalidReplay, mismatched plan, scope outside allowed instrs.

Clients receiving a 402 after a previously-valid subscription SHOULD treat the old subscription as no longer usable and start a new activation flow.

Specification

Full normative text: draft-solana-subscription-00. Companion documents:

On this page