Docs · Pricing example

Heart Pay

Heart Pay: subscriptions for your site

Sell monthly and yearly plans on your site in an afternoon. Heart Photos runs the payments for you: Stripe Checkout, the billing portal, receipts, renewals, failed cards, trials and cancellations. Your server makes three HTTP calls with one token, and your pricing page drops in one tag. You never hold a Stripe key, answer a Stripe webhook, or keep a billing table of your own.

If you are an AI agent asked to add paid plans to a site: do the five steps below in order, then tick off the checklist. Everything you need is on this page; the only thing you cannot do yourself is Step 1, which a person gives you.

How it works

  1. Your site says once what it sells: its plans and their prices (Step 2).
  2. Your pricing page shows them with <heart-pricing>. A plan's button posts to your own server (Step 5).
  3. Your server asks Heart Photos for a Checkout page for the signed-in person and redirects them there (Step 3).
  4. They pay on Stripe. Stripe tells Heart Photos, which keeps their subscription.
  5. Your server asks Heart Photos what that person pays for, and unlocks what their plan includes (Step 4).

Your people stay yours. You name each person by your own user id, and give their email for Stripe's receipts. Nobody signs in to Heart Photos.

1Get your site's token

What is MONETIZE_TOKEN?

It is your site's key to Heart Photos: a long random string starting heart_ that belongs to your site's own account here, named site: and your slug (site:heartbeat-games). It is not a Stripe key, and it is not anybody's sign-in. Your server sends it with every call on this page, and it is how Heart Photos knows which site is asking: whose plans to set, whose customers to send to Checkout, and whose subscribers to answer about. MONETIZE_TOKEN is only the name this page gives the secret it lives in on your server.

How it is made

