Plans & Subscriptions API
ButterPay V2 subscriptions are non-custodial and contract-driven. The flow has three actors:
- Merchant signs
createPlan()on theSubscriptionManagercontract to register a plan on chain. The backend builds the calldata; the merchant wallet sends the transaction. - Subscriber signs
approve()+subscribe()directly against the contract — no backend POST needed. - Backend chain-listener observes
PlanCreated,Subscribed,Charged,Cancelled,ExpiredByFailure,Resubscribed, andPlanActiveChangedevents and reconciles them into theplansandsubscriptionstables. Webhooks fire from those event handlers.
Because creation/cancellation are on-chain actions, the relevant POST endpoints return an intent ({ to, chainId, encodedTx, ... }) rather than mutating state directly. The wallet signs and broadcasts; the listener picks up the result.
All authenticated endpoints accept either a session JWT (Authorization: Bearer ...) or X-API-Key. See Authentication.
Contents
- Endpoint summary
- Plans
- Subscriptions
- Webhooks fired
- Field reference
Endpoint summary
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/v1/plans |
merchant | Build createPlan() intent for the merchant wallet to sign |
GET |
/v1/plans |
merchant | List plans owned by the authenticated merchant |
GET |
/v1/plans/:id |
public | Fetch a plan by pln_* id or 0x... onChainPlanId |
GET |
/v1/subscriptions |
merchant | List subscriptions for plans owned by the merchant |
GET |
/v1/subscriptions/:id |
merchant | Fetch a single subscription |
GET |
/v1/subscriptions/:id/charges |
merchant | On-chain Charged event history for a subscription |
POST |
/v1/subscriptions/:id/cancel |
merchant | Build merchantCancel() intent for the merchant wallet to sign |
There is no API endpoint for the subscriber to "create a subscription" — that step is on-chain. See Subscribing on chain.
CreatePlan
Builds an unsigned createPlan() calldata blob for the merchant's wallet to send to the SubscriptionManager contract. Inserts a row in the plans table with active=false; the chain-listener flips active=true after observing the PlanCreated event.
- Method:
POST - Path:
/v1/plans - Auth: Bearer JWT or
X-API-Key
The merchant must already be registered on chain (merchants.onChainMerchantId must be set). If not, the request returns 400.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
externalPlanCode |
string | yes | Stable per-merchant code, 3–64 chars matching ^[a-z0-9_-]{3,64}$. Used as the salt input. Must be unique per merchant. |
tokenAddress |
string | yes | ERC-20 address charged each cycle. Will be EIP-55 checksummed. |
amount |
string | yes | Amount in smallest units of the token (decimal string, e.g. "9990000" for 9.99 USDC with 6 decimals). |
monthsPerCycle |
integer | yes | Cycle length in months. Range: 1..12. |
bufferTimeSeconds |
integer | yes | Grace period after nextDueAt before the subscription is treated as failed. Must satisfy 0 <= bufferTimeSeconds < monthsPerCycle * 30 * 86400. |
metadata |
object | no | Arbitrary JSON. Hashed (keccak256 of the canonical JSON with sorted keys) into metadataHash and stored on chain. Defaults to {}. |
chainId |
integer | yes | EVM chain id. Currently supported: 42161 (Arbitrum One), 421614 (Arbitrum Sepolia). |
Response (HTTP 201)
{
"to": "0xSubscriptionManagerAddress",
"chainId": 42161,
"planId": "0xabc...32bytes",
"salt": "0xdef...32bytes",
"metadataHash": "0x111...32bytes",
"encodedTx": "0xa9059cbb..."
}
| Field | Description |
|---|---|
to |
SubscriptionManager address for chainId. The wallet sends to this. |
planId |
Deterministic on-chain plan id (bytes32). Computed as keccak256(merchantOnChainId, salt). Use this to read the plan on chain or to reference it from subscribe(). |
salt |
Plan salt derived from externalPlanCode. |
metadataHash |
keccak256(canonical JSON of metadata). |
encodedTx |
ABI-encoded calldata for createPlan(...). |
How the merchant signs
import { createWalletClient, http } from "viem";
import { arbitrum } from "viem/chains";
const intent = await fetch("https://api.butterpay.io/v1/plans", {
method: "POST",
headers: { "X-API-Key": apiKey, "Content-Type": "application/json" },
body: JSON.stringify({
externalPlanCode: "pro_monthly",
tokenAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC arb
amount: "9990000", // 9.99 USDC (6 decimals)
monthsPerCycle: 1,
bufferTimeSeconds: 86400,
metadata: { name: "Pro Monthly" },
chainId: 42161,
}),
}).then((r) => r.json());
const wallet = createWalletClient({ chain: arbitrum, transport: http() /* ... */ });
const txHash = await wallet.sendTransaction({
to: intent.to,
data: intent.encodedTx,
});
After the transaction confirms, the chain-listener observes PlanCreated, calls applyPlanCreated, and flips plans.active to true. A plan.created webhook fires to the merchant.
curl
curl -X POST https://api.butterpay.io/v1/plans \
-H "X-API-Key: bp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"externalPlanCode": "pro_monthly",
"tokenAddress": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
"amount": "9990000",
"monthsPerCycle": 1,
"bufferTimeSeconds": 86400,
"metadata": {"name": "Pro Monthly"},
"chainId": 42161
}'
ListPlans
Returns every plan owned by the authenticated merchant, including pending (active=false) plans whose PlanCreated event has not yet been observed.
- Method:
GET - Path:
/v1/plans - Auth: Bearer JWT or
X-API-Key
Response (HTTP 200)
{
"data": [
{
"id": "pln_1a2b3c4d5e6f7g8h9i0j1k2l",
"merchantId": "mer_01hwzq3k5n8ej4v2b7r9abc123",
"externalPlanCode": "pro_monthly",
"onChainPlanId": "0xabc...32bytes",
"tokenAddress": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
"amount": "9990000",
"monthsPerCycle": 1,
"bufferTimeSeconds": 86400,
"metadataHash": "0x111...32bytes",
"active": true,
"chainId": 42161,
"createdAt": "2026-04-27T12:00:00.000Z"
}
],
"count": 1
}
curl
curl https://api.butterpay.io/v1/plans \
-H "X-API-Key: bp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
GetPlan
Public endpoint used by the hosted subscribe page (pay.butterpay.io/subscribe/:planId). It accepts either the pln_* database id or the on-chain 0x... onChainPlanId as the path parameter.
Returns 404 when the plan does not exist or is not yet active=true (i.e. the PlanCreated event has not been observed yet, or the plan has been deactivated on chain).
- Method:
GET - Path:
/v1/plans/:id - Auth: Public
Response (HTTP 200)
{
"id": "pln_1a2b3c4d5e6f7g8h9i0j1k2l",
"merchantId": "mer_01hwzq3k5n8ej4v2b7r9abc123",
"externalPlanCode": "pro_monthly",
"onChainPlanId": "0xabc...32bytes",
"tokenAddress": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
"amount": "9990000",
"monthsPerCycle": 1,
"bufferTimeSeconds": 86400,
"metadataHash": "0x111...32bytes",
"active": true,
"chainId": 42161,
"createdAt": "2026-04-27T12:00:00.000Z"
}
curl
# By DB id
curl https://api.butterpay.io/v1/plans/pln_1a2b3c4d5e6f7g8h9i0j1k2l
# By on-chain plan id (bytes32 hex)
curl https://api.butterpay.io/v1/plans/0xabc1234567890abcdef1234567890abcdef1234567890abcdef1234567890abc
Deactivating a plan
V2 has no REST endpoint for deactivating plans — this is a deliberate non-custodial design. The merchant calls SubscriptionManager.deactivatePlan(bytes32 planId) directly from the same wallet that owns the on-chain merchant record.
import { encodeFunctionData } from "viem";
import { subscriptionManagerAbi } from "./abi";
const data = encodeFunctionData({
abi: subscriptionManagerAbi,
functionName: "deactivatePlan",
args: [onChainPlanId],
});
await wallet.sendTransaction({ to: subscriptionManagerAddress, data });
When the PlanActiveChanged(active=false) event is observed, applyPlanActiveChanged flips plans.active to false and a plan.deactivated webhook fires. Existing subscriptions are then transitioned to INACTIVATED_BY_PLAN_CLOSED by the listener as the contract emits its own Cancelled-class events for affected subs.
A helper endpoint that returns a deactivatePlan() intent will be added in a later release. Until then, build the calldata client-side as shown above.
Subscribing on chain
There is no POST /v1/subscriptions endpoint. To subscribe, the subscriber's wallet talks to the SubscriptionManager contract directly. The backend learns about the new subscription only when the chain-listener observes the Subscribed event.
End-to-end flow
- Read the plan. Fetch plan details with
GET /v1/plans/:id(public). Use either thepln_*id or theonChainPlanId. - Read the contract address. Look up the
SubscriptionManageraddress for the plan'schainIdviaGET /v1/config(or hard-code from the contracts page). - Approve the spend. Have the subscriber sign
ERC20.approve(subscriptionManager, amount * cyclesYouWantToCover). The contract will pullamountper cycle until allowance is exhausted; thenExpiredByFailurefires. - Subscribe. Have the subscriber sign
SubscriptionManager.subscribe(bytes32 planId). This emitsSubscribed(subId, planId, subscriber, anchorTime, anchorDay, serviceFeeBpsSnapshot)and immediately performs the first charge (emittingCharged). - Wait for the listener. The chain-listener inserts a row in
subscriptionswithstatus='ACTIVE',cyclesCharged=1, and a computednextDueAt. Asubscription.createdwebhook fires, followed bysubscription.charged.
viem + wagmi pseudocode
import { useWriteContract, useReadContract } from "wagmi";
import { erc20Abi, parseUnits } from "viem";
import { subscriptionManagerAbi } from "./abi";
// 1. Fetch plan
const plan = await fetch(`https://api.butterpay.io/v1/plans/${planId}`).then(r => r.json());
// 2. SubscriptionManager address (e.g. from /v1/config or a constant)
const subMgr = "0xSubscriptionManagerAddress";
// 3. Approve enough allowance to cover N cycles up front
const cyclesToCover = 12n;
const totalAllowance = BigInt(plan.amount) * cyclesToCover;
const approveHash = await writeContract({
abi: erc20Abi,
address: plan.tokenAddress,
functionName: "approve",
args: [subMgr, totalAllowance],
});
// 4. Subscribe (this also performs the first charge)
const subHash = await writeContract({
abi: subscriptionManagerAbi,
address: subMgr,
functionName: "subscribe",
args: [plan.onChainPlanId],
});
// 5. Backend listener picks up `Subscribed` and inserts the subscription row.
// Poll GET /v1/subscriptions or listen for the subscription.created webhook.
Why no API write?
Subscriber funds and authorisations live in the subscriber's wallet and the ERC-20 contract; ButterPay never custodies them. Forcing the subscribe call through the contract guarantees the backend cannot create or modify subscriptions out of band — every state change is rooted in an on-chain event.
ListSubscriptions
Returns every subscription whose planId belongs to a plan owned by the authenticated merchant. Optional status query filter.
- Method:
GET - Path:
/v1/subscriptions - Auth: Bearer JWT or
X-API-Key
Query parameters
| Name | Type | Description |
|---|---|---|
status |
string | One of ACTIVE, CANCELLED, EXPIRED_BY_FAILURE, INACTIVATED_BY_PLAN_CLOSED. Omit for all. |
Response (HTTP 200)
{
"data": [
{
"id": "sub_1a2b3c4d5e6f7g8h9i0j1k2l",
"planId": "pln_1a2b3c4d5e6f7g8h9i0j1k2l",
"onChainSubId": "42",
"subscriberAddress": "0xSubscriberWalletAddress",
"anchorTime": "2026-04-27T12:00:00.000Z",
"anchorDay": 27,
"cyclesCharged": 3,
"serviceFeeBpsSnapshot": 80,
"status": "ACTIVE",
"nextDueAt": "2026-07-27T12:00:00.000Z",
"cancelReason": null,
"chainId": 42161,
"createdAt": "2026-04-27T12:00:30.000Z",
"updatedAt": "2026-06-27T12:00:30.000Z"
}
],
"count": 1
}
curl
curl "https://api.butterpay.io/v1/subscriptions?status=ACTIVE" \
-H "X-API-Key: bp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
GetSubscription
Fetch a single subscription. Returns 404 if the subscription does not exist or its plan is not owned by the authenticated merchant.
- Method:
GET - Path:
/v1/subscriptions/:id - Auth: Bearer JWT or
X-API-Key
curl
curl https://api.butterpay.io/v1/subscriptions/sub_1a2b3c4d5e6f7g8h9i0j1k2l \
-H "X-API-Key: bp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Response is a single subscription record (same shape as one element of ListSubscriptions data).
GetSubscriptionCharges
Returns the on-chain charge history for a subscription, sourced from chain_events rows where event_name='Charged' and the subId arg matches.
- Method:
GET - Path:
/v1/subscriptions/:id/charges - Auth: Bearer JWT or
X-API-Key
Response (HTTP 200)
{
"data": [
{
"cycle": 3,
"txHash": "0xabc...",
"amount": "9990000",
"merchantNet": "9910080",
"serviceFee": "79920",
"chargedAt": "2026-06-27T12:00:14.000Z",
"blockNumber": 234567890
}
],
"count": 1,
"cyclesTotal": 3
}
| Field | Description |
|---|---|
cycle |
The cycle number (cyclesCharged at the time of this charge). |
txHash |
Transaction containing the Charged event. |
amount |
Total token amount pulled from subscriber, in smallest units. |
merchantNet |
Net amount delivered to the merchant after fees, smallest units. |
serviceFee |
Service fee taken, smallest units. |
chargedAt |
Time the listener processed the event. |
cyclesTotal |
Current subscriptions.cyclesCharged (top-level, not per row). |
curl
curl https://api.butterpay.io/v1/subscriptions/sub_1a2b3c4d5e6f7g8h9i0j1k2l/charges \
-H "X-API-Key: bp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
CancelSubscription
Builds an unsigned merchantCancel(uint256 onChainSubId) calldata blob. The merchant's wallet (which is the on-chain admin for the merchant) must sign and send. The backend does not mutate the subscription row; the chain-listener does that when Cancelled is observed.
- Method:
POST - Path:
/v1/subscriptions/:id/cancel - Auth: Bearer JWT or
X-API-Key
Response (HTTP 200)
{
"to": "0xSubscriptionManagerAddress",
"chainId": 42161,
"encodedTx": "0x...calldata...",
"onChainSubId": "42"
}
How the merchant signs
const intent = await fetch(
`https://api.butterpay.io/v1/subscriptions/${subId}/cancel`,
{ method: "POST", headers: { "X-API-Key": apiKey } },
).then((r) => r.json());
await wallet.sendTransaction({ to: intent.to, data: intent.encodedTx });
When the Cancelled event is observed, applyCancelled sets status='CANCELLED', clears nextDueAt, and a subscription.cancelled webhook fires.
Subscriber-initiated cancel
The subscriber can also cancel by calling SubscriptionManager.subscriberCancel(uint256 subId) directly, or by revoking the ERC-20 allowance (which triggers ExpiredByFailure on the next cycle). Both paths land in the same listener handlers and emit the corresponding webhook (subscription.cancelled or subscription.expired).
curl
curl -X POST https://api.butterpay.io/v1/subscriptions/sub_1a2b3c4d5e6f7g8h9i0j1k2l/cancel \
-H "X-API-Key: bp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Webhooks fired
All webhooks are dispatched by the chain-listener after it processes the corresponding event, never by the API request itself. See Webhooks for delivery semantics, signing, and full payload shapes.
| Event | Source on chain | When |
|---|---|---|
plan.created |
PlanCreated |
Merchant's createPlan() tx confirms; row flips to active=true. |
plan.deactivated |
PlanActiveChanged(active=false) |
Merchant's deactivatePlan() tx confirms. |
subscription.created |
Subscribed |
Subscriber's subscribe() tx confirms; row inserted with status='ACTIVE'. |
subscription.charged |
Charged |
Each successful auto-charge cycle (including the first one inside subscribe()). |
subscription.cancelled |
Cancelled |
Either merchantCancel() or subscriberCancel() tx confirms. |
subscription.expired |
ExpiredByFailure |
The contract failed to pull funds (subscriber allowance/balance ran out). |
subscription.resubscribed |
Resubscribed |
A previously cancelled/expired subscriber re-subscribed; the existing row is reset (cyclesCharged=0, new anchor). |
Field reference
Plan (plans table)
| Column | Type | Notes |
|---|---|---|
id |
string | pln_ + 24-char nanoid (lowercase alphanumeric). |
merchantId |
string | FK → merchants.id (mer_*). |
externalPlanCode |
string | 3–64 chars matching ^[a-z0-9_-]{3,64}$. Unique per merchant. Acts as the salt input. |
onChainPlanId |
string | bytes32 hex. keccak256(merchantOnChainId, salt). |
tokenAddress |
string | EIP-55 checksummed ERC-20 address. |
amount |
string | Token smallest units, decimal string. |
monthsPerCycle |
integer | Cycle length in months, 1..12. |
bufferTimeSeconds |
integer | Grace period after nextDueAt before failure escalates. |
metadataHash |
string | keccak256(canonical-JSON(metadata)). |
active |
boolean | false until PlanCreated is observed; flipped to false on PlanActiveChanged(false). |
chainId |
integer | EVM chain id. |
createdAt |
timestamp | Set when the intent row is inserted, before chain confirmation. |
Subscription (subscriptions table)
| Column | Type | Notes |
|---|---|---|
id |
string | sub_ + 24-char nanoid. |
planId |
string | FK → plans.id. |
onChainSubId |
string | uint256 from Subscribed, stored as decimal string. Unique per (chainId, onChainSubId). |
subscriberAddress |
string | Wallet that signed subscribe(). |
anchorTime |
timestamp | Time of the first successful charge. Cycle boundaries are computed from this. |
anchorDay |
integer | Day-of-month of the anchor (1..31), used for monthly cadence on irregular months. |
cyclesCharged |
integer | Increments on each Charged event. Reset to 0 on Resubscribed. |
serviceFeeBpsSnapshot |
integer | Service fee bps captured at subscribe time. |
status |
enum | One of ACTIVE, CANCELLED, EXPIRED_BY_FAILURE, INACTIVATED_BY_PLAN_CLOSED. |
nextDueAt |
timestamp | Read from SubscriptionManager.nextDueAt(subId) after each event. null when not active. |
cancelReason |
string | Free-form reason from the listener ("merchant_cancel", "subscriber_cancel", "plan_closed", etc.). |
chainId |
integer | EVM chain id. |
createdAt |
timestamp | Set when the listener inserts the row. |
updatedAt |
timestamp | Touched on each event. |