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.
How it works
- Your site says once what it sells: its plans and their prices (Step 2).
- Your pricing page shows them with
<heart-pricing>. A plan's button posts to your own server (Step 5). - Your server asks Heart Photos for a Checkout page for the signed-in person and redirects them there (Step 3).
- They pay on Stripe. Stripe tells Heart Photos, which keeps their subscription.
- 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.
- It reaches your own site and nothing else. Another site's plans and customers are out of its reach, and a person's key or sign-in is refused by these calls.
- It cannot delete anything. Every site key is made without that right.
- It is the same key a site uses to keep its photos here. If your site already has a
HEART_PHOTOS_TOKEN, that is its key: use the same value. - Only an admin can make one. Every site sells through the one Stripe account Heart Photos and Heart ID sell through, so a site is let in by a person: ask George.
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)
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"] }
]
}
| Field | What it is |
|---|---|
name | Your site as its customers know it, 1 to 40 characters. Stripe shows "name plan name" on Checkout, receipts and the billing portal. |
return_origins | 1 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_url | Optional. Linked from the billing portal. |
logo_url | Optional. 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_url | Optional. An https address of a square icon (at least 128 by 128), Checkout's tab icon. |
plans[].key | Your name for the plan in code: a-z, 0-9 and -, up to 24. This is what Step 4 answers. |
plans[].name | The plan as people read it, up to 30 characters. |
plans[].month | The monthly price in US cents (299 is $2.99), at least 50. 0 makes a free plan: shown in the table, never sold. |
plans[].year | Optional 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_days | Optional free days, 0 to 30. Offered once per person per site. |
plans[].trial_needs_card | Optional. 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[].recommended | Optional, one plan at most: the table puts a Recommended band on it. |
plans[].headline | Optional line above the features, like "No ads" or "50 GB of photos". |
plans[].features | Up 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.
- Back from Checkout, your
success_urlpage has?session=cs_.... Pass it on:GET /subscribers/u_1842?session=cs_.... The purchase is then filed that moment, so the page can say "Your Plus plan is on" instead of waiting a few seconds for Stripe's own message. - On every request that needs it, read the answer from a cache of a few minutes per person rather than asking each time. Ask again after sign-in and after the person comes back from the portal.
statusis Stripe's:trialing,activeandpast_due(a card that failed, while Stripe retries) are allactive: true;canceled,unpaid,incompleteandincomplete_expiredare not.ends_atis set once they cancel. They keep the plan until then; say "Your plan ends Oct 30, 2026" and offer Manage your plan to change their mind.trial_usedis true once they have had the free days. Pass it to the table (Step 5) so it stops offering them.GET /api/v1/pay/subscribers(no user) lists everybody who has subscribed, newest change first, up to 1,000.
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.
| Attribute | What it does |
|---|---|
site | Your slug. Required. |
action | Your checkout route (Step 3). Its form posts plan and period. Default /billing/checkout. |
manage | Your portal route. Default /billing/portal. |
current | The 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-used | Present when their trial_used is true: no trial is offered. |
free-href | Where 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
| Call | Auth | Does |
|---|---|---|
PUT /api/v1/pay/plans | token | Sets what the site sells (Step 2). Answers the plans as stored. |
GET /api/v1/pay/plans | token | The site's plans, return origins included. |
GET /api/v1/pay/sites/{site} | none | The name and plans, for any page (CORS open). What the table reads. |
POST /api/v1/pay/checkout | token | A 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/portal | token | One person's billing portal. |
GET /api/v1/pay/subscribers/{user} | token | What one person pays for (Step 4). URL-encode the id. ?session= files a purchase at once. |
GET /api/v1/pay/subscribers | token | Everybody who has subscribed, up to 1,000. |
GET /api/v1/pay/mode | token | production 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/mode | token | Switches between them: {"mode": "sandbox"}. |
GET /pay.js | none | The <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
| When | What happens |
|---|---|
| A trial starts | status 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 upgrade | At 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 downgrade | At 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 cancel | In 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 fails | past_due while Stripe retries and emails them; still active. Then canceled or unpaid if it never goes through. |
| Receipts and invoices | Stripe emails them, and the portal lists them. |
| A refund | George, in the Stripe dashboard. |
| Money and tax | US 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 see | Your 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:
- George attaches it in Studio: Sites, your site's row, Attach a live Stripe account, with that account's
live secret key (
sk_live_..., or a restrictedrk_live_...that can read the account and write products, prices, Checkout, the customer portal, customers, subscriptions and webhook endpoints). Heart Photos checks it with Stripe, keeps it sealed and never shows it again. - Everything is made there at once: a webhook endpoint pointing at
/stripe/webhook/live/{site}with its own signing secret, your plans' products and prices under the samepay_ids, and your billing portal. - Production sells there from then on.
GET /api/v1/pay/modenames the account instripe_account. Your sandbox, if any, is unchanged. - Customers are the account's own. Somebody who bought before the move keeps their subscription, and their portal, where they bought it; their next new purchase makes them a customer in your account.
- Your name everywhere. Checkout, receipts and the portal carry the account's public business name, icon and colors, set in that account's Stripe settings.
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:
- Every call works as in production: plans, Checkout, the portal and the subscriber answers, which carry
"mode": "sandbox". - Checkout takes Stripe's test cards, like
4242 4242 4242 4242with any future date and any CVC, and charges nothing. Stripe's pages say so at the top. - Subscriptions are kept apart. A test purchase never reads as a paid one, and switching back to production finds the real subscribers exactly as they were. While your site is in sandbox mode, its real paying customers read as not paying: test before you launch, or switch for a few minutes at a time.
- Changes arrive the same way. Cancelling or changing plan in the sandbox's portal reaches
/subscribers/{user}within seconds, through the sandbox's own webhook, which Heart Photos made when the sandbox was attached. - Your key can be seen again in Studio while you are in sandbox mode. Switching to production lets go of that copy: the key goes on working, and from then on a new one is shown once.
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
MONETIZE_TOKENis a server secret and appears in no client code, bundle, repository or log.- The plans are in the repository and sent with
PUT /api/v1/pay/plans;GET /api/v1/pay/sites/{slug}answers them. POST /billing/checkoutsends a signed-out visitor to sign in, and everybody else to theurlit is given, with303.POST /billing/portaldoes the same for the portal. "Manage your plan" in your account menu posts to it.success_urlcarries?session={CHECKOUT_SESSION_ID}, and that page passes the session to/subscribers/{user}.- Paid features check
activeandplan, from a per-person cache of a few minutes, refreshed after sign-in, checkout and the portal. - The pricing page has
<heart-pricing>withcurrentandtrial-usedset for the signed-in person. - A person with
ends_atis told when their plan ends; one who ispast_dueis asked to update their card. - 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.