George opens Sites in Studio (heart.photos/studio#sites, admins only), types your site's name and slug, and presses Create the site. The slug (heartbeat-games) is your site's name here: a lowercase letter, then a-z, 0-9 and -. The key is shown there. While your site is in sandbox mode he can see it again at any time; once it is in production, its key is shown once and never again, so copy it to your server straight away. The same place can roll it (a new key, with the old one ending at once, shown once in production) or delete it (no key at all until the next roll).

A site can also be made on the server's command line, which keeps only the key's hash and writes the key to a file, once. Studio shows such a key as made on the command line; roll it to have one that can be seen again:

heart -site-account heartbeat-games ./heartbeat-games-token.txt

Put it on your server

Keep it in your server's secrets as MONETIZE_TOKEN. On Fly, paste it into the import rather than typing it into a command, so it stays out of your shell's history:

flyctl secrets import -a your-app
MONETIZE_TOKEN=heart_...        (paste the line, press Enter, then Ctrl-D)
The token never goes into a browser, a bundle, a repository or a log. It is your site's hand on its customers' subscriptions. Every call below sends it as Authorization: Bearer $MONETIZE_TOKEN. If it ever leaks, ask George to roll it, then set the new one: the old key stops working the moment it is rolled.

2Say what you sell

Send your plans, cheapest first. Keep this JSON in your repository (say pay-plans.json) and send it again whenever it changes:

curl -X PUT https://heart.photos/api/v1/pay/plans \
  -H "Authorization: Bearer $MONETIZE_TOKEN" -H "Content-Type: application/json" \
  -d @pay-plans.json
{
  "name": "Heartbeat Games",
  "return_origins": ["https://heartbeat.games", "https://evergamer.com", "http://localhost:5173"],
  "privacy_url": "https://heartbeat.games/privacy",
  "logo_url": "https://heartbeat.games/checkout-logo.png",
  "icon_url": "https://heartbeat.games/checkout-icon.png",
  "plans": [
    { "key": "free", "name": "Free", "month": 0,
      "headline": "Every game, free", "features": ["All 12 games", "Cloud saves"] },
    { "key": "plus", "name": "Plus", "month": 299, "year": 2990, "trial_days": 7, "recommended": true,
      "headline": "No ads", "features": ["No ads anywhere", "Early access to new games"] },
    { "key": "pro", "name": "Pro", "month": 999, "year": 9990,
      "headline": "Your own servers", "features": ["Private multiplayer servers", "Priority support"] }
  ]
}
FieldWhat it is
nameYour site as its customers know it, 1 to 40 characters. Stripe shows "name plan name" on Checkout, receipts and the billing portal.
return_origins1 to 10 origins (https://host, nothing after) that Checkout and the portal may send people back to. http://localhost:port is allowed for development.
privacy_urlOptional. Linked from the billing portal.
logo_urlOptional. An https address of your logo (PNG or JPG, under 512 KB), which Checkout shows at the top in place of your name. Give the file a new address when it changes: Stripe and caches keep the old image under the old one.
icon_urlOptional. An https address of a square icon (at least 128 by 128), Checkout's tab icon.
plans[].keyYour name for the plan in code: a-z, 0-9 and -, up to 24. This is what Step 4 answers.
plans[].nameThe plan as people read it, up to 30 characters.
plans[].monthThe monthly price in US cents (299 is $2.99), at least 50. 0 makes a free plan: shown in the table, never sold.
plans[].yearOptional yearly price in US cents. Ten months' price (2990 for 299) makes the table say "2 months free". Leave it out to sell monthly only.
plans[].trial_daysOptional free days, 0 to 30. Offered once per person per site.
plans[].trial_needs_cardOptional. true asks for a card when the trial starts. By default no card is needed, and a trial with no card by its end simply ends, charging nothing.
plans[].recommendedOptional, one plan at most: the table puts a Recommended band on it.
plans[].headlineOptional line above the features, like "No ads" or "50 GB of photos".
plans[].featuresUp to 20 short lines. The table puts "Everything in the plan before, plus ..." above each plan's list, so list only what that plan adds.

A price you change applies to new buyers. Everybody already paying keeps their price until they change plan. Anything wrong in the JSON comes back as 400 {"error": "..."} naming the field.

3Add two routes to your server

Your pricing table's buttons post to these, so they run where your session is. Each works out who is signed in, asks Heart Photos for a Stripe page, and redirects there with 303.

POST /billing/checkout

Receives the form fields plan and period (month or year) and sends the person to Stripe Checkout:

POST https://heart.photos/api/v1/pay/checkout
Authorization: Bearer $MONETIZE_TOKEN
Content-Type: application/json

{ "user": { "id": "u_1842", "email": "[email protected]" },
  "plan": "plus", "period": "month",
  "success_url": "https://heartbeat.games/account?session={CHECKOUT_SESSION_ID}",
  "cancel_url": "https://heartbeat.games/pricing" }

200 { "url": "https://checkout.stripe.com/c/pay/cs_live_...", "session": "cs_live_..." }

Leave {CHECKOUT_SESSION_ID} exactly as written in success_url: Stripe puts the session's id there. Somebody who already pays is never sold a second subscription: the answer is then {"url": "https://billing.stripe.com/...", "portal": true, "change": true}: Stripe's page for moving to that plan, which shows what it costs before they confirm. An upgrade starts at once and charges the new plan's price less the unused part of the old one; a downgrade waits for the end of the period they paid for, with nothing given back (What happens when). Asked for the plan they already have, it is their billing portal, with "change": false. Either way, redirect to url.

POST /billing/portal

Receives nothing and sends the person to their billing portal (change plan, cancel, card, invoices):

POST https://heart.photos/api/v1/pay/portal
Authorization: Bearer $MONETIZE_TOKEN
Content-Type: application/json

{ "user": { "id": "u_1842" }, "return_url": "https://heartbeat.games/account" }

200 { "url": "https://billing.stripe.com/p/session/..." }
404 { "error": "this user has never subscribed (HA-0446 · r7k2qd)", "code": "HA-0446", "ref": "r7k2qd" }

In Go (net/http)

// pay.go: Heart Pay for this site (https://heart.photos/docs/pay).
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"time"
)

const payAPI = "https://heart.photos/api/v1/pay"

var payHTTP = &http.Client{Timeout: 20 * time.Second}

// heartPay calls Heart Pay with this site's token and decodes the answer into out.
func heartPay(method, path string, body, out any) error {
	var rd *bytes.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		rd = bytes.NewReader(b)
	} else {
		rd = bytes.NewReader(nil)
	}
	req, err := http.NewRequest(method, payAPI+path, rd)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("MONETIZE_TOKEN"))
	req.Header.Set("Content-Type", "application/json")
	resp, err := payHTTP.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		var e struct{ Error string }
		_ = json.NewDecoder(resp.Body).Decode(&e)
		return fmt.Errorf("heart pay %s: %d %s", path, resp.StatusCode, e.Error)
	}
	return json.NewDecoder(resp.Body).Decode(out)
}

