Start for free. Sign up without a card and you'll get a test project and a test key right away. Once your company (or, for a personal account, you) is verified, you can create a live key in the portal.
Payments: sell on your own site
Everything on this page works; where a part has a limit, it is marked. Your
chat can always tell you where your project stands: get_payments_status
returns availability (live) and mode (live when the project takes real payments, test when every
payment is a test payment and no money moves). The same two words are used
everywhere: in the tools, in the portal and on this page.
How the money moves
You are the seller. You connect your own payment account in the Hypamoon portal (Payments), and the money of every sale goes straight to that account. Hypamoon never holds it. Hypamoon takes an application fee per successful payment, only after the owner of the project has agreed to it in the portal. A test project always pays in test mode.
You are also responsible for what a seller has to publish: your terms, your privacy notice, your returns policy and, if you sell to consumers in the EU, VAT. Where available, a paid order gets an invoice in your name, never in Hypamoon's (see VAT and invoices); your order data is yours to export.
Products
A product is a document in the product collection of the project. Create that collection once:
- with MCP:
create_collectionwith"role": "products"(or, on connections without the shop settings,create_product_collection); - with REST:
POST /api/v1/collectionswith{"name": "products", "role": "products"}(orPOST /api/v1/payments/product-collection).
Then add products with insert_document. The fields that matter for the
checkout:
| field | meaning |
|---|---|
name |
What the buyer sees, 1 to 120 characters. |
price_cents |
A whole number in the smallest unit of the currency, like 1995 for 19.95. VAT included, unless the shop setting prices_include_vat is false. |
currency |
Optional, ISO 4217. Left out: the shop currency (EUR by default). One checkout is one currency. See Currencies. |
stock |
Optional whole number. Left out: unlimited. It goes down when an order is paid. |
active |
Optional. false takes the product off sale. |
line_fields |
Optional, at most 3 questions per order line, like an engraving or a name on a cake. Same shape as checkout_fields below. |
tax_class |
Optional, where available: standard (default), reduced, zero or exempt. Which VAT rate of the shop's rate table the product gets (see VAT and invoices). A variant may have its own tax_class. |
kind |
Optional: physical (default), digital, service or gift_card. With VAT per EU country, goods and digital products follow the buyer's country; a service stays in the seller's country. |
Other fields (description, image_url, sku, anything you like) may be
added; the checkout ignores them. The product collection can be read by a site
but never written by one: a visitor can never set a price.
Currencies
The currency is your choice, per product. Set currency on a product, or a
default for the whole shop with the shop setting currency; a product
without either is EUR. Hypamoon never converts a price: if you sell in EUR and
GBP, you set both prices yourself, as two products.
You can sell in EUR, USD, GBP, CAD, AUD, CHF, SEK, NOK, DKK and PLN. Currencies without cents (like JPY) are not available. What a buyer can pay with depends on the currency and on your payment provider:
| payment provider for Europe | second payment provider (outside Europe) | |
|---|---|---|
| One payment in EUR | Every method you turned on, like iDEAL, Bancontact, cards, Apple Pay and PayPal. | Every method you turned on. |
| One payment in another currency | Cards, Apple Pay and PayPal. iDEAL and Bancontact are EUR only. | Cards and Apple Pay, plus the local methods your account offers in that currency. |
| A subscription | EUR only. | Every currency above. |
Everything after the sale stays in the currency of the order: the order, the invoice and credit notes, a gift card bought with it and its balance, and every refund. A gift card only pays for an order in its own currency.
The shop setting payment_methods (optional: card, apple_pay, paypal,
ideal, bancontact) limits what the checkout offers. When none of the
chosen methods can take the order's currency, the checkout refuses the order
with currency_not_available, and configure_shop refuses the setting, with
the way out in the message (add a method that can, leave payment_methods
out, or price in EUR).
Selling in another currency than EUR needs fee terms that list the fee per
currency. Terms agreed to before 14 October 2026 cover EUR only; the owner
agrees to the current terms in the portal (Payments). get_payments_status
shows currencies: what the project can sell in once (one_time) and as a
subscription (subscriptions), the methods outside EUR, and whether the fee
terms cover other currencies.
Variants (where available)
A product that comes in sizes or colours stays one document, with options
and variants:
{
"name": "T-shirt",
"price_cents": 2500,
"options": [
{ "name": "Size", "values": ["S", "M", "XL"] },
{ "name": "Colour", "values": ["Blue", "Black"] }
],
"variants": [
{ "id": "s-blue", "options": { "Size": "S", "Colour": "Blue" }, "stock": 4, "sku": "TS-S-BL" },
{ "id": "xl-black", "options": { "Size": "XL", "Colour": "Black" }, "price_cents": 2700, "stock": 2, "image_url": "https://example.com/xl-black.jpg" }
]
}| field | meaning |
|---|---|
options |
At most 3, each with a name and 1 to 50 values. |
variants |
At most 100. Each has an id (letters, digits, _ and -, unique in the product) and a value for every option. |
variants[].price_cents |
Optional. Left out: the product's price_cents. |
variants[].stock |
Optional. Left out: the product's stock counts for this variant (a shared stock), and without that it is unlimited. |
variants[].active |
Optional. false takes only this variant off sale. |
variants[].sku, variants[].image_url |
Optional. |
A product with variants sells only a variant: every line names a
variant_id. The stock goes down on the variant when the order is paid, and
back up on a full refund that was never shipped. When a product document does
not fit these rules, insert_document says which field is wrong (like
data.variants.2.options.Size). A product without variants works exactly as
before.
Importing from WooCommerce turns a variable product and its variations into one product with variants. Products imported earlier as separate variations stay as they are.
The buy button
get_checkout_snippet returns a button and the script tag:
<button type="button" data-hypamoon-checkout data-hypamoon-token="hm_pub_..." data-hypamoon-product="<product id>">Buy now</button>
<p data-hypamoon-checkout-status role="status"></p>
<script src="https://api.hypamoon.com/checkout.js" defer></script>Options on the button: data-hypamoon-items (a JSON list of
{"product_id", "quantity", "fields"} for several lines),
data-hypamoon-quantity, data-hypamoon-shipping="true" (ask for an address),
data-hypamoon-return (a path on the site to come back to), and
data-hypamoon-terms and data-hypamoon-privacy (https links, used when the
shop settings have none). The button uses the project's public token; the
site's origin must be in its allowed_origins.
When the visitor clicks, a small window asks for an email address (and an
address with shipping) and runs a bot check. Before the pay button it shows
the total, the name of the seller and the links to the seller's terms and
privacy notice; without a total the pay button stays off. Then the visitor
goes to the payment page and comes back with ?hypamoon_order=<id> and a
signed ?hypamoon_ref= (see "After paying" below).
For a product with variants, add data-hypamoon-variant="<variant id>" (or
variant_id in each line of data-hypamoon-items). For a shop with more than
one product, the cart below is usually the better fit.
When the shop cannot take payments right now (the quote says
checkout_available: false, for example because the owner has to connect
payments again), the window shows no pay button but a short line for the
buyer with the shop's contact link, and a warning in the browser console for
you. In test mode (test_mode: true) it shows a "Test mode" label next to the
title and "No real money moves." under the pay button.
After paying: the confirmation
You do not have to build a thank-you page. The payment provider and the test payment
page both send the buyer back to the page of the button (or
data-hypamoon-return), whatever happened. checkout.js (1.5.0 and later)
then shows a window in the same style as the checkout window, with the same
--hm-* variables:
| What happened | The window |
|---|---|
| Paid | "Thank you, your order is confirmed", the order number, what was bought, the total and "A confirmation is on its way to |
| Not paid yet (the payment provider has not told us yet, or the buyer came back without finishing) | "We are waiting for your payment". It checks again by itself (every 2 seconds, then every 5, for 5 minutes), with Continue to payment to the payment page. |
| Failed, canceled or expired | "The payment did not go through", with Try again (opens the checkout with the same products, or the cart). |
| Refunded | "This order was refunded". |
The status line from the snippet (data-hypamoon-checkout-status) gets the
same short sentence. Escape and the close button close the window; focus goes
back to the status line.
How it works: the return URL carries the order id and a signed reference
(hypamoon_ref, an HMAC of the order id with a server key). checkout.js
reads the order with
GET https://api.hypamoon.com/api/v1/public/orders/<order id>?ref=<reference>
and then takes the reference out of the address bar (it keeps it in
sessionStorage for a reload in the same tab). Without the right reference
that route answers 404, like for an order that does not exist; order ids or
numbers alone open nothing. It returns only what the buyer entered or bought:
status, order number, the lines, the total, the buyer's email, test or not,
and the gift cards (value, whether they go to a recipient, never who, and
never the code). The confirmation email links to the same page with the
reference ("View your order"), so a buyer who closed the tab can come back.
The gift card PDF (/api/v1/public/orders/<order id>/gift-cards/<n>?ref=,
&download=1 to download) is made on request and not stored. It works for 30
days after the payment; after that the PDF in the email and the balance page
still have the card. The code itself is never shown in the window: the window
runs on your page, where other scripts (analytics, chat, session replay) can
read what it shows, and a code is money.
Your own texts or your own confirmation:
data-hypamoon-paid-title,data-hypamoon-pending-titleanddata-hypamoon-failed-titleon the button or the status line replace the titles. The value ofdata-hypamoon-checkout-statusreplaces the paid sentence in the status line.data-hypamoon-confirmation="false"on the button, the status line or the script tag: no window. The status line still updates, and every check fireshypamoon:orderonwindowwith{ status, order_id, order_number, test, total_cents, currency, items }(statusispaid,pending,failedorrefunded), so you can show your own.
Orders from before 1.5.0 have no reference in their return URL: then only the status line shows its sentence, as before.
checkout.js and cart.js are served with Cache-Control: max-age=300, stale-while-revalidate=3600: a new version reaches visitors within minutes,
without a version in the script URL.
The look of the checkout window
Hypamoon is the plumbing; the site picks the style. The window lives in a
shadow root, so the site's own CSS (like button {} or input {}) does not
leak into it. It starts calm and neutral (rounded corners, a soft shadow,
light and dark through prefers-color-scheme) and then takes over the site's
look in three layers, the first one wins:
- CSS variables set by the site, on
:rootor on the button itself ([data-hypamoon-checkout]). The window reads them when it opens. - The appearance of the shop from
GET /api/v1/public/appearance(the same as the cart). - The neutral style.
| Variable | What it sets |
|---|---|
--hm-accent |
The pay button and checkboxes |
--hm-accent-text |
Text on the pay button (default: black or white, whichever reads best on --hm-accent) |
--hm-font |
The font of the window |
--hm-font-heading |
The font of the title |
--hm-radius |
Corner radius of fields and buttons (the window itself is a bit rounder) |
--hm-bg |
Background of the window |
--hm-surface |
Fields and the summary box |
--hm-text |
Text |
--hm-muted |
Labels and small print |
--hm-line |
Thin dividers |
--hm-border |
Borders of fields and secondary buttons |
--hm-focus |
The focus ring |
--hm-error |
Error messages |
Without --hm-font the window uses the font of the page (the font-family
of body), and without --hm-font-heading the font of the first h1 or
h2 on the page. The older --accent, --primary and --brand on :root
still work as a fallback for --hm-accent. If you set --hm-bg but not the
text colours, the window picks neutral light or dark colours that go with your
background.
For dark mode there are no separate names: set the same variables again
inside a prefers-color-scheme block.
:root {
--hm-font: "Hanken Grotesk", system-ui, sans-serif;
--hm-font-heading: "Source Serif 4", Georgia, serif;
--hm-accent: #9a4f27;
--hm-accent-text: #ffffff;
--hm-bg: #f6f1e9;
--hm-surface: #ede4d6;
--hm-radius: 4px;
}
@media (prefers-color-scheme: dark) {
:root {
--hm-accent: #de9d6e;
--hm-accent-text: #1c120b;
--hm-bg: #17120e;
--hm-surface: #221a14;
}
}The window is a modal <dialog> with its title as label: Tab and Shift+Tab
stay inside it, Escape and the close button close it, and focus goes back to
the button that opened it. Fields have their label above them and are at
least 44 px high; on a phone the window fills almost the whole screen and
scrolls inside.
The cart (where available)
get_cart_snippet gives a drop-in shopping cart: one script line for every
page, a cart button for the header, and an add-to-cart button per product.
<script src="https://api.hypamoon.com/cart.js" data-token="hm_pub_..." defer></script>
<hypamoon-cart></hypamoon-cart>
<hypamoon-add-to-cart product="<product id>">Add to cart</hypamoon-add-to-cart>
<hypamoon-add-to-cart product="<product id>" variant="xl-black">Add to cart</hypamoon-add-to-cart>- The cart button shows a counter and opens a drawer where the buyer changes
quantities, removes lines and picks a variant. Without
<hypamoon-cart>on the page a floating cart button appears (turn that off withdata-floating="false"on the script tag). - An add-to-cart button for a product with variants and without
variantshows a variant picker next to it; sold-out variants cannot be picked. Addopen="false"to keep the drawer closed after adding. - The cart keeps only product ids, variants and quantities, in the visitor's browser per public token. Every amount, name and stock message comes from the quote below, never from the page.
- Checkout opens the same checkout window as the buy button. Options on the
script tag:
data-shipping="true",data-return="/thanks",data-terms,data-privacyanddata-lang(enornl; otherwise the page's<html lang>). - Back from the payment page, the cart (1.1.0 and later) loads checkout.js, which shows the confirmation (see "After paying" above). The cart empties only when the order is paid; after a failed payment the lines stay, and Try again opens the checkout from the cart.
- It takes the site's colours (light and dark), fonts, corner radius and
language from
GET /api/v1/public/appearance?token=.... Override anything with CSS variables on the element, likehypamoon-cart { --hm-accent: #0a7b55; --hm-radius: 4px }(also--hm-bg,--hm-surface,--hm-text,--hm-muted,--hm-line,--hm-border,--hm-accent-text,--hm-focus,--hm-error,--hm-font,--hm-font-heading), or with::part(button),::part(count),::part(drawer)and::part(checkout). - Keyboard and screen readers: every control is a real button, select or input with a label, the drawer is a modal dialog (Escape closes it and focus goes back to the cart button), and the counter is announced.
Your own cart
The same script gives window.Hypamoon.cart for a cart in your own design:
Hypamoon.cart.add(productId, { variant: "m-blue", quantity: 1, fields: { engraving: "Anna" } });
Hypamoon.cart.remove(0); // by index, or { product_id, variant_id }
Hypamoon.cart.setQuantity(0, 3);
Hypamoon.cart.setVariant(0, "s-blue");
Hypamoon.cart.get(); // [{ product_id, variant_id, quantity, fields }]
Hypamoon.cart.quote(); // a Promise with the quote below
Hypamoon.cart.open(); // the drawer; open(items) replaces the cart first
Hypamoon.cart.checkout(); // straight to the checkout window
window.addEventListener("hypamoon:cart", (e) => console.log(e.detail.type, e.detail.count, e.detail.quote));Without the script, call the quote yourself and start the checkout with the
same items: the amounts are the same either way.
The quote: one place where amounts are calculated
POST /api/v1/public/cart/quote?token=hm_pub_...&lang=en calculates an order
without starting it. The checkout uses the same calculation to create the
payment, so a cart you build yourself always shows the amount the buyer will
pay. Public, with the public token and its allowed origins, like a form.
Request:
{
"items": [{ "product_id": "<id>", "quantity": 2, "fields": { "engraving": "Anna" } }],
"fields": { "delivery_date": "2026-12-24" },
"country": "NL",
"code": "WELCOME10"
}Answer (amounts in the smallest unit, like price_cents):
{
"ok": true,
"currency": "EUR",
"prices_include_vat": true,
"items": [{ "product_id": "<id>", "name": "Mug", "unit_price_cents": 1495, "quantity": 2, "subtotal_cents": 2990, "discount_cents": 0 }],
"subtotal_cents": 2990,
"discount_cents": 0,
"discount_code": null,
"shipping": { "cents": 0, "method": null, "options": [] },
"tax": { "mode": "not_calculated", "included": true, "total_cents": null, "lines": [], "note": null },
"gift_card_cents": 0,
"total_cents": 2990,
"to_pay_cents": 2990,
"total": "29.90",
"total_display": "€29.90",
"seller": { "name": "Example Shop B.V." },
"links": { "terms": "https://example.com/terms", "privacy": "https://example.com/privacy", "returns": null, "contact": null },
"checkout_fields": [{ "key": "delivery_date", "label": "Delivery date", "type": "date", "required": true, "max_length": 500 }],
"problems": []
}problems lists what stops a payment, each with code, path and
message: not_available, sold_out, not_enough_stock, mixed_currency,
currency_not_available, invalid_fields and code_invalid. A quote with
problems is a 200 with ok: false; show the messages to the buyer.
With variants (where available), each line in items may have a
variant_id, and each line in the answer also has variant_id,
variant_name (like "M / Blue"), options (like {"Size": "M"}),
image_url, needs_variant and, for a product with variants, choices: the
product's options and its variants that are on sale, each with id,
label, options, price_cents and available. A line without a variant
for such a product comes back with needs_variant: true and the problem
variant_required; an unknown or switched-off variant gives
variant_not_available. code_field says whether the shop takes a code.
Stock that another buyer is paying for right now counts as gone (see the
checkout).
With VAT set up in the shop (where available, see VAT and invoices), tax
has the calculation: mode (none, domestic, eu_oss or stripe_tax),
included, total_cents, lines per rate with rate, country,
treatment (standard, reverse_charge, export, exempt,
outside_scope, none), base_cents and tax_cents, the country of the
rate, vat_number (the buyer's number with its status and whether VAT was
reverse_charged), notices for the buyer, and a note for the receipt.
Send vat_number in the request for a business buyer, and country as soon
as the buyer's country is known. Without a VAT setting, tax.mode stays
not_calculated and the prices are what you set.
Not here yet: discount codes and shipping rules. Until then
discount_cents and shipping.cents are 0. Include shipping in your prices
for now.
Downloads (where available)
Sell a file (an e-book, a pattern, music, a font, a zip) as a download. The file is private: it is never on a site, and buyers only get links that expire.
create_download_upload_urlswithname,sha256andbytesper file. You get anupload_urlper file:PUTthe exact bytes there within 15 minutes. Types: pdf, zip, epub, mp3, m4a, wav, flac, mp4, png, jpg, webp, gif, svg, txt, csv, json, woff2, ttf and otf. A file can be as large as a video on your plan.- Put the returned
fileobjects on the product:{"name": "Recipes e-book", "price_cents": 1200, "delivery": "download", "files": [...]}. At most 10 files per product; a variant can have its owndeliveryandfiles(a CD and an MP3 of the same album)."kind": "digital"means the same as"delivery": "download".
At checkout the buyer ticks that delivery starts right away and that they lose
their right of withdrawal (EU law for digital content); without the tick the
checkout is refused with an error on waive_withdrawal. An order with only
downloads asks no shipping address. After payment the order confirmation has
one link to the buyer's download page. Each file works a number of days and a
number of downloads (shop setting downloads, default 7 days and 5 times; or
download: {"expires_days": 30, "max_downloads": 3} on the product). Opening
the page counts nothing; only the download button does. When the links have
expired or are used up, the buyer asks for new ones on the same page (they
go to the email of the order) or on their orders page. A full refund closes
the links. A download product without files cannot be bought;
check_business_readiness lists files that are missing.
My orders for buyers (where available)
Every site of the project has /_hypamoon/orders: buyers enter the email of
their order and get a sign-in link (30 minutes, once). They see their paid
orders with the status, tracking, invoices, gift cards they bought with the
balance left, their downloads and a button for new links,
and they can download all their data or have it deleted. Orders they paid for
stay with you for your accounts, with their name and address but without their
email. On a site with an app with sign-in, a signed-in member is a buyer with
the same email, without a second sign-in. Link to it from your site, like "My
orders". The checkout fills in the name, email and last address of a buyer who
is signed in on that site. Turn it off with the shop setting buyer_account.
Product feed (where available)
A public feed of your active products at a fixed address, shown by get_shop
as feed:
https://api.hypamoon.com/api/v1/public/shops/{project_id}/products.xmlfor Google Merchant Center (add it there as a scheduled fetch);.../products.json, the same items as simple JSON.
Each variant is an item with item_group_id set to the product, with colour,
size and material from its options. Price from the server (a sale price when
compare_at_cents is higher), availability from the stock, the image of the
variant or the product, and the link from the product's url or the pattern
in the shop setting feed.product_url (like https://example.com/products/{slug},
with {slug}, {id} or {sku}). A product without a link is left out;
check_business_readiness says which. Products with "feed": false, access
products, gift cards and services are never in it. Updated every five
minutes. Turn it off with feed.enabled.
The checkout
POST /api/v1/public/checkout?token=hm_pub_... is what checkout.js calls.
It takes the same items and fields as the quote, plus customer
(email, optional name; where available also company, vat_number and
country, the country for VAT when there is no address), an optional
shipping_address, an optional billing_address (the address on the
invoice, where available), an optional
return_path, a turnstile_token from the bot check, waive_withdrawal
(true, required when the order has a download) and an
idempotency_key (the same key twice gives one order). It answers with
order_id and checkout_url; send the visitor there. Limits: 20 checkouts
per network per hour, 300 per project per day.
Where the shop settings are available, starting a checkout holds the stock of
its lines for 30 minutes, or until the payment fails, expires or is canceled.
Two buyers can never pay for the last item: the second one gets a 422 with
the detail Not enough stock. before any payment starts.
Orders
list_orders and get_order (REST: GET /api/v1/orders and
GET /api/v1/orders/{id}). Statuses: open, paid, shipped, canceled,
refunded, and charged_back or disputed when the buyer's bank is
involved. Every order is also copied into a private collection orders that
can never be public; the copy is for reading.
Where the shop settings are available, an order also has:
| field | meaning |
|---|---|
number, order_number |
The order number from the project's own series, like QS-1001, next to the id. |
fields |
The answers to the shop's checkout_fields; items[].fields has the answers per line. |
amounts |
The breakdown: subtotal, discount, shipping, tax, tax_included, gift_card, total. |
amount_refunded, refunds |
What has been refunded so far, also partly, with each refund (id, amount, at, via api or dashboard). |
tracking |
number and url, from update_order_status with shipped. |
Mark an order shipped with update_order_status (REST:
PATCH /api/v1/orders/{id} with {"status": "shipped", "tracking_number": "...", "tracking_url": "https://..."}).
Emails to the buyer
- Order confirmation, always, once, when the order is paid. In the language and colours of the site, with the order number, the lines, the total, the buyer's answers, the seller's name, address and contact, the links to terms and privacy and, for physical products, the 14-day right of withdrawal with the model form. In the EU the buyer must get this confirmation by email; it cannot be turned off. With downloads it also has the link to the download page and the text the buyer ticked.
- Shipped, with the tracking link, when you mark an order shipped (shop
setting
emails.shipped). - Refunded, with the amount, after every refund (shop setting
emails.refunded).
The emails come from a Hypamoon address with the shop's name as the sender,
and replies go to the shop's contact_email (by default the project's billing
email).
Refunds
refund_order (REST: POST /api/v1/orders/{id}/refund) refunds a paid or
shipped order from your payment account, after an explicit yes from the
user (confirm: true). Calling it twice refunds once.
- Without
amount_cents: everything that is left. The order becomesrefunded, and the stock that went down for it goes back up, once, unless the order was shipped. The answer hasstock_restorewith what happened. - With
amount_cents(where the shop settings are available): only that part. The order stayspaidorshipped,amount_refundedgoes up, the stock stays. You cannot refund more than is left.
A refund you make in the dashboard of your payment account, in full or in part, is picked up the same way: the order shows it and the buyer gets the refund email.
Shop settings (where available)
get_shop and configure_shop (REST: GET and PATCH /api/v1/shop). One
object; send only what changes, null clears a field. get_shop also shows
the seller as buyers see them, from set_company_details, with what is
missing.
| setting | meaning |
|---|---|
currency |
The default for products without a currency. Default EUR. See Currencies. |
payment_methods |
Optional: which of card, apple_pay, paypal, ideal, bancontact the checkout offers. Left out: everything you turned on that works in the order's currency. |
prices_include_vat |
true by default, as consumers in the EU must see prices. |
shop_name |
The sender name of buyer emails. Default: the site's name. |
contact_email, contact_phone |
Where buyers reach you; the reply-to of every buyer email. |
links |
terms, privacy, returns, contact: https links shown in the checkout and in every confirmation. You publish these pages yourself. |
order_number |
prefix (like QS-) and start (default 1001). |
checkout_fields |
At most 5 questions to the buyer: key, label (text, or {"en": "...", "nl": "..."}), type (text, textarea, select, checkbox, date), required, options for a select, max_length. A field you did not declare is refused with an error that names it. |
withdrawal_right |
true by default. false only if nothing in the shop has the right of withdrawal (made to order, perishable). |
stock |
deactivate_when_sold_out (set active to false at stock 0) and low_threshold (send stock.low). |
emails |
shipped, refunded (both on by default) and a footer line. |
gift_cards |
desk_for_viewers (let viewers redeem at the desk), redeem_url (your own balance page; the QR links there with the code after #) and terms (a short line printed on every card). |
tax |
How VAT works in the shop: mode, country, none_reason, rates, country_rates, rates_confirmed, oss_pricing, reverse_charge, stripe_tax. See VAT and invoices. |
invoices |
enabled (default true), prefix (default INV-), attach (the PDF goes with the order confirmation, default true) and note (a line at the bottom of every invoice). |
downloads |
expires_days (default 7) and max_downloads per file (default 5) for download links. |
buyer_account |
true by default: buyers see their orders at /_hypamoon/orders. |
feed |
enabled (default true), product_url (a link pattern with {slug}, {id} or {sku}) and brand. |
Gift cards (where available)
Sell gift cards on your site, issue them at the desk, and redeem them at the desk or online. Every card comes as a PDF in the look of your site that buyers can print or forward.
A gift card is a product. Add a document to the product collection with
kind: "gift_card" and a gift_card block:
{
"name": "Gift card", "kind": "gift_card", "price_cents": 5000,
"gift_card": {
"mode": "amount",
"amounts": [2500, 5000, 7500],
"open_amount": { "min_cents": 1000, "max_cents": 25000 },
"validity_months": 24,
"redeem": "both",
"vat": "multi"
}
}mode:amount(a value in money) orservicewithservice, like"One haircut"; thenprice_centsis what the card is worth.amountsandopen_amount: the buyer picks a fixed amount or any amount in the range. Without them the card is worthprice_cents.validity_months: at least and by default 24. In the Netherlands a gift card for consumers must be valid for at least two years.redeem:in_store,onlineorboth.vat:multi(default) when the card can be used for things with different VAT rates; VAT is then due when it is used.singlewithvat_ratewhen it is for one thing with a known rate, like a haircut at 9%; VAT is then due when it is sold. Ask your accountant if you are not sure.pdf(optional): the look of the PDF card. Leave it out and the card follows the site it was bought on: its name, accent colour (--hm-accent, the same variable the checkout reads), its own fonts and its icon next to the name. To change that:accent: a colour like"#9a4f27"for the card. When no text is readable on it, the default stays.logo: a path to an image on that same site, like"/logo.png"(png, jpg, webp, gif, or an SVG with only shapes in it), at most 512 kB. It replaces the name on the card."none"prints only the name.font:site(default),sans,seriformono.
The PDF never loads anything from another host: only files from the site itself.
The checkout asks the buyer, per card: amount_cents (when there are amounts
or an open amount), recipient_name, recipient_email, message and
send_on (a date). Send them as fields on the line.
After payment every card gets its own 16-character code (like
K7QM-3XRD-9PTA-WC2H). The buyer gets an email with the card as a PDF; with
recipient_email the recipient gets it too, right away or on send_on. The
code is never stored in readable form: only as a fingerprint and encrypted.
Redeeming online. The checkout has one field for a discount or gift card
code (code on POST /api/v1/public/checkout and the quote). A gift card pays
after VAT: it comes off the total, not off the price. A card cannot buy
another card, and a card for in_store only does not work online. When the
card covers everything, the order is paid at once without a payment page.
When it covers part, the buyer pays the rest. If the rest would be below the
minimum of the payment provider, the card is used for a little less.
At the desk. Open Gift cards in the portal (/gift-cards), type the
code or scan the QR with the phone camera, see the balance and take off part
or all of it. The QR on the card opens a balance page with a download of the
PDF, and a link to the desk for staff. Every change is in the card's history,
with who did it.
From the API or your chat.
| tool | REST | what |
|---|---|---|
issue_gift_card |
POST /api/v1/gift-cards |
A card without a payment: sold at the desk, or as an apology. Needs amount_cents and a reason; with email it is sent as a PDF (or on send_on). |
check_gift_card |
POST /api/v1/gift-cards/check |
Balance, valid until, status and history, by code or id. |
redeem_gift_card |
POST /api/v1/gift-cards/redeem |
Take amount_cents (or "all") off. Send an idempotency_key: the same key never takes money off twice. The balance never goes below zero. |
list_gift_cards |
GET /api/v1/gift-cards |
Newest first, with status, limit and before. |
void_gift_card |
POST /api/v1/gift-cards/{id}/void |
Cancel a card with a reason. Cannot be undone. |
POST /api/v1/gift-cards/{id}/adjust |
Correct the balance (plus or minus) with a reason and an idempotency_key. |
|
GET /api/v1/gift-cards/{id} |
One card with its history. | |
GET /api/v1/gift-cards/{id}/pdf |
The card as a PDF (?download=1 to download). |
Only an owner or builder can issue, redeem, cancel and correct. A viewer can
redeem at the desk when you turn on gift_cards.desk_for_viewers. Editors
never touch gift cards.
Refunds. A full refund of an order puts the gift card part back on the card, and cancels cards that were bought in that order and not used yet.
Expiry. A card past its date expires by itself; its history says so.
VAT and invoices (where available)
You are the seller, and you stay responsible for your VAT. Hypamoon calculates the VAT with the rates you confirm and puts it on an invoice in your name. The rate table we provide is a starting point to check, not tax advice. Check the rates for your products with your accountant or the tax authority before you sell. Hypamoon does not file any VAT return.
Three modes
Set tax.mode with configure_shop:
| mode | for | what happens |
|---|---|---|
none |
A small business scheme (in the Netherlands the KOR), or a seller outside VAT. | No VAT on anything. The confirmation and the receipt say why (none_reason: small_business, not_registered or exempt). |
domestic |
Most small shops: one country. | The rates of your own country (tax.country, by default the country of your address) for every buyer. Below the EU threshold for distance sales (10,000 euro a year to consumers in other EU countries together) this is also right for buyers elsewhere in the EU. |
eu_oss |
Above that threshold, registered for the One Stop Shop. | Goods and digital products to a consumer in another EU country carry the VAT of that country. Services (a haircut, a lesson) stay at your own rate: they happen where you are. With prices including VAT, oss_pricing same_gross (default) keeps the price the same everywhere; same_net keeps the amount without VAT the same, so the price follows the rate. |
Without a mode, nothing changes: VAT is not calculated and prices are what you set.
Prices including or excluding VAT. prices_include_vat (default true):
the VAT is taken out of the price. With false (a shop for businesses), the
VAT comes on top.
Rates per product. Every product (or variant) has tax_class:
standard, reduced, zero or exempt. The rate comes from the table for
the country, or from your own tax.rates ({"standard": 21, "reduced": 9})
and, with eu_oss, tax.country_rates ({"FR": {"reduced": 5.5}}). Where a
country has more than one reduced rate, the table uses the highest; set your
own when your product has another. get_shop with include_vat_rates
returns the table with its date and source. When you have checked your rates,
configure_shop with {"tax": {"rates_confirmed": true}}; when the table
changes, check_business_readiness asks you to confirm again.
The order of the calculation. A discount comes off the price before VAT
(it lowers the taxable amount); shipping is added and follows the rates of the
lines it ships; then VAT; a gift card pays from the total after VAT, as a means
of payment. A gift card for several purposes (gift_card.vat multi) has no
VAT when it is sold, only when it is used; one for a single purpose carries
its rate when it is sold.
Exports. Goods and digital products to a buyer outside the EU have no EU
VAT (treatment: "export"); with prices including VAT, the VAT comes off.
Business buyers in the EU
The buy button and the cart do this for you: with VAT set up, the checkout
shows the VAT amount and a box "I am buying for a business" with company name
and VAT number (and, with eu_oss and no shipping address, the buyer's
country). In your own cart, the checkout takes customer.vat_number (and the
quote vat_number). We
check it in the EU register VIES. Valid, from another EU country than yours,
tax.reverse_charge on (the default) and your own VAT number known: no VAT,
the price without VAT, and the invoice says "VAT reverse charged". The VIES
consultation number is kept with the order as your proof. Not valid, or VIES
not reachable right now: VAT is charged as for anyone else, the payment goes
on, and the quote has a notice for the buyer in tax.notices.
When your payment provider calculates the VAT
Outside Europe, payments run on a second payment provider (see How the money
moves). That provider can calculate the VAT itself on your own account. With
tax.stripe_tax: true on such a project, it calculates the VAT in its
checkout instead of our table, asks for the billing address, collects business
VAT numbers, and creates the invoice on your own account there, so Hypamoon
does not make one. You set up your tax registrations with that provider, and
it charges for this. Prices must include VAT, so the amount the buyer pays
never changes after the order is made.
The payment provider for Europe does not calculate VAT. There, our own calculation and rate table always apply, and Hypamoon makes the invoice. The amount sent to the provider is the total with VAT.
The invoice
Once tax.mode is set and your details are complete (set_company_details:
name, address, company number, and your VAT number unless the mode is
none), every paid order gets an invoice in your name:
- its own number from your series, like
INV-0001, running on without gaps (a test order getsTEST-INV-0001from a separate series); - the date, your name, address, company number and VAT number, the buyer's name and address (and company and VAT number for a business), each line with quantity, unit price without VAT, rate and amount, the taxable amount and VAT per rate, the total, what a gift card paid, and the notes the law asks for (reverse charge, OSS, no VAT);
- in the colours, letter and logo of your site;
- attached to the order confirmation as a PDF (setting
invoices.attach), and always as a link the buyer can open later.
An invoice never changes. A refund, in full or in part, adds a credit note with the next number, the refunded amount spread over the rates of the invoice, and a reference to the invoice. It goes with the refund email.
Hypamoon is not on the invoice: you are the seller.
| tool | REST | what it does |
|---|---|---|
get_invoice |
GET /api/v1/invoices?order_id=..., GET /api/v1/invoices/{id}, GET /api/v1/invoices/{id}/pdf |
The invoice of an order and its credit notes, with pdf_url and buyer_url. |
create_invoice |
POST /api/v1/invoices with {"order_id": "..."} |
Makes the invoice of a paid order that has none, for example one paid before your details were complete. |
export_orders |
GET /api/v1/shop/export?format=...&from=...&to=... |
CSV for your bookkeeping. |
The invoices are also under Payments in the portal, next to each order.
Export for your bookkeeping
export_orders gives a CSV over a period (from included, to not; by
default the current quarter):
invoices: one row per invoice and credit note;vat: sales per VAT rate, country and treatment, credit notes subtracted;oss: only the sales with the VAT of another EU country, per country and rate, for your quarterly OSS return.
Amounts use a dot (12.50). A link with an accounting package comes later
through webhooks; until then, the export is the way.
Webhooks
Subscribe with create_webhook (REST: POST /api/v1/webhooks), signed like
every other webhook.
| event | when | data |
|---|---|---|
order.paid |
A buyer paid. | order |
order.shipped |
You marked the order shipped. Where available. | order |
order.refunded |
Money went back, in full or in part, through the API or the dashboard. One event per refund. Where available. | order |
order.canceled |
An open order was canceled or its payment expired. Where available. | order |
stock.low |
A paid order brought a product to the shop's low_threshold or below. Where available. |
product |
gift_card.issued |
A card was issued, after a payment or with issue_gift_card. Where available. |
gift_card (no code, no email address) |
gift_card.redeemed |
Money was taken off a card, at the desk, online or through the API. | gift_card, amount_cents, channel, order_id |
gift_card.expired |
A card passed its date with a balance left. | gift_card, expired_cents |
Collections with a role
create_collection with role makes a collection the shop uses:
products (the product collection) or orders (the private copy of the
orders, made by the first checkout if you do not make it yourself). discounts
comes later. Gift cards are not a collection: use the gift card tools above.
REST at a glance
| method | path | scope |
|---|---|---|
| GET | /api/v1/payments |
project:read |
| POST | /api/v1/payments/product-collection |
data:write |
| GET | /api/v1/shop |
data:read |
| PATCH | /api/v1/shop |
project:write |
| GET | /api/v1/orders |
data:read |
| GET | /api/v1/orders/{id} |
data:read |
| PATCH | /api/v1/orders/{id} |
data:write |
| POST | /api/v1/orders/{id}/refund |
project:write |
| GET | /api/v1/public/shops/{project_id}/products.xml and .json |
none (public) |
| POST | /api/v1/public/cart/quote |
public token |
| POST | /api/v1/public/checkout |
public token |
| GET | /api/v1/public/appearance |
public token |
| GET | /api/v1/gift-cards |
data:read |
| POST | /api/v1/gift-cards |
data:write |
| POST | /api/v1/gift-cards/check |
data:read |
| POST | /api/v1/gift-cards/redeem |
data:write |
| GET | /api/v1/gift-cards/{id} |
data:read |
| GET | /api/v1/gift-cards/{id}/pdf |
data:read |
| POST | /api/v1/gift-cards/{id}/void |
data:write |
| POST | /api/v1/gift-cards/{id}/adjust |
data:write |
| GET | /api/v1/invoices?order_id=... |
data:read |
| POST | /api/v1/invoices |
data:write |
| GET | /api/v1/invoices/{id} |
data:read |
| GET | /api/v1/invoices/{id}/pdf |
data:read |
| GET | /api/v1/shop/export |
data:read |
| GET | /api/v1/public/invoices/{id}?s=... |
the signed link from the buyer's email |
These routes are not in the OpenAPI document yet; this page is their reference.