Offers API — design record
The v1 rewrite — Offers replace Plans and Paylinks, pricing becomes an Offer property, and Invoices merge into Payments.
This rewrite has shipped. It is kept as the record of what changed and why — the reasoning behind removing Plans and Paylinks, and the endpoint-by- endpoint migration. For how the API works today, read Core concepts and the API Reference.
What changes
Tidepay's public API exposes seven concepts today: Plans, Paylinks, Subscriptions, Payments, Invoices, Customers, Currencies, and Wallets. Three of those — Plan, Paylink, and Paylink Variant — are internal implementation structure that leaked into the public surface.
This proposal collapses them into one resource, Offer, and merges Invoice into Payment.
| Before | After |
|---|---|
/v1/plans | (gone — becomes an internal pricing row) |
/v1/paylinks | /v1/offers |
/v1/paylinks/{id}/variants | (gone — pricing is first-class) |
/v1/invoices | /v1/payments?subscriptionId= |
/v1/wallets | /v1/wallets (rename deferred — see note below) |
/v1/subscriptions | /v1/subscriptions (unchanged shape, new fields) |
/v1/customers | /v1/customers (unchanged) |
/v1/currencies | /v1/currencies (unchanged) |
Result: six public resources.
/v1/offers
/v1/customers
/v1/subscriptions
/v1/payments
/v1/currencies
/v1/walletsTwo deviations from this proposal, as shipped. /v1/wallets was NOT
renamed to /v1/settlement-wallets — the rename is cosmetic, and was left out
to keep the change surface smaller; the docs still call the concept a
settlement wallet. And retiring a pricing option is DELETE /v1/offers/ {offerId}/pricing/{priceId} rather than a PATCH on the offer, since
deactivating one option is not an edit to the offer itself.
Plus two operational endpoints that stay as-is: /v1/webhooks/secret and /v1/sandbox/charge.
The model
CUSTOMER
│
│ purchases
↓
OFFER ──────────────→ checkoutUrl
│ (a property, not a resource)
│
pricing[] — one or more options
│ ├ amount + currency
│ ├ acceptedTokens (chain × token)
│ └ billing { type, interval }
│
customer picks exactly one
│
┌─────┴──────┐
↓ ↓
one_time recurring
PAYMENT SUBSCRIPTION
│ (snapshots the chosen option)
↓
PAYMENT × NOffer
What am I selling, and on what terms?
An Offer replaces Plan + Paylink. It carries its own pricing, its own presentation (name, description, image), and its own checkoutUrl. Creating one is a single call — there is no separate object to create first.
Payment
A transaction that actually happened.
One resource for every settled charge, whether it came from a one-time Offer or from a subscription's billing cycle. source distinguishes them.
Subscription
A customer's recurring relationship with an Offer.
References the Offer and the specific pricing option chosen, with the terms snapshotted so a later Offer edit never silently reprices an existing subscriber.
Customer
Who is paying.
Unchanged from today.
Pricing without /prices
An Offer holds a pricing array. Each entry is a complete set of billing terms.
{
"name": "Pro",
"pricing": [
{
"amount": "30",
"currency": "USDC",
"acceptedTokens": ["usdc-base", "usdc-polygon"],
"billing": { "type": "recurring", "interval": "month" }
},
{
"amount": "300",
"currency": "USDC",
"acceptedTokens": ["usdc-base", "usdc-polygon"],
"billing": { "type": "recurring", "interval": "year" }
}
]
}Each entry gets a server-assigned id (price_...) in the response. That id is referenceable but not manageable: a Subscription records which option it was created against, and reporting can group by it — but there is no /v1/prices endpoint, and a merchant never creates, lists, or edits one directly. Pricing changes by PATCHing the Offer.
Why the ids exist at all. A subscription must record which option it was bought under, and that reference has to survive the Offer being edited later. An opaque id in responses is the minimum needed to express that. Exposing a whole CRUD resource around it is not.
Immutability
A pricing option's billing terms — amount, currency, acceptedTokens, billing — are immutable once created. This is not a policy choice; it is enforced by the payment rails:
- EVM: a subscriber grants an ERC-20 allowance sized against the amount in force when they subscribed. Changing it would attempt to pull more than was authorized, and the next cycle would simply fail.
- Solana: the
@solana/subscriptionsprogram stores the amount and period in an on-chain account that is immutable by construction.
PATCH /v1/offers/{id} may therefore add a pricing option or deactivate one, but never rewrite the terms of an existing one. Deactivating hides it from new checkouts; subscribers already on it keep billing unchanged.
Fiat-denominated pricing
currency accepts an ISO-4217 fiat code (USD, BRL, MXN, COP, ARS) or a token ticker (USDC, USDT).
When it is fiat, the amount is converted to each accepted token once, at Offer creation, and frozen. A subscriber pays the same number of tokens every cycle for the life of the subscription, regardless of what the exchange rate does afterwards. The fiat amount/currency remain on the Offer as the price you advertised.
{
"amount": "49.90",
"currency": "BRL",
"acceptedTokens": ["usdc-base"],
"billing": { "type": "recurring", "interval": "month" }
}The response echoes the frozen per-token amounts:
{
"id": "price_01j8x2q3r4s5t6u7v8w9x0y1z2",
"amount": "49.90",
"currency": "BRL",
"billing": { "type": "recurring", "interval": "month" },
"acceptedTokens": [
{
"token": "USDC",
"chain": "base",
"tokenKey": "usdc-base",
"amountBaseUnits": "8420000"
}
]
}Endpoints
Offers
POST /v1/offers
GET /v1/offers
GET /v1/offers/{offerId}
PATCH /v1/offers/{offerId}
DELETE /v1/offers/{offerId}
POST /v1/offers/{offerId}/pricing
DELETE /v1/offers/{offerId}/pricing/{priceId}GET /v1/offers supports ?active=, ?identifier=, and standard pagination.
Subscriptions
POST /v1/subscriptions
GET /v1/subscriptions
GET /v1/subscriptions/{subscriptionId}
PATCH /v1/subscriptions/{subscriptionId}
POST /v1/subscriptions/{subscriptionId}/cancel
POST /v1/subscriptions/{subscriptionId}/resume
POST /v1/subscriptions/{subscriptionId}/retry
POST /v1/subscriptions/{subscriptionId}/migratecancel replaces today's DELETE — cancelling is not deletion, the payment history outlives it. migrate is the explicit operation for moving a subscriber to a different pricing option (see Migration).
Payments
POST /v1/payments
GET /v1/payments
GET /v1/payments/{paymentId}
POST /v1/payments/{paymentId}/cancelGET /v1/payments supports ?source=one_time|subscription, ?subscriptionId=, ?offerId=, ?status=, and pagination.
Customers, Currencies, Settlement Wallets
POST /v1/customers
GET /v1/customers
GET /v1/customers/{customerId}
PATCH /v1/customers/{customerId}
DELETE /v1/customers/{customerId}
GET /v1/currencies
GET /v1/wallets
POST /v1/wallets
DELETE /v1/wallets/{walletId}Customers and Currencies are unchanged. Settlement Wallets is a rename of /v1/wallets — same behavior, an unambiguous name.
Examples
One-time payment
POST /v1/offers{
"name": "Complete crypto course",
"description": "12 hours of video, lifetime access",
"imageUrl": "https://cdn.example.com/course.png",
"pricing": [
{
"amount": "100",
"currency": "USDC",
"acceptedTokens": ["usdc-base", "usdc-polygon"],
"billing": { "type": "one_time" }
}
],
"merchantDestinationWallet": "0x71c7656ec7ab88b098defb751b7401b5f6d8976",
"afterCompletion": {
"type": "redirect",
"redirectUrl": "https://example.com/thanks"
},
"collectFields": { "email": true }
}{
"id": "offer_01j8x2q3r4s5t6u7v8w9x0y1z2",
"name": "Complete crypto course",
"checkoutUrl": "https://tidepay.cc/pay/a8f3k2",
"active": true,
"pricing": [
{
"id": "price_01j8x2q3r4s5t6u7v8w9x0y1z3",
"amount": "100",
"currency": "USDC",
"billing": { "type": "one_time" },
"acceptedTokens": [
{
"token": "USDC",
"chain": "base",
"tokenKey": "usdc-base",
"amountBaseUnits": "100000000"
},
{
"token": "USDC",
"chain": "polygon",
"tokenKey": "usdc-polygon",
"amountBaseUnits": "100000000"
}
]
}
],
"createdAt": "2026-09-01T12:00:00.000Z"
}The customer opens checkoutUrl, picks a chain, and pays. That produces:
{
"id": "pay_01j8x2q3r4s5t6u7v8w9x0y1z4",
"source": "one_time",
"offerId": "offer_01j8x2q3r4s5t6u7v8w9x0y1z2",
"priceId": "price_01j8x2q3r4s5t6u7v8w9x0y1z3",
"customerId": "cus_01j8x2q3r4s5t6u7v8w9x0y1z5",
"subscriptionId": null,
"amount": "100",
"currency": "USDC",
"tokenKey": "usdc-base",
"status": "settled",
"txHash": "0x9a2b..."
}Recurring payment
POST /v1/offers{
"name": "Pro",
"description": "Pro SaaS subscription",
"pricing": [
{
"amount": "30",
"currency": "USDC",
"acceptedTokens": ["usdc-base"],
"billing": { "type": "recurring", "interval": "month" }
}
],
"merchantDestinationWallet": "0x71c7656ec7ab88b098defb751b7401b5f6d8976",
"trialDays": 7,
"maxFailedCycles": 3
}The customer opens checkoutUrl, connects a wallet, and authorizes the recurring pull on-chain. That creates a Subscription:
{
"id": "sub_01j8x2q3r4s5t6u7v8w9x0y1z6",
"offerId": "offer_01j8x2q3r4s5t6u7v8w9x0y1z2",
"priceId": "price_01j8x2q3r4s5t6u7v8w9x0y1z3",
"customerId": "cus_01j8x2q3r4s5t6u7v8w9x0y1z5",
"status": "active",
"tokenKey": "usdc-base",
"currentPeriodEnd": "2026-10-08T12:00:00.000Z",
"cyclesCompleted": 0,
"snapshot": {
"offerName": "Pro",
"amount": "30",
"currency": "USDC",
"billing": { "type": "recurring", "interval": "month" },
"amountBaseUnits": "30000000"
}
}Each billing cycle then produces a Payment with source: "subscription":
GET /v1/payments?subscriptionId=sub_01j8x2q3r4s5t6u7v8w9x0y1z6{
"data": [
{
"id": "pay_01j8x...",
"source": "subscription",
"subscriptionId": "sub_01j8x2q3r4s5t6u7v8w9x0y1z6",
"offerId": "offer_01j8x2q3r4s5t6u7v8w9x0y1z2",
"priceId": "price_01j8x2q3r4s5t6u7v8w9x0y1z3",
"amount": "30",
"currency": "USDC",
"tokenKey": "usdc-base",
"status": "settled",
"periodStart": "2026-09-08T12:00:00.000Z",
"periodEnd": "2026-10-08T12:00:00.000Z",
"attempts": 1,
"txHash": "0x4c8d..."
}
],
"hasMore": false
}Multiple currencies and chains
One pricing option, many chain × token combinations. The customer picks at checkout.
{
"name": "Pro",
"pricing": [
{
"amount": "30",
"currency": "USD",
"acceptedTokens": [
"usdc-base",
"usdt-base",
"usdc-polygon",
"usdc-arbitrum",
"usdc-solana"
],
"billing": { "type": "recurring", "interval": "month" }
}
]
}Because currency is USD here, each accepted token gets its own frozen amountBaseUnits at creation time. See Chains for the full catalog of supported combinations.
Monthly and annual
Two options, one Offer, one checkoutUrl.
{
"name": "Pro",
"pricing": [
{
"amount": "30",
"currency": "USDC",
"acceptedTokens": ["usdc-base"],
"billing": { "type": "recurring", "interval": "month" }
},
{
"amount": "300",
"currency": "USDC",
"acceptedTokens": ["usdc-base"],
"billing": { "type": "recurring", "interval": "year" }
}
]
}This is the case that today requires POST /paylinks/{id}/variants plus two unrelated links with nothing tying them together.
Migrating a subscriber
Editing an Offer never touches existing subscribers. Moving one to different terms is explicit, and requires the customer to re-authorize on-chain:
POST /v1/subscriptions/sub_01j8x.../migrate{ "priceId": "price_01j8x2q3r4s5t6u7v8w9x0y1z7" }{
"id": "sub_01j8x2q3r4s5t6u7v8w9x0y1z6",
"status": "pendingAuthorization",
"pendingPriceId": "price_01j8x2q3r4s5t6u7v8w9x0y1z7",
"authorizationUrl": "https://tidepay.cc/authorize/sub_01j8x..."
}The subscription keeps billing on its existing terms until the customer completes authorizationUrl. If they never do, nothing changes — there is no path where a migration silently increases what a customer is charged.
Pricing tiers
Tiers are a field on a pricing option, not a separate resource:
{
"amount": "10",
"currency": "USDC",
"acceptedTokens": ["usdc-base"],
"billing": { "type": "recurring", "interval": "month" },
"tiers": [
{ "upTo": 10, "unitAmount": "10" },
{ "upTo": 50, "unitAmount": "8" },
{ "upTo": null, "unitAmount": "5" }
]
}Tiers are specified here but deliberately not implemented in this phase.
Tiered and quantity-based pricing imply a variable amount per cycle, which the on-chain rails do not currently support. An EVM allowance is granted for a fixed amount, and Solana's subscriptions program stores one immutable amount per plan account. Charging a different amount for a cycle would require re-publishing on-chain state and obtaining fresh authorization from the customer every time the tier changes.
The schema reserves the field so adding it later is not a breaking change. Shipping it is a separate project with its own on-chain design work.
What each Offer field does
| Field | Notes |
|---|---|
name | Public, shown at checkout. Editable. |
description | Public, shown at checkout. Editable. |
imageUrl | Cover image at checkout. Editable. |
identifier | Optional unique slug, for referencing an Offer without storing its id. Editable. |
pricing[] | One or more options. Options may be added or deactivated, never rewritten. |
merchantDestinationWallet | Payout destination (on each pricing option, not the offer). Editable — it never appears in what a subscriber signs, so changing it cannot invalidate an existing authorization. Overridden by a registered settlement wallet (/v1/wallets) for the same token. |
split | Multi-recipient payout routing. Immutable — set at creation or not at all. |
availableQuantity | Total redemptions allowed. Null = unlimited. |
expiresAt | After this, checkout returns 404. |
afterCompletion | { type: "hosted", customMessage? } or { type: "redirect", redirectUrl }. |
collectFields | Which payer fields checkout collects: fullName, email, dob, pob, address, phone. |
allowedPaymentMethods | { walletConnect, walletTransfer }. walletTransfer is only valid on one_time pricing — a recurring pull needs an allowance, which only wallet-connect grants. |
trialDays | Delays the first cycle. Authorization still happens immediately. |
maxCycles | Auto-cancel after this many cycles. Null = bills forever. |
maxFailedCycles | Auto-cancel after this many consecutive failures, independent of the 7-day past-due grace window. |
features | Descriptive metadata shown at checkout. Never affects billing. |
metadata | Free-form key/value, never interpreted by Tidepay. |
reference | Optional merchant-defined reference string. Not required to be unique. |
active | Deactivating hides it from new checkouts; existing subscriptions keep billing. |
Migration from the current API
No client compatibility layer is planned — there are no external API consumers yet, so /v1 is redefined in place rather than versioned to /v2.
Endpoint mapping
| Current | New | Action | Reason |
|---|---|---|---|
POST /v1/plans | POST /v1/offers | MERGE | Plan becomes a pricing option |
GET /v1/plans | GET /v1/offers | MERGE | |
GET /v1/plans/{id} | GET /v1/offers/{id} | MERGE | Plans are no longer addressable alone |
PATCH /v1/plans/{id} | PATCH /v1/offers/{id} | MERGE | Editable fields move up to Offer |
DELETE /v1/plans/{id} | DELETE /v1/offers/{id} | MERGE | |
GET /v1/plans/identifier/{identifier} | GET /v1/offers?identifier= | MERGE | A dedicated path for slug lookup is noise |
POST /v1/paylinks | POST /v1/offers | RENAME | Already accepts an inline plan — the direct ancestor of Offer |
GET /v1/paylinks | GET /v1/offers | RENAME | |
GET /v1/paylinks/{id} | GET /v1/offers/{id} | RENAME | |
PATCH /v1/paylinks/{id} | PATCH /v1/offers/{id} | RENAME | |
DELETE /v1/paylinks/{id} | DELETE /v1/offers/{id} | RENAME | |
POST /v1/paylinks/{id}/variants | POST /v1/offers/{offerId}/pricing | REPLACE | A workaround for pricing not being first-class; the replacement keeps one offer and one checkout URL |
PATCH /v1/paylinks/batch | — | REMOVE | Batch endpoints dropped as shipped; not reintroduced on offers |
DELETE /v1/paylinks/batch | — | REMOVE | |
POST /v1/subscriptions | same | KEEP | Body changes: planId → offerId + priceId |
GET /v1/subscriptions | same | KEEP | |
GET /v1/subscriptions/{id} | same | KEEP | Response gains the chosen pricing option |
PATCH /v1/subscriptions/{id} | same | KEEP | |
DELETE /v1/subscriptions/{id} | POST /v1/subscriptions/{id}/cancel | RENAME | Cancelling is not deletion |
| — | POST /v1/subscriptions/{id}/resume | NEW | |
| — | POST /v1/subscriptions/{id}/migrate | NEW | Explicit repricing, with re-authorization |
POST /v1/subscriptions/{id}/retry | same | KEEP | |
POST /v1/payments | same | KEEP | Already named payments, not charges |
GET /v1/payments | same | KEEP | Now lists recurring charges too |
GET /v1/payments/{id} | same | KEEP | |
POST /v1/payments/{id}/cancel | same | KEEP | |
GET /v1/invoices | GET /v1/payments?subscriptionId= | MERGE | One place to look for any transaction |
GET /v1/invoices/{id} | GET /v1/payments/{id} | MERGE | |
/v1/customers/* | same | KEEP | Already clean |
GET /v1/currencies | same | KEEP | Already represents token + chain + decimals + address |
/v1/wallets/* | /v1/wallets/* | KEEP | Rename to /settlement-wallets proposed but deferred — cosmetic only |
/v1/webhooks/secret* | same | KEEP | Operational |
POST /v1/sandbox/charge | same | KEEP | Internal test surface |
30 endpoints → 26. Seven public concepts → six.
Data model
The underlying schema already implements most of this — the work is renaming and reversing one foreign key, not remodeling.
The existing plan table is already a price: its billing terms are immutable (PATCH /plans rejects amount, tokenKeys, intervalDay). The existing planAcceptedToken table is already a per-token pricing table, holding frozen tokenAmountBaseUnits and the on-chain plan references. What is missing is only the grouping of several prices under one sellable Offer.
Table renames:
tidepay_paylink → tidepay_offer
tidepay_plan → tidepay_offer_price
tidepay_plan_accepted_token → tidepay_offer_price_token
tidepay_plan_split → tidepay_offer_price_splitThen the foreign key inverts. Today paylink.planId → plan.id (many links to one plan). It becomes offer_price.offerId → offer.id (one Offer, many prices).
Backfill:
- Every existing
paylinkbecomes anoffer, and itsplanpoints at it. - Plans with no paylink (created via
POST /v1/plansdirectly) get a synthetic Offer with no public slug. - Subscriptions gain
offerIdandpriceId. The pricing snapshot they need already exists —planName,planAmount,planCurrency,chainId,tokenKey— only the explicit reference to which option is new.
Nothing is deleted. Frozen prices and published on-chain plan references stay untouched; breaking them would invalidate active subscribers' authorizations.
A latent bug this refactor should fix. Today, cycle.ts re-quotes a
fiat-denominated plan from the live plan row each cycle, rather than from
the subscription's snapshot. That is safe only because amount is not
editable through the API. The rewrite should make the billing cycle read from
the subscription snapshot, so that a future editable price cannot silently
reprice existing subscribers.
Paylink variants cannot be regrouped
Paylinks created via POST /paylinks/{id}/variants cloned an entire plan and stored no link back to the base. That relationship was never recorded, so it cannot be recovered — each existing variant migrates to its own single-price Offer. This is semantically valid, just flatter than if the grouping had been modeled from the start.
Internal consumers to update
These break with the API and must ship together: the apps/web dashboard (/api/dashboard/plans, /api/dashboard/paylinks, embed/[planId]), apps/admin, packages/embed-widget, and the WooCommerce plugin (which uses the inline plan form of POST /v1/paylinks). Roughly 57 files under apps/web reference paylink or planId.
Open question: resume semantics
POST /v1/subscriptions/{id}/resume needs a decision, because cancellation is currently terminal on-chain — EVM revokes the allowance, Solana closes the delegation account.
The proposal above assumes both paths, with the response saying which one applied:
- Resuming from
pastDuereactivates directly. The authorization survived, so no customer action is needed. - Resuming from
canceledreturns anauthorizationUrl, because the customer must reconnect a wallet and re-authorize. This cannot be done server-side.
{
"id": "sub_01j8x...",
"status": "pendingAuthorization",
"authorizationUrl": "https://tidepay.cc/authorize/sub_01j8x..."
}Related
- Core concepts — the current, live model
- Chains — supported chain and token combinations
- Webhooks