TidePay

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.

BeforeAfter
/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/wallets

Two 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 × N

Offer

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/subscriptions program 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}/migrate

cancel 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}/cancel

GET /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

FieldNotes
namePublic, shown at checkout. Editable.
descriptionPublic, shown at checkout. Editable.
imageUrlCover image at checkout. Editable.
identifierOptional unique slug, for referencing an Offer without storing its id. Editable.
pricing[]One or more options. Options may be added or deactivated, never rewritten.
merchantDestinationWalletPayout 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.
splitMulti-recipient payout routing. Immutable — set at creation or not at all.
availableQuantityTotal redemptions allowed. Null = unlimited.
expiresAtAfter this, checkout returns 404.
afterCompletion{ type: "hosted", customMessage? } or { type: "redirect", redirectUrl }.
collectFieldsWhich 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.
trialDaysDelays the first cycle. Authorization still happens immediately.
maxCyclesAuto-cancel after this many cycles. Null = bills forever.
maxFailedCyclesAuto-cancel after this many consecutive failures, independent of the 7-day past-due grace window.
featuresDescriptive metadata shown at checkout. Never affects billing.
metadataFree-form key/value, never interpreted by Tidepay.
referenceOptional merchant-defined reference string. Not required to be unique.
activeDeactivating 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

CurrentNewActionReason
POST /v1/plansPOST /v1/offersMERGEPlan becomes a pricing option
GET /v1/plansGET /v1/offersMERGE
GET /v1/plans/{id}GET /v1/offers/{id}MERGEPlans are no longer addressable alone
PATCH /v1/plans/{id}PATCH /v1/offers/{id}MERGEEditable fields move up to Offer
DELETE /v1/plans/{id}DELETE /v1/offers/{id}MERGE
GET /v1/plans/identifier/{identifier}GET /v1/offers?identifier=MERGEA dedicated path for slug lookup is noise
POST /v1/paylinksPOST /v1/offersRENAMEAlready accepts an inline plan — the direct ancestor of Offer
GET /v1/paylinksGET /v1/offersRENAME
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}/variantsPOST /v1/offers/{offerId}/pricingREPLACEA workaround for pricing not being first-class; the replacement keeps one offer and one checkout URL
PATCH /v1/paylinks/batch—REMOVEBatch endpoints dropped as shipped; not reintroduced on offers
DELETE /v1/paylinks/batch—REMOVE
POST /v1/subscriptionssameKEEPBody changes: planId → offerId + priceId
GET /v1/subscriptionssameKEEP
GET /v1/subscriptions/{id}sameKEEPResponse gains the chosen pricing option
PATCH /v1/subscriptions/{id}sameKEEP
DELETE /v1/subscriptions/{id}POST /v1/subscriptions/{id}/cancelRENAMECancelling is not deletion
—POST /v1/subscriptions/{id}/resumeNEW
—POST /v1/subscriptions/{id}/migrateNEWExplicit repricing, with re-authorization
POST /v1/subscriptions/{id}/retrysameKEEP
POST /v1/paymentssameKEEPAlready named payments, not charges
GET /v1/paymentssameKEEPNow lists recurring charges too
GET /v1/payments/{id}sameKEEP
POST /v1/payments/{id}/cancelsameKEEP
GET /v1/invoicesGET /v1/payments?subscriptionId=MERGEOne place to look for any transaction
GET /v1/invoices/{id}GET /v1/payments/{id}MERGE
/v1/customers/*sameKEEPAlready clean
GET /v1/currenciessameKEEPAlready represents token + chain + decimals + address
/v1/wallets/*/v1/wallets/*KEEPRename to /settlement-wallets proposed but deferred — cosmetic only
/v1/webhooks/secret*sameKEEPOperational
POST /v1/sandbox/chargesameKEEPInternal 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_split

Then 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 paylink becomes an offer, and its plan points at it.
  • Plans with no paylink (created via POST /v1/plans directly) get a synthetic Offer with no public slug.
  • Subscriptions gain offerId and priceId. 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.

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 pastDue reactivates directly. The authorization survived, so no customer action is needed.
  • Resuming from canceled returns an authorizationUrl, 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..."
}

On this page