// POST /billing/checkout
func handleCheckout(w http.ResponseWriter, r *http.Request) {
	u := currentUser(r) // your own sign-in: id and email
	if u == nil {
		http.Redirect(w, r, "/signin?next=/pricing", http.StatusSeeOther)
		return
	}
	var out struct{ URL string }
	err := heartPay("POST", "/checkout", map[string]any{
		"user":        map[string]string{"id": u.ID, "email": u.Email},
		"plan":        r.FormValue("plan"),
		"period":      r.FormValue("period"),
		"success_url": "https://heartbeat.games/account?session={CHECKOUT_SESSION_ID}",
		"cancel_url":  "https://heartbeat.games/pricing",
	}, &out)
	if err != nil {
		http.Error(w, "Payments could not be reached. Please try again.", http.StatusBadGateway)
		return
	}
	http.Redirect(w, r, out.URL, http.StatusSeeOther)
}

// POST /billing/portal
func handlePortal(w http.ResponseWriter, r *http.Request) {
	u := currentUser(r)
	if u == nil {
		http.Redirect(w, r, "/signin", http.StatusSeeOther)
		return
	}
	var out struct{ URL string }
	if err := heartPay("POST", "/portal", map[string]any{
		"user": map[string]string{"id": u.ID}, "return_url": "https://heartbeat.games/account",
	}, &out); err != nil {
		http.Redirect(w, r, "/pricing", http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, out.URL, http.StatusSeeOther)
}

// Subscription is what one person pays for (Step 4).
type Subscription struct {
	Active    bool       `json:"active"`
	Plan      string     `json:"plan"`
	Period    string     `json:"period"`
	Status    string     `json:"status"`
	PeriodEnd *time.Time `json:"period_end"`
	EndsAt    *time.Time `json:"ends_at"`
	TrialUsed bool       `json:"trial_used"`
}

// subscriptionOf asks what a person pays for; session is the Checkout session they came back from, or "".
func subscriptionOf(userID, session string) (Subscription, error) {
	var s Subscription
	path := "/subscribers/" + url.PathEscape(userID)
	if session != "" {
		path += "?session=" + url.QueryEscape(session)
	}
	return s, heartPay("GET", path, nil, &s)
}

In Node (Express)

// Heart Pay for this site (https://heart.photos/docs/pay). Node 18+ (global fetch).
const PAY = 'https://heart.photos/api/v1/pay';

async function heartPay(method, path, body) {
  const r = await fetch(PAY + path, {
    method,
    headers: { Authorization: `Bearer ${process.env.MONETIZE_TOKEN}`, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await r.json();
  if (!r.ok) throw new Error(`heart pay ${path}: ${r.status} ${data.error}`);
  return data;
}

app.post('/billing/checkout', express.urlencoded({ extended: false }), async (req, res) => {
  if (!req.user) return res.redirect(303, '/signin?next=/pricing');
  const { url } = await heartPay('POST', '/checkout', {
    user: { id: req.user.id, email: req.user.email },
    plan: req.body.plan, period: req.body.period,
    success_url: 'https://heartbeat.games/account?session={CHECKOUT_SESSION_ID}',
    cancel_url: 'https://heartbeat.games/pricing',
  });
  res.redirect(303, url);
});

app.post('/billing/portal', async (req, res) => {
  if (!req.user) return res.redirect(303, '/signin');
  const { url } = await heartPay('POST', '/portal', { user: { id: req.user.id }, return_url: 'https://heartbeat.games/account' });
  res.redirect(303, url);
});

const subscriptionOf = (userId, session = '') =>
  heartPay('GET', `/subscribers/${encodeURIComponent(userId)}` + (session ? `?session=${encodeURIComponent(session)}` : ''));

4Know who pays

GET https://heart.photos/api/v1/pay/subscribers/u_1842
Authorization: Bearer $MONETIZE_TOKEN

200 { "user": "u_1842", "email": "[email protected]", "active": true,
      "plan": "plus", "period": "month", "status": "active",
      "period_end": "2026-10-30T13:41:15Z", "trial_end": null, "ends_at": null, "trial_used": false }

Unlock a plan's features when active is true and plan is that plan. Somebody who has never paid answers {"active": false, "plan": ""}, and so does somebody whose subscription has ended.

5Drop in the pricing table

<script src="https://heart.photos/pay.js" defer></script>
<heart-pricing site="heartbeat-games" action="/billing/checkout" manage="/billing/portal"
               current="plus" trial-used free-href="/signup"></heart-pricing>

It draws the same table as heart.photos/pricing from your plans: the cards share their rows like a table, the recommended plan wears a band, the person's own plan is ringed and says "Your plan", and a Monthly / Yearly switch shows the other price. For somebody on a plan (a free one counts), every other plan's button reads Upgrade or Downgrade from theirs, and their own reads Manage your plan; somebody signed out sees Get started. Here it is with the plans from Step 2 (its buttons only say what they would send):

 

With the plans written inside the element as a <script type="application/json">, as this one is, it draws them without asking Heart Photos: handy to see a table before its plans are sent.

AttributeWhat it does
siteYour slug. Required.
actionYour checkout route (Step 3). Its form posts plan and period. Default /billing/checkout.
manageYour portal route. Default /billing/portal.
currentThe signed-in person's plan: their plan when active (Step 4), otherwise your free plan's key. Leave it out for somebody signed out.
trial-usedPresent when their trial_used is true: no trial is offered.
free-hrefWhere a free plan's Start free goes, like your sign-up page.

In React (and any single-page app), set the attributes from state. To make the call yourself instead of the form posting, listen for the element's cancelable heart-pricing-choose event:

useEffect(() => {
  const el = ref.current;
  const choose = async (e) => {
    e.preventDefault(); // this app calls its own API instead of the form posting
    const r = await fetch(e.detail.manage ? '/api/billing/portal' : '/api/billing/checkout', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ plan: e.detail.plan, period: e.detail.period }),
    });
    window.location.assign((await r.json()).url);
  };
  el.addEventListener('heart-pricing-choose', choose);
  return () => el.removeEventListener('heart-pricing-choose', choose);
}, []);

return <heart-pricing ref={ref} site="heartbeat-games" current={!user ? undefined : sub.active ? sub.plan : 'free'}
                     {...(sub.trial_used ? { 'trial-used': '' } : {})} />;

Its colors are CSS custom properties, dark by default: --hp-bg, --hp-panel, --hp-panel-2, --hp-ink, --hp-muted, --hp-line, --hp-accent, --hp-cta, --hp-good and --hp-pick (the recommended plan's band). It takes your page's font. Set them on the element: heart-pricing { --hp-accent: #ff5470; --hp-cta: #d63a57; }.

Reference

CallAuthDoes
PUT /api/v1/pay/planstokenSets what the site sells (Step 2). Answers the plans as stored.
GET /api/v1/pay/planstokenThe site's plans, return origins included.
GET /api/v1/pay/sites/{site}noneThe name and plans, for any page (CORS open). What the table reads.
POST /api/v1/pay/checkouttokenA Stripe Checkout page for one person (Step 3). For somebody who already pays: Stripe's page for changing to that plan ("change": true), or their portal for the plan they have.
POST /api/v1/pay/portaltokenOne person's billing portal.
GET /api/v1/pay/subscribers/{user}tokenWhat one person pays for (Step 4). URL-encode the id. ?session= files a purchase at once.
GET /api/v1/pay/subscriberstokenEverybody who has subscribed, up to 1,000.
GET /api/v1/pay/modetokenproduction or sandbox, whether a sandbox is attached (Testing), and stripe_account: your site's own Stripe account once one is attached, else "" (Your own Stripe account).
PUT /api/v1/pay/modetokenSwitches between them: {"mode": "sandbox"}.
GET /pay.jsnoneThe <heart-pricing> element.

Old addresses, deprecated. Until 17 October 2026 Heart Pay was called Monetize, and its calls began /api/v1/monetize/ (as in POST /api/v1/monetize/checkout) and its table was /monetize.js. Those still answer exactly as the new ones do, so nothing already built breaks, but use /api/v1/pay/ and /pay.js in anything new, and move to them when you next touch the code.

Errors are {"error": "...", "code": "HA-0446", "ref": "r7k2qd"}: the message ends with its code and reference, as in this user has never subscribed (HA-0446 · r7k2qd). The code names the error and never changes; the reference is new each time and finds that moment in our logs, so quote both when you ask us about one. Show the message as it is; to act on an error, read the status or the code, never the words. The status is 400 (the request, naming the field), 401 (no token), 403 (a person's token rather than a site's), 404 (no plans yet, or no such subscriber), 409 (sandbox mode with no sandbox attached), 502 (Stripe did not answer: try again) or 503.

What happens when

WhenWhat happens
A trial startsstatus is trialing and active true, with no charge. With no card by trial_end, the subscription ends and nothing is charged; with one, it becomes active and bills.
They upgradeAt once. Checkout for a higher plan answers "change": true and a Stripe page for that one change, which shows what is due today (the new plan's full price, less the unused part of the old one) and takes it. The new plan starts a fresh period that day, and plan changes within seconds.
They downgradeAt the end of the period they paid for, with nothing given back: the same Stripe page confirms it, they keep the higher plan until then, and plan changes on that day. The same goes for a shorter period (yearly to monthly).
They cancelIn the portal. The plan runs to the end of what they paid for: ends_at is set, active stays true until then, then canceled.
A card failspast_due while Stripe retries and emails them; still active. Then canceled or unpaid if it never goes through.
Receipts and invoicesStripe emails them, and the portal lists them.
A refundGeorge, in the Stripe dashboard.
Money and taxUS dollars. Payments land in your site's own Stripe account once one is attached (below), else in the Freedom! Family Limited account Heart Photos has sold through. No sales tax is added.
Whose name they seeYour site's logo_url (or, without one, its name) at the top of Checkout, and its name as the billing portal's headline. Stripe's terms text, the receipts and the portal's "Return to" link carry the account's business name, which every site shares.

Your own Stripe account

A site can sell in a live Stripe account of its own (George, 17 October 2026: Heart Photos and Vi.ki each get one in his Stripe organization, as separate accounts, with no Connect). Nothing changes for your site: the same token, the same calls, the same answers. Heart Pay routes by the site the token belongs to, so your code never names an account. What changes is where the money goes and whose name Stripe shows:

Without an attached account a site sells in the Freedom! account, exactly as before.

Testing in a Stripe sandbox

Heart Photos sells through a live Stripe account, so your site tests in a sandbox of its own: a Stripe test environment George makes for your site and attaches to it in Studio. From then on your site switches between the sandbox and production whenever it likes, with the same token:

curl -X PUT https://heart.photos/api/v1/pay/mode \
  -H "Authorization: Bearer $MONETIZE_TOKEN" -H "Content-Type: application/json" \
  -d '{"mode": "sandbox"}'

It answers {"site": "heartbeat-games", "mode": "sandbox", "sandbox_attached": true}; GET /api/v1/pay/mode answers the same, and {"mode": "production"} switches back. Without a sandbox attached, switching to it is a 409. In sandbox mode:

Put http://localhost:port in return_origins while you build. Run the whole round in the sandbox: subscribe, check /subscribers/{user}, change plan and cancel in the portal, check plan and ends_at; then switch to production. Without a sandbox, a plan with trial days still goes all the way through in production with no card and no charge.

Checklist

  1. MONETIZE_TOKEN is a server secret and appears in no client code, bundle, repository or log.
  2. The plans are in the repository and sent with PUT /api/v1/pay/plans; GET /api/v1/pay/sites/{slug} answers them.
  3. POST /billing/checkout sends a signed-out visitor to sign in, and everybody else to the url it is given, with 303.
  4. POST /billing/portal does the same for the portal. "Manage your plan" in your account menu posts to it.
  5. success_url carries ?session={CHECKOUT_SESSION_ID}, and that page passes the session to /subscribers/{user}.
  6. Paid features check active and plan, from a per-person cache of a few minutes, refreshed after sign-in, checkout and the portal.
  7. The pricing page has <heart-pricing> with current and trial-used set for the signed-in person.
  8. A person with ends_at is told when their plan ends; one who is past_due is asked to update their card.
  9. The whole round was run in the sandbox with a test card, and the site is back in production (GET /api/v1/pay/mode).

A prompt for your agent

Add paid monthly subscriptions to this site with Heart Pay, following
https://heart.photos/docs/pay exactly. Our slug is heartbeat-games and the token is
in the server secret MONETIZE_TOKEN. Plans: Free (free), Plus ($2.99/month, $29.90/year,
7-day trial, recommended), Pro ($9.99/month, $99.90/year). Put the plans in
pay-plans.json and send them, add POST /billing/checkout and POST /billing/portal,
gate the paid features on /subscribers/{user}, and put <heart-pricing> on /pricing.
Finish with the checklist at the end of that page.

Heart Photos sells its own plans on the same machinery, at heart.photos/pricing.