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.
API reference
Every endpoint of the Hypamoon API with its scope, parameters, responses and errors.
Hypamoon is the infrastructure a chat or an agent builds on for a business: sites and apps (with server code, sign-in and their own database), data and forms, a media library, domains, email from your own domain, phone numbers with an AI receptionist, SMS and chat, payments (see https://hypamoon.com/docs/payments) and agents. The same project is reachable from a connected chat over MCP and from your own code over this REST API.
Calls are inbound, plus outbound calls your assistant makes to people who expect them (POST /calls).
Every request needs an API key: Authorization: Bearer hm_live_..., or hm_test_... in test mode. Each key has scopes and a rate limit of 120 requests per 60 seconds.
An OAuth access token (hm_oat_..., from connecting an assistant such as Claude.ai or ChatGPT) works on every /api/v1 route as well, with the scopes the user granted on the consent screen. For your own app, create an API key in the portal instead.
Every error is application/problem+json with a type URL that explains the fix (https://hypamoon.com/docs/errors/<slug>). Every response carries an X-Request-Id header; include it when you ask us for help.
Field names are snake_case, except inside a call plan, which is stored exactly as you send it (camelCase). New fields may be added to a response at any time; renaming or removing one requires a new version.
Your own domain for a site: connect a domain the user owns (nameservers, with existing records copied), or register a new one. DNS records are managed here.
Connects a domain. method nameservers (default): we create a DNS zone for it, copy its existing records (mail included), and return the two nameservers the user sets at their registrar. method cname: www (for a domain) or one subdomain (like shop.example.com), through a CNAME and one TXT record (proof of ownership) at the user's own DNS provider; the certificate is issued and renewed automatically. Connecting through the nameservers is free on every plan; the cname method carries a surcharge of EUR 2 a month excl. VAT (cname_surcharge), except for a subdomain of a domain the project already has on Hypamoon's nameservers, which is free. Registering a new domain costs its registration price. Connecting a domain that is already in this project only changes its site. A subdomain that still has an A, AAAA or CNAME record pointing elsewhere in the zone here returns confirmation_required with preflight, diagnosis (what it points to and what answers there) and fix (the same call with replace_existing true); undo_dns_change puts the old record back. Every error of a domain route carries diagnosis and fix.
Optional. A site of this project (from deploy_site or list_sites) to serve on the domain and on www.
method
"nameservers", "cname"
nameservers (default): the user points the domain's nameservers to the two we return; existing records, mail included, are copied first. cname: fallback when the nameservers cannot change: only www, through a CNAME and a TXT record at the user's DNS provider.
dkim_selectors
string[]
Optional. The DKIM selector(s) of the user's mail provider (like google or selector1), so their DKIM records are copied too.
replace_existing
boolean
Optional, default false. For a subdomain of a domain this project has on Hypamoon's nameservers: if the subdomain still has an A, AAAA or CNAME record pointing elsewhere (like an old host), the call is refused with what is live there now. true replaces those records (only after the user agreed); undo_dns_change puts them back for 30 days.
The domains this project watches (watch_domain), each with status ok, warning or problem, the reason in one sentence, the findings (dangling DNS that points to a host where the domain is not set up, spam on the homepage, a changed title or host, expiry in 30 or 7 days, the certificate, and mail advice), whether ownership is proven, and whether the domain is parked. Checked once a day; the owner gets one mail per domain per day at most, for a new problem. Watching is included in the plan, up to limit domains. dns_cleanup lists, for every domain of the project on Hypamoon's nameservers (watched or not), how many records the previous host left behind: safe_to_remove and ask; GET /domains/{id}/dns-cleanup has the details.
Watches a domain the user owns, registered anywhere, without connecting it to a site. Ownership is proven with the same TXT record as connect_domain (ownership.challenge); a domain that is already active in this project or bought through Hypamoon needs none. Until it is proven the status is pending (202) and nothing is checked. park page or redirect also moves the domain to Hypamoon's nameservers (like connect_domain without a site) and then shows a neutral page or sends visitors to redirect_to with a 301; a domain without mail gets a null MX, SPF -all and DMARC p=reject against spoofing. park off stops parking. remove true stops watching.
Optional. Where the domain is hosted on purpose (vercel, netlify, github, heroku, aws, azure, shopify, fly, render). If its DNS points to another one of these, that is a problem. Leave it out and we remember the host from the first check, and warn when it changes.
expected_language
string or null
Optional. The language of the site, as a two-letter code like en or nl. A homepage in another language is a warning.
park
"off", "page", "redirect"
Optional. page: park the domain on Hypamoon with a neutral page ("This domain is reserved"). redirect: park it and send visitors to redirect_to with a 301. off: stop parking. Parking moves the domain's DNS to Hypamoon (nameservers), so nobody can claim it elsewhere.
redirect_to
string
Only with park redirect: the address to send visitors to, like https://example.com. Path and query of the request are kept.
remove
boolean
true: stop watching the domain. Parking stays as it is.
For a domain on Hypamoon's nameservers: which records were left behind by the previous host, such as the names a hosting control panel (cPanel, Plesk, DirectAdmin) creates by itself, old certificate tokens and names that still point to the previous host. Each record has a label (safe_to_remove, ask or keep), a group and the reason. Microsoft 365, Google Workspace, everything Hypamoon manages, SPF, DMARC and the domain's MX are always keep. spf_advice suggests how to tighten the SPF; it is never changed by a cleanup. Names that point to the previous host are checked over https (live_checked). recent_cleanups lists the cleanups of the last 30 days. Read-only.
Removes the records labeled safe_to_remove, minus exclude_record_ids, plus the ask-records in include_record_ids, in one step. The suggestion is made again at that moment; a record labeled keep is never removed (422). Before anything is removed, the records are written to the audit log as a zone file; the answer has the same zone_file and a cleanup_id. For 30 days the cleanup can be undone with POST /domains/{id}/dns-cleanup/restore.
Within 30 days of a cleanup: adds the removed records again, only those that are missing now. A name that now serves a Hypamoon site is skipped. Records that were put back are not suggested for a cleanup again unless the user chooses them.
Whether the domain can be registered, the price of the first year and of a renewal, and the URL of the registry's terms. Results are cached for a minute; checks are rate limited per project.
Available domains based on a few words, or on names you came up with yourself. With query, Hypamoon makes the candidates: the words themselves on .nl and .com first, then short variants like getbakery, trybakery and bakeryhq. With names, up to ten names of your own are checked in one call; a name without an extension is tried on .nl and .com first. At most ten names are checked per call, and each one counts as a check in the rate limit. unavailable lists the names that are taken; rejected lists the names that are not valid.
Scope: sites:read
Parameter
In
Type
query
query
string
A few words, like 'corner bakery'. Required without names.
tlds
query
string
Optional. Comma-separated extensions, like nl,com.
names
query
string
Optional. Up to ten comma-separated names to check, with or without extension, like getcrumb,crumbhq.com.
Registers the domain in the user's name, with DNS and DNSSEC set up here, and whois privacy where the extension allows it. accept_registry_terms must be true: the user explicitly accepted the registry's terms (registry_terms_url from the check). max_price_eur is the total the user confirmed for all years; a higher price is refused. A name with a higher registry price (premium in the check) is bought for 1 year only, with accept_price_eur exactly the price the user confirmed, and only up to a ceiling; if the registry price changed just before the purchase, nothing is bought. Needs an Idempotency-Key; the same key returns the same domain, and a key whose attempt failed is never retried (use a new key). In live mode the price is deducted from the balance; in test mode nothing is bought. Real purchases in live mode are not open yet: until they are, a live key gets service_unavailable, and test mode works as described.
Registration period in years. Most extensions only allow 1.
accept_registry_terms (required)
boolean
Must be true: the user (the registrant) explicitly accepted the terms of the registry at registry_terms_url from check_domain.
max_price_eur (required)
number
The highest total the user agreed to pay for all years together, as shown by check_domain. Refused if the price is higher.
accept_price_eur
number
Only for a name with a higher registry price (premium true in check_domain): the price_eur for the first year exactly as check_domain showed it and the user confirmed. Without it, or if the price changed, nothing is bought.
site_id
string or null (uuid)
Optional. A site of this project to serve on the domain once it is active.
One domain as it is stored: status, nameservers, mail_check, site and next_step. Read-only; POST /domains/{id}/activate checks activation. For a purchased domain, it also includes the registration details and the registrant verification status.
Changes a setting of the domain. hsts tells browsers to always open the domain over https: off (default), on (for six months) or on_with_subdomains (the same, for every subdomain too). Once on, browsers that visited the site remember it for about six months and will only open the domain over https, even if you turn it off again. Only choose on_with_subdomains if every subdomain of the domain (like mail or shop) works over https too. It can only be turned on when a site is live on the domain and every host of it has a valid certificate; otherwise the request returns conflict. You can always turn it off. auto_renew is for a domain bought or transferred through Hypamoon: true (default) renews it for a year before it expires, from the project's balance; false lets it expire. For a connected domain it returns conflict. primary chooses the main address: apex (default, example.com) or www (www.example.com). The other address redirects to it permanently (301) with path and query, and canonical links and the sitemap follow it. Only for method nameservers; while the site is live, the new main address needs a valid certificate first, otherwise the request returns conflict.
Tells browsers to always open the domain over https (HSTS). off (default): no such instruction. on: for six months. on_with_subdomains: the same, for every subdomain too. Once on, browsers that visited the site remember it for about six months and will only open the domain over https, even if you turn it off again. Only choose on_with_subdomains if every subdomain of the domain (like mail or shop) works over https too. Only possible when the site is live on the domain with a valid certificate.
auto_renew
boolean
Only for a domain bought or transferred through Hypamoon. true (default): Hypamoon renews it for a year before it expires, from the project's balance. false: it is not renewed and expires on its expiry date.
primary
"apex", "www"
Which address of the domain is the main one. apex (default): example.com, and www.example.com redirects to it. www: www.example.com, and example.com redirects to it. The redirect is permanent (301) and keeps the path and query; canonical links and the sitemap follow the main address. Only for method nameservers.
Takes the site off the domain and deletes its DNS zone (or the www connection). A domain purchased through Hypamoon cannot be disconnected; remove its site instead.
Checks whether the nameservers (or the CNAME records) are in place, scans the previous DNS again while it waits, and attaches the site once the domain is active.
The transfer (auth) code of a domain bought through Hypamoon, to move it to another registrar. Only the account owner gets it, in the Hypamoon portal (Domains, Show transfer code) after confirming their second factor. A chat (OAuth) and an API key get forbidden; the error names the page in the portal. Each request is logged and the owner receives an email. The domain is unlocked for a transfer for 14 days, then locked again.
Without record_id a new record (a new A, AAAA or CNAME replaces the A, AAAA or CNAME on that name); with record_id that record is replaced; with delete true it is deleted. The records that serve the site cannot be changed here. On an active domain, a change that replaces or deletes a live record returns 409 confirmation_required with preflight (what changes, what is live there now, mail_affected) and nothing changes; call again with confirm_overwrite true once the user agreed. Every change keeps the previous state for POST /domains/{id}/dns-records/undo.
The name: @ for the domain itself, or a subdomain like www, mail or _dmarc.
content
string
The value: an IP address, a hostname, or the text of a TXT record. For SRV: 'priority weight port target'. For CAA: 'flags tag "value"'.
ttl
integer
Seconds; 1 means automatic.
priority
integer
For MX: the priority (lower is preferred).
proxied
boolean
For A, AAAA and CNAME: through the CDN (true) or DNS only (false, default).
delete
boolean
true deletes the record with record_id.
confirm_overwrite
boolean
Optional, default false. A change that replaces or deletes a record that is live now (on an active domain) is refused first with a preflight: what changes, what is live there now, and whether mail is affected. Show it to the user; only after they agree, call again with true. undo_dns_change reverses it for 30 days.
Reverses the last DNS change made through Hypamoon on the domain, within 30 days: a record set, replaced or deleted, an old record replaced when a subdomain was connected, a cleanup, a zone file import or a restore. Calling it again goes one change further back. Without confirm true nothing changes: the answer is 409 confirmation_required with preflight (the change and what undoing it does). A record that changed again since is not undone blindly (conflict).
Optional, default false. Without it nothing changes: the answer (confirmation_required) shows which change would be undone and what that does. Show it to the user; after they agree, call again with true.
What is set up on a domain or subdomain right now: the nameservers (at Hypamoon or not), the DNS records here with labels (left by a previous host or not), what answers on the domain, www, the subdomain and the main names (status, server, certificate), email (MX, SPF, DKIM, DMARC, Microsoft 365 or Google Workspace), conflicts and a connection or transfer in progress. Each item in problems has diagnosis, fix (a tool with arguments, or a portal link) and user_message. Read-only, also for a domain that is not connected here. Lookups have short timeouts and never reach private addresses.
Scope: sites:read
Parameter
In
Type
domain
query
string
The domain or subdomain, like example.com or portal.example.com.
domain_id
query
string (uuid)
Instead of domain: the id of a domain of this project.
Imports the records of a BIND zone file (exported from the current DNS provider) into the domain's DNS zone here. NS and SOA of the domain itself and unsupported types are skipped and listed. Also accepts the file itself as text/plain (with ?replace_existing=true). Keeps two restore points: the file (the previous DNS) and the zone as it was just before.
Queries every known record at both the current nameservers and ours (directly, not through a cache) and lists the differences. ready is true only when mail (MX, SPF, DKIM, DMARC), the domain itself and www match. Read-only.
Sets the domain's DNS zone back to a restore point (by default the newest one of the previous DNS), and returns it as a zone file plus the previous nameservers, so the move can be undone. The zone as it was is kept as a new restore point.
Optional. The id of a restore point from dns_snapshots in get_domain. Without it: the newest restore point of the previous DNS (a zone file, or the scan when the domain was connected).
For a domain registered or transferred through Hypamoon: point its nameservers to the DNS zone here (refused with confirmation_required and the preflight while it is not ready, unless ignore_preflight), or back to the previous ones.
hypamoon: the nameservers of the DNS zone here. previous: back to the nameservers the domain had before (to undo the move).
ignore_preflight
boolean
Optional, default false. With use hypamoon, the switch is refused while preflight_domain is not ready; true switches anyway (only when the user accepts the listed differences).
Moves the registration of a domain the user owns to Hypamoon, with the transfer code of the current registrar. accept_registry_terms must be true and max_price_eur is the amount the user confirmed. dns hypamoon switches the nameservers to the zone here when the transfer completes, so it is refused with confirmation_required (nothing charged) while the preflight is not ready, unless ignore_preflight; keep leaves them as they are. Needs an Idempotency-Key; a key whose attempt failed is never retried. 201 when the transfer completes immediately, 202 while it is in progress (follow it with GET /domains/{id}). In live mode the price is deducted from the balance and refunded if the transfer fails. Transfers in live mode are not open yet: until they are, a live key gets service_unavailable, and test mode works as described.
The transfer code (auth code, EPP code; for .nl the token) the user got from their current registrar. Required for most extensions. Never stored.
accept_registry_terms (required)
boolean
Must be true: the user (the registrant) explicitly accepted the terms of the registry at registry_terms_url from check_domain.
max_price_eur (required)
number
The highest amount the user agreed to pay for the transfer. Refused if the price is higher.
dns
"hypamoon", "keep"
hypamoon (default): the nameservers switch to the DNS zone here when the transfer completes; run preflight_domain first. keep: the nameservers stay as they are; switch later with switch_domain_nameservers.
site_id
string or null (uuid)
Optional. A site of this project to serve on the domain once it is active.
dkim_selectors
string[]
Optional. The DKIM selector(s) of the user's mail provider, so their DKIM records are copied too.
ignore_preflight
boolean
Optional, default false. With dns hypamoon the nameservers switch when the transfer completes, so the transfer is refused (confirmation_required, nothing charged) while preflight_domain is not ready. true transfers anyway, only when the user accepts the listed differences.
The project behind the API key: one app, with its own numbers, one assistant, its calls and its webhooks. verification lists the four verification levels with what each still needs and what it unlocks. access says whether this key or connection is limited to some sites and apps (an owner or admin sets that in the portal); a limited key gets only id, name, edition, created_at, mode and access.
The name of the project. The assistant uses it in its greeting.
edition (required)
string
Always api for a project of the Hypamoon API.
created_at (required)
string (date-time)
When the project was created.
spending_limit_eur
number or null
The most the project can spend per month, in euros. Null in test mode: a test project costs nothing. Left out for a key or connection limited to some sites and apps.
numbers_count
integer
How many numbers the project has. Left out for a limited key or connection.
mode (required)
"live", "test"
live or test: the mode of the key, which is always the mode of its project.
live_project_id
string or null (uuid)
Only in test mode: the live project this test project belongs to, or the project itself when it has no separate test project.
rails
object
Per function with real-world effects, for this request: live (in use) or test (simulated: not used yet, or a test key). A function on test becomes live the first time it is asked for, once the project is verified for it; until then the request answers verification_required with a link. A test key is test everywhere. Left out for a limited key or connection. Sites, data and apps are always real and have no mode.
verification
object
The verification (KYC) of the project. In test mode: the verification of the live project.
access (required)
object or null
Null: this key or connection can use the whole project. Otherwise only these sites, apps and collections: lists show only those, and anything for the whole project (billing, team, domains, numbers, a new site or app) is refused with 403 and a user_message.
A phone number is registered to a business or a person. For a business, set the company name, the address with its country, a contact person and the number in its country's business register (kvk_number in the Netherlands, registration_number elsewhere) before you order a number. Without a company, send account_type personal with the user's address and the user as contact: no registration number needed. A national number needs a business; a personal account can order a geographic number. In test mode no registration or VAT number is needed to order a test number. Switching to account_type personal is refused while the project has a national number (account_type_change_blocked); every switch resets the number_documents and full verification levels to not_started. In the EU send vat_number: we check it with VIES before saving (vat_number_invalid when VIES says it is not valid; vat_status unverified when VIES does not respond, and we retry). A country we cannot serve returns country_not_supported. Which numbers you can order is what GET /numbers/available returns. PUT replaces all fields, except registrar_contact_name, which keeps its saved value when left out. When the project has domains registered or transferred through Hypamoon, the contact, email, phone and address also go to each domain's contact at the registry; domain_contacts in the response says per domain updated, unchanged, skipped or failed, with the reason. A change of organization, registration number or (personal account) person is a change of holder: never sent automatically, it needs Hypamoon support.
Who the project belongs to, and who its phone numbers are registered to: a business or a personal account (account_type). For a business: company_name, the address, the number in its country's business register (kvk_number in the Netherlands, registration_number elsewhere) and, in the EU, vat_number (checked with VIES). For a personal account: the user's address and the contact (the user), without company fields.
Field
Type
account_type
"personal", "business"
personal (someone without a company: no company_name, kvk_number, registration_number or vat_number) or business. Leave it out to keep the project's current type. A national number needs business. When a personal account is not available for the address, validation_failed says so.
company_name
string or null
The legal name. Required for a business, not allowed for a personal account.
kvk_number
string or null
For a company in the Netherlands (required there): the KvK number, 8 digits. Spaces and dots are removed.
registration_number
string or null
For a company outside the Netherlands: the number in its country's business register (like a UK Companies House number or a German HRB number). Needed for the company verification level outside the EU.
vat_number
string or null
The VAT number. In the EU outside the Netherlands it is needed for the company verification level and checked with VIES: send it with or without the country prefix. A number that VIES rejects returns vat_number_invalid.
address (required)
object
contact (required)
object
A person the regulator can reach today. For a personal account: the user.
registrar_contact_name
string or null
Optional, for a business: the name on the contacts of the domains registered or transferred through Hypamoon, when it should differ from contact.name. Use a role, like Administration or Webmaster, so the domain registration carries no personal name while contact.name stays the person that number registration needs. Leave it out to keep the saved value; send null to use contact.name again. Not allowed for a personal account.
For a company in the EU: the VIES check of vat_number (unverified when VIES did not answer; we retry automatically). missing: no VAT number yet. not_required: no VIES check needed for this country.
vat_checked_at (required)
string or null
When VIES last checked vat_number, ISO 8601.
address (required)
object
contact (required)
object
registrar_contact_name
string or null
Only for a business: the name on the domain contacts at the registry when it differs from contact.name, or null (then contact.name is used).
complete (required)
boolean
The details are complete enough for the company or personal verification level: a business needs its business register number and, in the EU, a valid VAT number; a personal account needs a complete address.
domain_contacts
object[]
Only when the project has domains registered or transferred through Hypamoon: per domain, whether the new details reached the domain's contact at the registry. A change of organization, registration number or (for a personal account) person is never sent: that is a change of holder.
Advice for building a webshop, appointments and bookings, a restaurant site, or a course or membership: the questions that matter (each with why and a default when the user does not know; only the ones marked blocking stop a working result), the build plan with the Hypamoon tools in order, the go-live checklist for the country (EU in detail, UK and US basics, a general fallback), the common mistakes, and a structured-data (JSON-LD) snippet for search engines. Advice only: nothing here is required of the user. The checklist is guidance, not legal or tax advice.
The kind of application. Required, unless you ask for a topic. app: a build plan for an app with accounts, data per user and uploads, from description.
description
query
string
Only with type app: what the app should do, in the user's words (one or two sentences).
Instead of type: one known problem (an upload blocked by a sandbox, an image too large, version_conflict, phone verification, a site nobody else can see, is Hypamoon down) with what to do and what to tell the user. Every blueprint also lists them in known_problems.
country
query
string
Where the business is: an ISO country code like NL, or a name. Leave it out for the Netherlands.
audience
query
"consumers", "businesses", "both"
Who the user sells to; consumers (the default) adds the consumer rules.
eu (detailed), uk and us (basics), other (a general fallback).
audience (required)
"consumers", "businesses", "both"
how_to_use (required)
string
For the agent: inform yourself, ask only the blocking questions, use the defaults for the rest, and offer the checklist; never a questionnaire or a gate.
ask_first (required)
object[]
build_plan (required)
object[]
go_live_checklist (required)
object[]
pitfalls (required)
object[]
structured_data (required)
object
Structured data for search engines, ready to fill in and add with deploy_site.
note (required)
string
Guidance, not legal or tax advice.
other_types (required)
object
known_problems (required)
object[]
Known problems (an upload blocked by a sandbox, an image too large, version_conflict, phone verification, a site nobody else can see, is Hypamoon down), each with what to do and what to tell the user. GET /advice?topic= returns one.
summary_markdown (required)
string
JSON
{
"type": "webshop",
"title": "Webshop",
"summary": "A shop on the user's own site: products, a real checkout (or an honest order request while payments are not on), order emails and the pages a shop needs to go live.",
"country": "NL",
"region": "eu",
"audience": "consumers",
"how_to_use": "This is for you, the agent, to inform yourself; it is not a questionnaire for the user. Ask only the questions marked blocking (at most two or three), in plain words, and only if the user has not answered them already. For everything else use the default and mention briefly which defaults you used. If the user says \"just build it\" or skips a question, use the defaults and build. Follow build_plan with the real tools. Offer the go-live checklist afterwards as an option (\"Want me to go through what you might want to arrange before going live?\"), never as a condition for building or deploying. Name collections and their fields, and an app's tables and columns, in English and snake_case, descriptive (job_applications, contact_messages, full_name); the content and the texts on the site stay in the site's language.",
"ask_first": [
{
"id": "country",
"question": "Which country is your business in, and where do you sell to?",
"why": "It decides the rules (returns, VAT, what the site must show), the currency and the language.",
"default": "The Netherlands, selling within the Netherlands, in euro and Dutch.",
"blocking": true
},
{
"id": "products",
"question": "What do you sell, and roughly how many products? Do they come in variants like size or color?",
"why": "It decides how the product list is built and whether every variant needs its own price and stock.",
"default": "A small catalogue (up to 20 products) without variants; each variant would be its own product.",
"blocking": true
},
{
"id": "seller",
"question": "Do you sell as a business or as a private person, and to consumers or to other businesses?",
"why": "Consumer rules (14 days to withdraw, prices with VAT) apply when a business sells to consumers.",
"default": "A business selling to consumers.",
"blocking": false
},
{
"id": "shipping",
"question": "How do you ship, and what does shipping cost?",
"why": "Shipping costs must be clear before the customer orders.",
"default": "One flat shipping rate within the country, shown on every product page and before checkout; pickup on request.",
"blocking": false
},
{
"id": "returns",
"question": "How do you handle returns?",
"why": "Consumers in the EU and UK can usually withdraw within 14 days; the site has to explain how.",
"default": "The legal minimum: 14 days to withdraw, the customer pays return shipping, refund within 14 days.",
"blocking": false
},
{
"id": "stock",
"question": "Do you want the shop to track stock?",
"why": "With stock tracking a product goes off sale when it runs out.",
"default": "No stock tracking (unlimited); the owner takes a product off sale by hand.",
"blocking": false
},
{
"id": "payment_methods",
"question": "Which payment methods do you want to offer?",
"why": "Buyers expect their usual method (iDEAL in the Netherlands, cards elsewhere).",
"default": "What the user's own payment account offers (in the Netherlands usually iDEAL and cards).",
"blocking": false
},
{
"id": "order_emails",
"question": "Who should receive the order emails?",
"why": "Someone has to see every order and ship it.",
"default": "The project's billing email address.",
"blocking": false
},
{
"id": "language_currency",
"question": "Which language and currency should the shop use?",
"why": "Prices and texts must match where the customers are.",
"default": "The language and currency of the user's country.",
"blocking": false
}
],
"build_plan": [
{
"id": "pages",
"title": "Pages first",
"how": "deploy_site with a home page, a product list, a product page per product (or one page with all products), /contact with the company details, /terms, /privacy, /returns with the withdrawal form, and /404.html. Use the user's real texts and prices; leave out what they do not have yet.",
"tools": [
"deploy_site"
]
},
{
"id": "products",
"title": "Products as data",
"how": "With payments available: create_product_collection, then insert_document per product (name, price_cents including VAT, optional stock). Without payments: create_collection \"products\" with access {\"public_read\": true} and insert_document per product, so the site and the assistant read the same list.",
"tools": [
"create_product_collection",
"create_collection",
"insert_document"
]
},
{
"id": "checkout",
"title": "A real checkout, or an honest order request",
"how": "Call get_payments_status: when ready is true, get_checkout_snippet per product and put the buy button on the product page. When the payment account is not connected yet, connect_payments gives the owner the link. When payments are not available (no such tool, or the user does not want them yet): build an order request instead. create_collection \"order_requests\" with access {\"public_write\": true} and triggers {\"email_on_insert\": true}, get_form_snippet, and a button that says \"Send order request\". The page says plainly that the shop confirms availability and sends payment details, and that nothing is paid yet. Never a \"Pay now\" button that does not take a payment.",
"tools": [
"get_payments_status",
"connect_payments",
"get_checkout_snippet",
"create_collection",
"get_form_snippet"
]
},
{
"id": "orders",
"title": "Orders and emails",
"how": "Paid orders: list_orders and get_order, update_order_status to shipped. Order requests: query_documents on order_requests. With the Mail module on, send the buyer a confirmation with send_email (transactional) from the user's own domain (add_email_domain, verify_email_domain); otherwise tell the user to send it themselves.",
"tools": [
"list_orders",
"get_order",
"update_order_status",
"query_documents",
"send_email",
"add_email_domain",
"verify_email_domain"
]
},
{
"id": "contact_form",
"title": "Contact form that really arrives",
"how": "create_collection \"contact\" with access {\"public_write\": true} and triggers {\"email_on_insert\": true}, then get_form_snippet on /contact.",
"tools": [
"create_collection",
"get_form_snippet"
]
},
{
"id": "accounts",
"title": "Customer accounts (optional)",
"how": "Only if the user wants customers to log in and see their orders: deploy_app with auth, and set_table_owner_column so each customer sees only their own rows. A shop works fine without accounts.",
"tools": [
"deploy_app",
"set_table_owner_column"
],
"optional": true
},
{
"id": "chat",
"title": "Chat that knows the shop (optional)",
"how": "create_collection \"faq\" with access {\"assistant_read\": true}, insert_document with shipping, returns and opening hours, then create_chat_widget with the site's origin and put the embed code on the site.",
"tools": [
"create_collection",
"insert_document",
"create_chat_widget"
],
"optional": true
},
{
"id": "structured_data",
"title": "Structured data for search",
"how": "Add the Product block from structured_data.snippet to the head of each product page (one Product block per product, with that product's own name, price and image) with deploy_site and keep_existing: true. Replace every YOUR ... placeholder with the user's real details from the answers so far; never invent an address, phone number, price or opening hours, and leave out optional fields you do not know. check_readiness flags blocks that are incomplete or still have placeholders.",
"tools": [
"deploy_site"
],
"optional": true
},
{
"id": "launch",
"title": "Before launch",
"how": "check_readiness for the site, fix what you can, and label_site_version \"before launch\" so this state is kept. Then tell the user in plain words what works and what does not yet.",
"tools": [
"check_readiness",
"label_site_version"
]
}
],
"go_live_checklist": [
{
"id": "company_details",
"level": "usually_required",
"description": "Company details on the site, easy to find (footer or contact page): the legal name, the geographic address, an email address and a phone number, the chamber of commerce or company registration number, and the VAT number if the business has one. In the Netherlands: the KvK number and the btw-id.",
"why": "EU and UK rules for online services and distance selling ask businesses to say who they are and how to reach them. Customers also trust a shop more when they see a real business behind it.",
"how_on_hypamoon": "Ask the user for these details (never invent them), put them on /contact and in the footer of every page with deploy_site.",
"checked_by_check_readiness": true
},
{
"id": "privacy_statement",
"level": "usually_required",
"description": "A privacy statement that says which personal data the site collects (forms, orders, bookings, chat), why, how long it is kept, who processes it, and how people can see or delete their data.",
"why": "The GDPR (EU) and UK GDPR require this as soon as you collect personal data; in the US, California (CalOPPA) requires a posted privacy policy for sites that collect personal information from California residents.",
"how_on_hypamoon": "Write it with the user from what the site really collects, put it on /privacy with deploy_site and link it in the footer and next to every form.",
"checked_by_check_readiness": true
},
{
"id": "cookie_notice",
"level": "recommended",
"description": "A cookie notice with a real choice, but only if the site uses non-essential cookies or trackers (analytics with personal data, advertising pixels, embedded videos that set cookies). A site with only functional storage needs no banner.",
"why": "The ePrivacy rules (EU) and PECR (UK) require consent before non-essential cookies are set. A banner that is not needed only gets in the way.",
"how_on_hypamoon": "Hypamoon sites, forms, checkout and the chat widget need no cookie banner by themselves. If the user adds a tracker, add a consent banner that loads it only after a yes.",
"checked_by_check_readiness": false
},
{
"id": "accessibility",
"level": "recommended",
"description": "Basic accessibility: readable contrast, alt text on images, labels on form fields, keyboard navigation, and an accessibility statement for online shops and services.",
"why": "The European Accessibility Act applies to e-commerce and many consumer services since 28 June 2025; micro-enterprises (fewer than 10 staff and at most 2 million euro turnover) are usually exempt for services. Accessible sites also sell better.",
"how_on_hypamoon": "Build the pages with labels, alt text and enough contrast; add /accessibility if the business is not a micro-enterprise.",
"checked_by_check_readiness": false
},
{
"id": "prices_incl_vat",
"level": "usually_required",
"description": "Prices for consumers shown as the total price, VAT included, with the currency (for example € 19,95).",
"why": "Consumer law in the EU and UK requires the total price including taxes; a price without currency or VAT confuses buyers.",
"how_on_hypamoon": "Show prices with the currency on the page; with checkout, price_cents in the product collection is the price including VAT.",
"checked_by_check_readiness": true
},
{
"id": "terms_and_conditions",
"level": "recommended",
"description": "Terms and conditions on /terms, linked in the footer and in the order flow, that the customer can read and save before ordering.",
"why": "Not every country requires them, but if the shop uses them, customers must be able to read them before they order; they also set delivery, payment and liability.",
"how_on_hypamoon": "Write them with the user, put them on /terms with deploy_site and link them next to the order button.",
"checked_by_check_readiness": true
},
{
"id": "withdrawal_right",
"level": "usually_required",
"description": "Information about the 14-day right of withdrawal (cancellation): how to withdraw, who pays return shipping, the refund within 14 days, the exceptions (for example made-to-order or sealed hygiene products), and the model withdrawal form.",
"why": "The EU Consumer Rights Directive and the UK Consumer Contracts Regulations give consumers 14 days to cancel most online purchases; without the information the period can be extended by up to 12 months.",
"how_on_hypamoon": "Put it on /returns with the model withdrawal form, link it in the footer and from the order flow (deploy_site).",
"checked_by_check_readiness": true
},
{
"id": "withdrawal_function",
"level": "usually_required",
"description": "An easy way to withdraw online: a clearly labelled withdrawal function (like \"withdraw from contract here\") where the customer confirms their withdrawal, followed by a confirmation.",
"why": "An EU amendment to the Consumer Rights Directive (Directive 2023/2673) asks for an online withdrawal function for contracts made online, applying from 19 June 2026.",
"how_on_hypamoon": "A form on /returns that saves the withdrawal in a collection (create_collection with public_write and email_on_insert, get_form_snippet) and shows a confirmation.",
"checked_by_check_readiness": false
},
{
"id": "shipping_costs_upfront",
"level": "usually_required",
"description": "Shipping costs, delivery restrictions (countries) and the expected delivery time shown before the customer starts ordering.",
"why": "In the EU and UK this information must be clear at the start of the ordering process; everywhere, hidden costs at the end are the main reason people abandon a purchase.",
"how_on_hypamoon": "Show shipping costs on the product pages and next to the order button (deploy_site).",
"checked_by_check_readiness": false
},
{
"id": "payment_methods_upfront",
"level": "usually_required",
"description": "The accepted payment methods shown at the latest at the start of the ordering process.",
"why": "The Consumer Rights Directive asks for this at the start of the order.",
"how_on_hypamoon": "List the methods of the user's payment account on the product page or in the footer.",
"checked_by_check_readiness": false
},
{
"id": "order_button_text",
"level": "usually_required",
"description": "The final order button says clearly that ordering means paying, like \"Order with obligation to pay\" or in Dutch \"Bestellen en betalen\"; not just \"Next\" or \"Continue\".",
"why": "The EU Consumer Rights Directive and the UK rules require this; without it the customer may not be bound by the order.",
"how_on_hypamoon": "Set the button text on the checkout button (get_checkout_snippet html) or the order request button.",
"checked_by_check_readiness": false
},
{
"id": "order_confirmation",
"level": "usually_required",
"description": "An order confirmation by email (a durable medium) with what was ordered, the price, the company details and the withdrawal information.",
"why": "EU and UK consumer law ask for a confirmation on a durable medium at the latest at delivery; customers everywhere expect one right away.",
"how_on_hypamoon": "Check what the checkout or the payment provider already sends; add one with send_email (Mail module) if needed, or tell the user to send it.",
"checked_by_check_readiness": false
},
{
"id": "legal_guarantee",
"level": "recommended",
"description": "A line about the legal guarantee: products must be as promised, and consumers keep their legal rights when something is wrong.",
"why": "The legal guarantee applies anyway (at least two years in most EU countries; in the Netherlands what the buyer may reasonably expect); saying so avoids disputes.",
"how_on_hypamoon": "Add it to /terms or /returns with deploy_site.",
"checked_by_check_readiness": false
},
{
"id": "complaints",
"level": "recommended",
"description": "How to complain, with an email address and a reasonable answer time, and the dispute body if the business is a member of one (in the Netherlands for example the Geschillencommissie or a trade association).",
"why": "The EU ODR platform closed on 20 July 2025 and the link is no longer needed, but a clear complaint route is still expected, and members of a dispute body must mention it.",
"how_on_hypamoon": "Add it to /terms or /contact with deploy_site.",
"checked_by_check_readiness": false
},
{
"id": "age_check",
"level": "usually_required",
"description": "An age check when selling age-restricted products (alcohol, tobacco and vapes, knives, some games): ask for confirmation online and check the ID at delivery.",
"why": "Selling these to minors is forbidden; in the Netherlands the Alcoholwet requires an age check at the door for alcohol delivered to the home.",
"how_on_hypamoon": "Only if relevant: add an age statement to the order form and tell the user to arrange the check at delivery.",
"checked_by_check_readiness": false
},
{
"id": "vat_registration",
"level": "recommended",
"description": "VAT set up correctly: the right rate in the prices, and the EU One Stop Shop (OSS) when sales to consumers in other EU countries pass 10,000 euro a year.",
"why": "Above that threshold the VAT of the buyer's country applies.",
"how_on_hypamoon": "Nothing to build; mention it to the user so they check it with their accountant.",
"checked_by_check_readiness": false
},
{
"id": "checkout_real",
"level": "usually_required",
"description": "Every buy, order or add-to-cart button leads to a real checkout or a real order request; a cart always ends in one.",
"why": "A button that does nothing loses the sale and makes the shop look fake.",
"how_on_hypamoon": "get_checkout_snippet when payments are ready, otherwise an order request form (create_collection, get_form_snippet).",
"checked_by_check_readiness": true
},
{
"id": "structured_data",
"level": "recommended",
"description": "Structured data (JSON-LD from schema.org) that tells search engines what the business or product is: name, address, phone, opening hours, prices.",
"why": "Search engines can then show richer results, like opening hours, a price or a location on the map, instead of a plain link.",
"how_on_hypamoon": "Add the block from get_advice (structured_data.snippet) with the user's real details, with deploy_site and keep_existing: true.",
"checked_by_check_readiness": true
},
{
"id": "forms_saved",
"level": "usually_required",
"description": "Every form really saves what a visitor sends (in a collection or through an app), and the owner sees or gets each entry.",
"why": "A form that goes nowhere loses customers without anyone noticing. This is not a law; it is what makes the site work.",
"how_on_hypamoon": "create_collection with public_write (and email_on_insert so the owner gets each entry), then get_form_snippet and deploy_site.",
"checked_by_check_readiness": true
},
{
"id": "no_placeholders",
"level": "usually_required",
"description": "No lorem ipsum, example.com, \"your text here\" or other placeholder text, and no invented reviews, customers or numbers presented as real.",
"why": "Placeholders look unfinished; invented reviews or customers are misleading and, for consumers, an unfair commercial practice in the EU and UK.",
"how_on_hypamoon": "Replace placeholders with the user's real text, or leave the section out until they have it (deploy_site with keep_existing).",
"checked_by_check_readiness": true
},
{
"id": "no_test_wording",
"level": "usually_required",
"description": "No test wording on the live site, like \"this is a test form\" or \"test environment\", once it is meant for real visitors.",
"why": "Visitors stop trusting a form that says it is a test, and they will not send real requests.",
"how_on_hypamoon": "Change the texts (and data-hypamoon-success on forms) with deploy_site, and make sure the form uses the live public token.",
"checked_by_check_readiness": true
},
{
"id": "internal_links",
"level": "recommended",
"description": "Every link inside the site leads to a page that exists, and buttons go somewhere real.",
"why": "Broken links and dead buttons make a site look like a mockup.",
"how_on_hypamoon": "Add the missing pages or fix the links with deploy_site.",
"checked_by_check_readiness": true
},
{
"id": "not_found_page",
"level": "recommended",
"description": "A friendly not-found page (/404.html) with a link back home and the contact details.",
"why": "Old links and typos end there; a plain error page loses the visitor.",
"how_on_hypamoon": "Add /404.html with deploy_site.",
"checked_by_check_readiness": true
},
{
"id": "no_fake_login",
"level": "usually_required",
"description": "No login that checks a password in JavaScript, and no member area that only hides content in the browser.",
"why": "Anyone can read the password in the page source; it protects nothing and gives the owner a false sense of safety.",
"how_on_hypamoon": "One shared password: password on deploy_site. Accounts per person: deploy_app with auth. Paid access: create_access_product and set_site_access.",
"checked_by_check_readiness": true
},
{
"id": "no_local_storage_db",
"level": "usually_required",
"description": "Orders, bookings, sign-ups and messages are not kept in the visitor's browser (localStorage) as if it were a database.",
"why": "localStorage stays on the visitor's own device: the owner never sees it, and it is gone when they clear their browser.",
"how_on_hypamoon": "Save it in a collection (create_collection, get_form_snippet) or through an app (deploy_app). localStorage is fine for a theme or language choice.",
"checked_by_check_readiness": true
}
],
"pitfalls": [
{
"id": "fake_checkout",
"pitfall": "A \"Pay now\" or \"Add to cart\" button that does nothing, or a cart that only lives in the browser.",
"instead": "The real checkout when payments are ready, otherwise an honest order request that says payment follows later."
},
{
"id": "price_in_page",
"pitfall": "Trusting a price that the page sends to the server.",
"instead": "The checkout always reads price_cents from the product collection; show the price as text on the page."
},
{
"id": "no_shipping_costs",
"pitfall": "Shipping costs that only appear at the end.",
"instead": "Show shipping costs on the product page and before the order button."
},
{
"id": "no_withdrawal",
"pitfall": "No returns page or withdrawal form for consumers.",
"instead": "A /returns page with the 14-day rules and the model form, linked in the footer."
},
{
"id": "invented_reviews",
"pitfall": "Invented reviews, star ratings or \"1,000 happy customers\".",
"instead": "Only real reviews from the user, or none."
},
{
"id": "variants_as_one",
"pitfall": "Variants with different prices in one product.",
"instead": "Each variant with its own price as its own product."
},
{
"id": "forgot_order_email",
"pitfall": "Orders that nobody sees.",
"instead": "email_on_insert on order requests, list_orders for paid orders, and a clear owner email."
},
{
"id": "users_round_trip",
"pitfall": "App code that fetches /app-users itself on every page to show the people of the app.",
"instead": "Read users via env.HYPAMOON.users: env.HYPAMOON.users.list() or env.HYPAMOON.users.get(id) in the app's server code, usually answered next to the app without a round trip."
}
],
"structured_data": {
"schema_type": "Product",
"why": "Search engines can show the product with its price and whether it is in stock. Use one block per product page, with that product's own details.",
"snippet": "<script type=\"application/ld+json\">\n{\n \"@context\": \"https://schema.org\",\n \"@type\": \"Product\",\n \"name\": \"YOUR PRODUCT NAME\",\n \"description\": \"YOUR PRODUCT DESCRIPTION\",\n \"image\": \"YOUR PRODUCT IMAGE URL (absolute, https)\",\n \"offers\": {\n \"@type\": \"Offer\",\n \"price\": \"YOUR PRICE (like 19.95)\",\n \"priceCurrency\": \"YOUR CURRENCY CODE (like EUR)\",\n \"availability\": \"https://schema.org/InStock\",\n \"url\": \"YOUR PRODUCT PAGE URL\"\n }\n}\n</script>",
"required": [
"name",
"offers.price",
"offers.priceCurrency"
]
},
"note": "This is guidance to help the user prepare, not legal or tax advice. Rules differ per country and change over time; for anything that matters, the user checks with an advisor or the official sources (in the Netherlands for example ACM ConsuWijzer for consumer law, the Belastingdienst for VAT and the Autoriteit Persoonsgegevens for privacy).",
"other_types": {
"available": [
"webshop",
"bookings",
"restaurant",
"course_membership",
"wordpress_portal"
],
"planned": [
"events",
"portfolio",
"internal_tool",
"saas"
]
},
"known_problems": [
{
"id": "sandbox_blocks_upload",
"symptom": "The PUT to an upload_url fails or is blocked from your own environment (a 403, a network error, \"host not allowed\"), or deploy_site says upload_not_received.",
"what_to_do": "This is your sandbox, not Hypamoon: do not say Hypamoon is down or blocked. Send images under ~3 MB as base64 in deploy_site content instead. Files that are already online go as source_url in deploy_site (up to 100 per call). For larger or many local files, create_drop_link, give the user the url, and after they are done call get_drop and deploy its deploy_files; the user can also add them at https://app.hypamoon.com/media, after which deploy_site finds them by path, sha256 and bytes.",
"tell_user": "Your chat app blocked the upload, not Hypamoon. I will send the file another way, or give you a link to upload it yourself in your browser."
},
{
"id": "image_too_large",
"symptom": "site_too_large, or one request over 4 MB because of an image.",
"what_to_do": "Make a lighter copy first: at most about 2000 px wide, JPEG or WebP at quality ~80; that is usually under 500 KB and looks the same on screen. A file that must stay large goes through create_upload_urls, or create_drop_link if your environment cannot upload. The limits per file are in the error and in limits of create_upload_urls.",
"tell_user": "That image is bigger than a website needs. I will make a lighter copy that looks the same on screen."
},
{
"id": "version_conflict",
"symptom": "version_conflict (409 or 412) on deploy_site or set_call_plan.",
"what_to_do": "Something changed since you read it (another chat, a team member, the portal). Read it again (get_site or get_call_plan), apply your change on top of the current version, and send it with if_match_version set to current_version from the error. Never retry without reading, and never overwrite blindly.",
"tell_user": "The site changed in the meantime, so I did not overwrite it. I will apply your change to the latest version."
},
{
"id": "phone_verification_required",
"symptom": "phone_verification_required (403) when making a site public or connecting a domain.",
"what_to_do": "The account owner verifies their phone once at the verify_url in the error (https://app.hypamoon.com/verify-phone); a payment method also counts. Until then the site works with a password or as unlisted. Do not try to work around it.",
"tell_user": "Before the site can be public, please confirm your phone number once at the link I give you. It takes about a minute."
},
{
"id": "site_not_visible",
"symptom": "The user says the site works for them but not for others, or it does not show up in Google.",
"what_to_do": "Check get_site: an unlisted site only opens with its secret link, and a site with a password asks every visitor for it. update_site_address makes it public (that may need phone verification); rotate_site_secret_url makes a new secret link. A new public site takes days to appear in Google; check_readiness covers findability.",
"tell_user": "Your site is live, but it is set to private, so only people with the secret link can open it. I can make it public if you want."
},
{
"id": "is_hypamoon_down",
"symptom": "A step failed and it looks like Hypamoon might be down.",
"what_to_do": "Call get_status before you say anything about an outage. Operational: the problem is in the failing step or in your own environment; read its user_message and fix that step. A reported problem: tell the user what is affected and link https://status.hypamoon.com; retry later instead of inventing a workaround.",
"tell_user": "Hypamoon is working normally. The problem is likely in your chat app. (Use the user_message from get_status; it says so when there is a real problem.)"
}
],
"summary_markdown": "## Webshop (EU, NL, selling to consumers)\nA shop on the user's own site: products, a real checkout (or an honest order request while payments are not on), order emails and the pages a shop needs to go live.\n\n**Ask only these, if not answered yet:** Which country is your business in, and where do you sell to? What do you sell, and roughly how many products? Do they come in variants like size or color?\n**Defaults for the rest:** seller: A business selling to consumers.; shipping: One flat shipping rate within the country, shown on every product page and before checkout; pickup on request.; returns: The legal minimum: 14 days to withdraw, the customer pays return shipping, refund within 14 days.; stock: No stock tracking (unlimited); the owner takes a product off sale by hand.; payment_methods: What the user's own payment account offers (in the Netherlands usually iDEAL and cards).; order_emails: The project's billing email address.; language_currency: The language and currency of the user's country.\n\n**Build plan:**\n1. Pages first: deploy_site\n2. Products as data: create_product_collection, create_collection, insert_document\n3. A real checkout, or an honest order request: get_payments_status, connect_payments, get_checkout_snippet, create_collection, get_form_snippet\n4. Orders and emails: list_orders, get_order, update_order_status, query_documents, send_email, add_email_domain, verify_email_domain\n5. Contact form that really arrives: create_collection, get_form_snippet\n6. Customer accounts (optional) (optional): deploy_app, set_table_owner_column\n7. Chat that knows the shop (optional) (optional): create_collection, insert_document, create_chat_widget\n8. Structured data for search (optional): deploy_site\n9. Before launch: check_readiness, label_site_version\n\n**Before going live (offer it, do not impose it):** 16 usually required and 9 recommended points; check_readiness verifies 14 of them on the site.\n\n**Common mistakes:** A \"Pay now\" or \"Add to cart\" button that does nothing, or a cart that only lives in the browser. Trusting a price that the page sends to the server. Shipping costs that only appear at the end. No returns page or withdrawal form for consumers. Invented reviews, star ratings or \"1,000 happy customers\". Variants with different prices in one product. Orders that nobody sees. App code that fetches /app-users itself on every page to show the people of the app.\n\n_This is guidance to help the user prepare, not legal or tax advice. Rules differ per country and change over time; for anything that matters, the user checks with an advisor or the official sources (in the Netherlands for example ACM ConsuWijzer for consumer law, the Belastingdienst for VAT and the Autoriteit Persoonsgegevens for privacy)._"
}
Move sites from the test project to the live project.
Moves the chosen sites of the test project (default: all) to its live project, with every version, label, password and address, the collections they use with all documents, an app on the site with its sign-in users, files and database, the agents on those collections (paused) and domains connected for real. Simulated domains do not come along. The test form token (hm_pubtest_) in the site files is replaced by a new live token with the same allowed origins, as a new site version labelled 'moved to live'. A collection that a site staying in test also uses is refused by default (409 with the list); shared_collections copy gives the live project its own copy. Everything in the database happens in one transaction, and a move log keeps the rows from before, so the move can be undone within 24 hours. Works with a test or a live key of the same project, but only for an owner (through OAuth, or a key made by an owner) and only when the live project is verified: otherwise verification_required with the missing steps (company details, a payment with SCA, a verified phone). The plan limits of the live project apply (sites, files, data, apps). A chat connected through OAuth to the test project stays on test: reconnect and choose the live project, or switch the connection to live in the portal under Connections.
Move sites from the test project to the live project.
Field
Type
sites
string[]
Optional. The sites to move, by id or slug. Leave it out to move every site of the test project.
collections
string[]
Optional. The collections to move, by name or id. Leave it out to move the collections the chosen sites use (a site uses a collection when its files name it, like a form from get_form_snippet).
shared_collections
"refuse", "copy"
What to do with a collection that a site staying in test also uses. refuse (default): nothing is moved and the answer lists them. copy: the live project gets its own copy with all documents; the test site keeps the original.
partly_done: some steps after the move did not finish; see errors. Support is notified.
from_project (required)
object
to_project (required)
object
moved (required)
object
stayed_in_test (required)
object
warnings (required)
string[]
errors (required)
string[]
undo (required)
object
connection (required)
object or null
For a chat connected through OAuth: which project the connection points to now.
summary (required)
string
next_step (required)
string
JSON
{
"id": "0f6c2a8e-7d3b-4c1e-9a55-2b8f6d4e1c90",
"status": "done",
"from_project": {
"id": "6b1d2c3e-4f50-4a6b-8c7d-9e0f1a2b3c4d",
"name": "Bakkerij Jansen (test)",
"mode": "test"
},
"to_project": {
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Bakkerij Jansen",
"mode": "live"
},
"moved": {
"sites": [
{
"id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
"slug": "bakkerij-jansen",
"url": "https://bakkerij-jansen.hypamoon.app",
"new_version": 7,
"form_token_replaced": true
}
],
"collections": [
{
"id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
"name": "contact",
"documents": 12
}
],
"copied_collections": [],
"apps": [],
"agents": [],
"domains": [],
"form_tokens": [
{
"name": "Website form",
"live_token_id": "9c0d1e2f-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
"replaces_test_token_id": "2f3a4b5c-6d7e-4f8a-9b0c-1d2e3f4a5b6c"
}
],
"webhooks": 0
},
"stayed_in_test": {
"sites": [],
"collections": [],
"simulated_domains": []
},
"warnings": [],
"errors": [],
"undo": {
"until": "2026-10-10T09:00:00.000Z",
"how": "undo_move_to_live with move_id 0f6c2a8e-7d3b-4c1e-9a55-2b8f6d4e1c90 (POST /project/move-to-live/undo with {\"move_id\": \"0f6c2a8e-7d3b-4c1e-9a55-2b8f6d4e1c90\"}), until 2026-10-10T09:00:00.000Z."
},
"connection": {
"mode": "test",
"note": "This connection still points to the test project, where these sites no longer are. It keeps working there until it is reconnected or switched."
},
"summary": "Moved 1 site (bakkerij-jansen) with all versions, 1 collection with 12 documents and a new live form token from Bakkerij Jansen (test) to Bakkerij Jansen. Nothing else was in the test project. This can be undone until 2026-10-10T09:00:00.000Z with undo_move_to_live.",
"next_step": "Reconnect your chat to Hypamoon and choose Bakkerij Jansen (not test), or switch this connection in the portal under Connections (Switch to live; you confirm the access again there). Check the sites at their address; forms now save to the live project. To put the user's own domain on a site: connect_domain with the site_id from the live project (through the nameservers it is free)."
}
Moves the sites, collections, apps and agents of a move back to the test project, within 24 hours. The sites get the test form token back as a new version, and the live token is revoked. A copied collection stays in the live project. Without move_id the latest move of this project. After 24 hours: 409 with how support can still help.
{
"id": "0f6c2a8e-7d3b-4c1e-9a55-2b8f6d4e1c90",
"status": "undone",
"sites": [
"3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f"
],
"collections": 1,
"apps": 0,
"agents": 0,
"domains": 0,
"errors": [],
"summary": "Moved 1 site and 1 collection back from Bakkerij Jansen to Bakkerij Jansen (test).",
"next_step": "The sites are in the test project again, with the test form token. Use the test connection or a test key to work on them."
}
The failed MCP tool calls and API requests of this project (live and test) from the last 14 days, newest first: when, which app (chatgpt, claude, grok, oauth, mcp or api), the tool or route, the error type, the status, the detail the caller got, how long it took and the request id. Arguments are only summarized (field names, types, lengths and counts), never their values; email addresses, phone numbers, keys and URL paths in the detail are masked. Pass request_id to find one error the user mentions. At most 1000 errors per project per day are kept; dropped_today counts the ones above that.
Scope: project:read
Parameter
In
Type
limit
query
integer
Errors per page, 1 to 100.
before
query
string
next_before from the previous page, for older errors.
request_id
query
string
Only the errors of this request (the request id from an error message or the X-Request-Id header).
tool
query
string
Only the errors of this MCP tool, like deploy_site.
The prepaid balance: every SMS, call minute and number is deducted from it, and AI usage (chat replies, agents, the portal assistant) is billed per use. Top up in the portal.
A live project pays in advance: you top up a balance in the portal (https://app.hypamoon.com/balance) and every SMS, call minute and number is deducted from it immediately. This returns the balance, what was spent this calendar month against the spending cap, the automatic top-up settings and the latest 20 transactions, newest first. All amounts are in euro, excluding VAT. Read-only: topping up and the automatic top-up settings are in the portal. With a test key you see the balance of the live project with test: true. Test mode is free, except AI above the free daily test budget (chat replies, agent model calls), which is deducted from this balance.
Orders a number for the project. The Idempotency-Key header is required: when your request times out, send it again with the same key and you receive the same number, never a second one. Before ordering we check the company details, the spending cap, the payment method, a verified phone with one payment confirmed with your bank (iDEAL or a card with 3D Secure; verification_required otherwise), and the prepaid balance; each has its own error. A national number needs a business: a personal account receives business_required, and a business may need its registration number checked against the business register (verification_required with missing kvk_check). A live number is deducted from the balance: the first month when you order it, then on the first of every month. A geographic number also needs the number_documents verification level (see verification in GET /project).
Scope: numbers:write
Parameter
In
Type
Idempotency-Key (required)
header
string
1 to 255 visible ASCII characters without spaces, like a UUID. A new one per order; reuse it when you retry. We remember a key for 24 hours.
Available numbers you can order with POST /numbers. Everything returned can be ordered right now. type=national returns non-geographic numbers that work across the country; numbers from our stock come first and work immediately. Regional numbers (type=geographic) need documents that cannot be uploaded through the API yet. With a test key, it returns free test numbers from +447700900000 to +447700900999 instead (test: true); they never reach the carrier.
Scope: numbers:read
Parameter
In
Type
type
query
"national", "geographic"
national or geographic. Without it, the type follows from area: the national prefix or nothing is national.
area
query
string
The area code. Required for geographic; leave it out for national.
Test mode only. Removes a test number from the test project, so you can take another one (a test project has at most 3). Its call plan is removed with it; calls and messages are kept. A live key gets 409 number_release_not_supported: releasing a live number cancels it at the carrier for good and is not possible through the API.
Whether the assistant was ever set up with PUT /assistant.
voice (required)
string or null
The voice, like ava. It speaks the language.
language (required)
string
The language the assistant opens and speaks in, like en.
languages (required)
string[]
The languages your callers speak, with language first.
speech_recognition (required)
string
The language of speech recognition: the one language, or multi when there are two or more.
instructions (required)
string
What the assistant does: your instructions.
website (required)
string or null
The website the assistant reads for opening hours, address and services.
greeting (required)
string
The fixed greeting, in the language. It follows from the project name and always says the caller is talking to a digital assistant.
synced_at (required)
string or null (date-time)
When the assistant was last updated at the carrier.
JSON
{
"configured": true,
"voice": "ava",
"language": "en",
"languages": [
"en"
],
"speech_recognition": "en",
"instructions": "You answer for Acme Plumbing. Ask for the address and the problem, and tell the caller we call back within the hour. Never quote prices.",
"website": "https://acme-plumbing.example/",
"greeting": "You are speaking with the digital assistant of Acme Plumbing. How can I help you?",
"synced_at": "2026-10-01T09:00:00.000Z"
}
One assistant per project. PUT replaces all fields; any field you leave out reverts to its default. The greeting is fixed: it comes from your project name and always says the caller is talking to a digital assistant; sending greeting returns 422.
The voice. It must speak the language: nl saar, fenna, merel, julia, roos, tim, bram, daan, ruben; en ava, nora, liam, owen; de lena, mara, jonas, felix; fr chloe, manon, hugo, theo; es lucia, elena, pablo, diego. Case does not matter. Default: the first voice of the language.
language
"nl", "en", "de", "fr", "es"
The language the assistant opens and speaks in: en (English), nl (Dutch), de (German), fr (French) or es (Spanish). The greeting follows it. Default: the language of the country of your first number (Dutch while the project has no number yet).
languages
"nl", "en", "de", "fr", "es"[]
The languages your callers speak; language always comes first. One language is the most reliable, because speech recognition never has to guess. Two or more switch it to multi, which follows the caller sentence by sentence, and the assistant switches with them. auto and multi are not accepted. Default: [language].
instructions
string
What the assistant does, as free text. Default empty.
website
string or null
Optional. We read the page and use it for opening hours, address and services. Must be a public web address.
JSON
{
"language": "en",
"voice": "ava",
"instructions": "You answer for Acme Plumbing. Ask for the address and the problem, and tell the caller we call back within the hour. Never quote prices.",
"website": "https://acme-plumbing.example"
}
Whether the assistant was ever set up with PUT /assistant.
voice (required)
string or null
The voice, like ava. It speaks the language.
language (required)
string
The language the assistant opens and speaks in, like en.
languages (required)
string[]
The languages your callers speak, with language first.
speech_recognition (required)
string
The language of speech recognition: the one language, or multi when there are two or more.
instructions (required)
string
What the assistant does: your instructions.
website (required)
string or null
The website the assistant reads for opening hours, address and services.
greeting (required)
string
The fixed greeting, in the language. It follows from the project name and always says the caller is talking to a digital assistant.
synced_at (required)
string or null (date-time)
When the assistant was last updated at the carrier.
sync (required)
"synced", "failed", "skipped_test_mode"
synced when the assistant was updated right away; failed when not. Your settings are saved either way and applied with the next PUT. skipped_test_mode with a test key: a test project has no live assistant.
call_plan (required)
"added", "in_plan", "not_in_plan"
added: the project only had the default plan (voicemail), so it now has a call plan where the assistant answers every call, with voicemail if the assistant is not available. in_plan: the call plan already sends calls to the assistant. not_in_plan: your own call plan does not use the assistant yet; add an ai_agent node with PUT /numbers/{id}/call-plan.
next_step (required)
string
What to do next.
user_message (required)
string
A sentence you can show the user as it is.
JSON
{
"configured": true,
"voice": "ava",
"language": "en",
"languages": [
"en"
],
"speech_recognition": "en",
"instructions": "You answer for Acme Plumbing. Ask for the address and the problem, and tell the caller we call back within the hour. Never quote prices.",
"website": "https://acme-plumbing.example/",
"greeting": "You are speaking with the digital assistant of Acme Plumbing. How can I help you?",
"synced_at": "2026-10-01T09:00:00.000Z",
"sync": "synced",
"call_plan": "added",
"next_step": "Nothing else is needed: the assistant answers every call, and callers can leave a message if it is not available. To ring a team first, PUT /numbers/{id}/call-plan.",
"user_message": "Your assistant is ready and answers every call. If it is ever not available, callers can leave a message."
}
The version of the call plan, like "3". Send it back as If-Match.
Field
Type
number_id (required)
string (uuid)
version (required)
integer
The version of the number's own plan; 0 when the number has no own plan yet. Also in the ETag header.
source (required)
"number", "project", "none"
Where plan comes from. number: the number has its own plan. project: the number has no own plan and follows the plan of the project (set_assistant sets that one up); this is what callers get. none: no plan at all.
plan (required)
one of
The plan that calls to this number follow: its own plan, or else the plan of the project; null when there is none.
Replaces the call plan of one number. A plan with an ai_agent node needs the assistant (PUT /assistant; voice AI is always included) and updates the assistant with this plan. Send If-Match with the ETag from GET to avoid overwriting a newer plan.
Scope: numbers:write
Parameter
In
Type
id (required)
path
string (uuid)
The id of the number.
If-Match
header
string
The version from the ETag of GET, like "3". An outdated version returns 412 version_conflict.
A call plan: a small graph. entry is the first node; every node points to the next by id. At most 50 nodes, and every node must be reachable from entry. Field names inside the plan are camelCase: the plan is stored exactly as you send it.
Field
Type
entry (required)
string
The id of the first node.
nodes (required)
object
The nodes by id. The key must be the same as the node's id.
A call plan: a small graph. entry is the first node; every node points to the next by id. At most 50 nodes, and every node must be reachable from entry. Field names inside the plan are camelCase: the plan is stored exactly as you send it.
Your calls, inbound and outbound, with transcript and summary. Your assistant can also call one person who expects it, like a customer with an appointment.
The calls of the project, inbound and outbound, newest first, without transcript and summary. Follow next_cursor while has_more is true. A call your assistant made with POST /calls has direction outbound and an outbound object with its status, reference and metadata.
Scope: calls:read
Parameter
In
Type
limit
query
integer
Calls per page, default 25. A value above 100 is capped at 100.
cursor
query
string
The next_cursor of the previous page.
direction
query
"inbound", "outbound"
Only inbound calls, or only outbound calls (the ones your assistant made with POST /calls). Without it, both.
Your project's assistant calls one person who expects the call: to confirm an appointment, follow up a callback request or agree on a delivery time. Not for cold calling or sales: telemarketing to consumers needs their prior consent under telemarketing law, and numbers in a national do-not-call register must be respected. Only call people who asked for it or have an appointment with you. purpose says why you call and is recorded with the call: transactional (the default: an answer to the person's own question, confirming or reminding of an appointment, an order or delivery status, a callback they asked for) or marketing (anything that sells or promotes). Under Dutch rules a marketing call needs the person's prior consent, since 1 July 2026 also for existing customers: purpose marketing only calls when consent for this number is recorded (POST /call-consents), otherwise consent_required and nobody is called. The assistant always opens by saying it is the digital assistant of your company and asks whether this is a good time; that cannot be switched off. assistant_instructions adds what this call is about, on top of the assistant's own instructions (PUT /assistant). It calls from one of your project's numbers (from, or your first active number), so the person can call back. Calling hours: by default 09:00 to 20:00, Monday to Saturday, in the local time of the person called; calling_window can narrow or move that, but never before 08:00 or after 21:00. Outside those hours the request returns outside_calling_hours with next_window_start. Public holidays are not recognized yet. Limits per project: 5 calls per minute, 100 per day and 2 at the same time (concurrent_call_limit, with Retry-After). A new live project starts lower (10 calls per day, 30 after 7 days and a first top-up, the full limit after 30 days and €25 paid). The same number can be called at most 3 times in 24 hours per project and 10 times across all projects: 429 call_limit_reached with scope per_number or per_number_platform, retry_at and a user_message. A call lasts at most max_duration_sec (default 600, at most 1200). Live: mobile and geographic numbers in a country your project can call; never premium-rate, service, toll-free or short numbers like 112. An unsupported destination returns destination_not_allowed with a reason. A live key needs the full verification level (requested in the portal under Verification) plus the company level (company details, spending cap, payment method, a verified phone and one payment confirmed with your bank: iDEAL or a card with 3D Secure). In live mode, the cost of a call is deducted from your prepaid balance when it ends: the voice AI minutes plus the line minutes, each billed per started minute (the rates are under Billing in the portal), and it counts toward the spending cap. Unanswered, busy and failed calls cost nothing; a call that reaches an answering machine is charged for the line minutes. Before we call, the balance must cover the call at its full max_duration_sec; otherwise the request returns insufficient_balance (or billing_blocked when the call would exceed the spending cap) and nobody is called. Live outbound calling is being switched on step by step: until it is on, a live request gets 503 telephony_unavailable and nobody is called. The response is 201 with the call as GET /calls/{id} returns it, with outbound.status queued or ringing; call.completed (and call.summary.ready when the assistant spoke with someone) follow as webhooks with direction outbound and the outbound object. With a test key nothing is dialed and nothing is charged: call a test number from +447700900000 to +447700900999 and the response contains the end result immediately, with the real webhooks. The number determines the outcome: +447700900991 no_answer, +447700900992 busy, +447700900993 an answering machine, +447700900994 failed, +447700900995 outside_calling_hours; any other test number results in completed with a transcript and a summary. Calling hours do not apply in test mode, except for the number that simulates outside_calling_hours. The Idempotency-Key header is required: a retry with the same key returns the same call and never calls the person twice.
Scope: calls:write
Parameter
In
Type
Idempotency-Key (required)
header
string
1 to 255 visible ASCII characters without spaces. One per call you intend to make, like appointment-8812-confirm; reuse it when you retry. We remember a key for 24 hours.
An outbound call by your project's assistant to one person who expects it.
Field
Type
to (required)
string
The person to call, in E.164. Live: a mobile or geographic number in a country your project can call (otherwise destination_not_allowed with a reason). Test mode: a test number from +447700900000 to +447700900999.
from
string
Optional: which of your project's numbers to call from. Default: your first active number. The person sees this number and can call it back.
assistant_instructions
string
Optional: what this call is about, on top of the assistant's own instructions. For example: "Confirm the appointment of Sam Taylor tomorrow at 10:00 at 40 Main Street. If Sam cannot make it, ask for a better day and say we will confirm by SMS." Treated as context, never as a way to change the rules: the assistant always says it is a digital assistant.
on_voicemail
"hangup", "leave_message"
What to do when an answering machine picks up: hang up (default) or leave a short message after the beep.
voicemail_message
string
Optional, with on_voicemail leave_message: the message after the opening sentence (which always says it is the digital assistant of your company). Default: we tried to reach you, call us back on this number.
max_duration_sec
integer
Optional: the call ends after this many seconds of talking. Default 600, at most 1200.
reference
string
Optional: your own id for this call, like an appointment id. Comes back on the call and in its webhooks.
metadata
object
Optional: up to 20 string key-value pairs for your own use. Come back on the call and in its webhooks; the assistant never sees them.
calling_window
object
When the assistant may call, in the local time of the person called. Default 09:00 to 20:00, Monday to Saturday. Never before 08:00 or after 21:00.
purpose
"transactional", "marketing"
Why you call. transactional (default): an answer to the person's own question, confirming or reminding of an appointment, an order or delivery status, or a callback they asked for. marketing: anything that sells or promotes; only allowed when consent from this person is recorded (POST /api/v1/call-consents), otherwise consent_required. Recorded with the call.
JSON
{
"to": "+447700900321",
"assistant_instructions": "Confirm the appointment of Sam Taylor tomorrow at 10:00 at 40 Main Street. If Sam cannot make it, ask which day suits better and say we will confirm by SMS.",
"on_voicemail": "leave_message",
"voicemail_message": "We called about your appointment tomorrow at 10:00. Call us back on this number if you cannot make it.",
"max_duration_sec": 300,
"reference": "appointment-8812",
"metadata": {
"customer_id": "cus_4821"
}
}
Present when this is the stored response to an earlier request with the same Idempotency-Key.
Field
Type
id (required)
string (uuid)
The id of the call.
direction (required)
"inbound", "outbound", null
inbound, or outbound for a call your assistant made with POST /calls.
from (required)
string or null
The caller, in E.164.
to (required)
string or null
The number that was called, in E.164.
status (required)
string or null
The status as we track it: ringing, active, completed, missed, failed, voicemail, ai_handled. New values may be added; treat an unknown value like completed.
created_at (required)
string or null (date-time)
When the call reached us.
started_at (required)
string or null (date-time)
When the call started.
answered_at (required)
string or null (date-time)
When the platform picked up. With a call plan the platform always picks up first, so this does not prove a person answered.
ended_at (required)
string or null (date-time)
When the call ended.
duration_sec (required)
integer or null
The length of the call in seconds.
hangup_cause (required)
string or null
Why the call ended, as reported by the carrier, like normal_clearing.
has_summary (required)
boolean
Whether the call has a summary. Fetch it with GET /calls/{id}.
summary_ready_at (required)
string or null (date-time)
When the summary of a recorded call was ready. Empty for a call handled by the voice AI; look at has_summary instead.
test (required)
boolean
true for a simulated call from POST /test/calls or POST /calls with a test key (test mode). Also in webhooks, so you can keep test traffic apart.
outbound (required)
object or null
The request behind an outbound call started with POST /calls.
inbound, or outbound for a call your assistant made with POST /calls.
from (required)
string or null
The caller, in E.164.
to (required)
string or null
The number that was called, in E.164.
status (required)
string or null
The status as we track it: ringing, active, completed, missed, failed, voicemail, ai_handled. New values may be added; treat an unknown value like completed.
created_at (required)
string or null (date-time)
When the call reached us.
started_at (required)
string or null (date-time)
When the call started.
answered_at (required)
string or null (date-time)
When the platform picked up. With a call plan the platform always picks up first, so this does not prove a person answered.
ended_at (required)
string or null (date-time)
When the call ended.
duration_sec (required)
integer or null
The length of the call in seconds.
hangup_cause (required)
string or null
Why the call ended, as reported by the carrier, like normal_clearing.
has_summary (required)
boolean
Whether the call has a summary. Fetch it with GET /calls/{id}.
summary_ready_at (required)
string or null (date-time)
When the summary of a recorded call was ready. Empty for a call handled by the voice AI; look at has_summary instead.
test (required)
boolean
true for a simulated call from POST /test/calls or POST /calls with a test key (test mode). Also in webhooks, so you can keep test traffic apart.
outbound (required)
object or null
The request behind an outbound call started with POST /calls.
transcript (required)
string or null
The transcript. Only here, never in a webhook.
summary (required)
one of
The summary of a call.
JSON
{
"id": "c1a2b3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"direction": "inbound",
"from": "+447700900456",
"to": "+•••••••••42",
"status": "ai_handled",
"created_at": "2026-10-01T09:14:02.000Z",
"started_at": "2026-10-01T09:14:02.000Z",
"answered_at": "2026-10-01T09:14:03.000Z",
"ended_at": "2026-10-01T09:15:37.000Z",
"duration_sec": 94,
"hangup_cause": "normal_clearing",
"has_summary": true,
"summary_ready_at": null,
"test": false,
"outbound": null,
"transcript": "Assistant: U spreekt met de digitale assistent van Acme Plumbing. Waarmee kan ik u helpen?\nCaller: My kitchen tap is leaking, can someone come today?",
"summary": {
"text": "The caller has a leaking kitchen tap and wants someone to come today.",
"topics": [
"leak",
"appointment"
],
"sentiment": "neutral",
"action_items": [
"Call back within the hour to plan a visit."
],
"next_step": "Plan a visit today."
}
}
The project's recorded consents for marketing calls, newest first, active and revoked. With phone_number only those for that number. The number itself is never stored, only a keyed hash plus its last two digits (phone_number_ending).
Scope: calls:read
Parameter
In
Type
phone_number
query
string
Optional: only the consents for this number, in E.164.
Records that one person agreed to receive marketing calls from this project, so POST /calls with purpose marketing may call them. Under Dutch telemarketing rules a sales or promotional call to a consumer needs prior consent; since 1 July 2026 that also applies to existing customers. source says where it came from (form, call, import, manual) and evidence is your reference to the proof (a form entry id, a call id, a file and row), so it can be shown later. Record only consent the person really gave. The number is stored only as a keyed hash plus its last two digits. If valid consent for this number already exists, it is returned unchanged with 200. Only the project owner, or an API key with calls:write, can record consent.
Consent from one person to receive marketing calls from this project.
Field
Type
phone_number (required)
string
The number of the person who gave consent, in E.164. Stored only as a keyed hash plus its last two digits.
source (required)
"form", "call", "import", "manual"
Where the consent came from: form (a form the person filled in), call (they said yes on a call), import (from your own records), manual (recorded by hand).
evidence (required)
string
A reference to the proof, so it can be shown later: a form entry id, a call id, a file and row, or a short note. Not the proof itself and no other personal data.
consented_at
string (date-time)
When the person gave consent (ISO 8601). Default: now. Cannot be in the future.
JSON
{
"phone_number": "+447700900321",
"source": "form",
"evidence": "Newsletter sign-up form, entry 7f3c2a, box 'call me about offers' ticked",
"consented_at": "2026-10-01T09:30:00Z"
}
Revokes a consent: from now on a marketing call to this number returns consent_required. Nothing is deleted; revoked_at is set and the evidence stays. Revoking twice is harmless. Do this as soon as the person says they no longer want to be called. Only the project owner, or an API key with calls:write, can revoke consent.
Creates an endpoint that receives the events you choose, signed with Hypamoon-Signature. The secret is in this response only. At most 10 endpoints per project.
The events to receive, from call.completed, call.summary.ready, message.delivered, message.failed, conversation.summary.ready, balance.low, balance.topped_up, balance.reload_failed, document.created, email.sent, email.delivered, email.delayed, email.bounced, email.complained. The email events come once per recipient.
app_id
string (uuid)
Optional, for email events only: receive only the email events of emails this app sent (an id from list_apps). Without it, the endpoint gets the events of every email of the project.
An endpoint is switched off after 50 failed deliveries in a row. This switches it back on and resets the counter; events from the time it was off are not resent.
Immediately sends a webhook.test event to the endpoint and reports the result. When it fails, we retry it like any other delivery: up to 8 attempts over about 45 hours, or 3 attempts within about 6 minutes for an endpoint of a test project.
{
"data": [
{
"id": "8f7e6d5c-4b3a-4291-8f0e-1d2c3b4a5968",
"to": "+447700900321",
"from": "Acme Shop",
"body": "Hi Sam, thanks for your order 10432 at Acme Shop. We are packing it now; you get a track and trace link as soon as it ships.",
"segments": 1,
"encoding": "gsm7",
"status": "queued",
"error": null,
"reference": "order-10432",
"created_at": "2026-10-01T09:00:00.000Z",
"status_updated_at": "2026-10-01T09:00:00.000Z",
"test": true
}
],
"has_more": false,
"next_cursor": null
}
Sends one transactional SMS: an order confirmation, a shipping update with track and trace, an appointment reminder. Not for marketing: promotional SMS needs the recipient's prior consent under marketing law, and this endpoint is not built for bulk. A message goes to a mobile number in a country your project can text; an unsupported destination returns destination_not_allowed with a reason. It comes from an alphanumeric sender (your project name, or from); your phone numbers cannot send SMS. At most 6 segments per message, 30 messages per minute and 200 per day per project. A new live project starts lower (20 per day, 60 after 7 days and a first top-up, the full limit after 30 days and €25 paid). The same number gets at most 3 messages in 24 hours per project and 10 across all projects: 429 message_limit_reached with scope per_number or per_number_platform, retry_at and a user_message. A live key needs the company verification level (company details, spending cap, payment method, a verified phone and one payment confirmed with your bank: iDEAL or a card with 3D Secure); a business may also need its registration number checked against the business register, and a personal account may need our approval of its identity documents (the number_documents level); otherwise the request returns verification_required. Live messages are deducted from your prepaid balance per segment (the rate is under Billing in the portal; test mode is free) and count toward the spending cap. If the balance is too low for this message, the request returns insufficient_balance; if this month's spending plus this message would exceed the cap, it returns billing_blocked. In both cases nothing is sent. The response says queued; message.delivered or message.failed follows as a webhook, and GET /messages/{id} shows the status. With a test key nothing is sent: use a test number from +447700900000 to +447700900999; the message is delivered immediately, and +447700900999 simulates a failure. Send an Idempotency-Key (one per order event, like order-10432-shipped) so a retry never sends the SMS twice.
Scope: messages:write
Parameter
In
Type
Idempotency-Key
header
string
Optional but recommended: 1 to 255 visible ASCII characters without spaces. Reuse it when you retry; the same key within 24 hours returns the same message instead of sending a second one.
The recipient in E.164. Live: a mobile number in a country your project can text (otherwise destination_not_allowed with a reason). Test mode: a test number from +447700900000 to +447700900999.
body (required)
string
The text. At most 6 SMS segments: 918 characters in plain text (GSM-7), 402 with an emoji or another character outside GSM-7. Typographic quotes and dashes are replaced by plain ones.
from
string
Optional sender name: 1 to 11 letters, digits and spaces, with at least one letter. Default: your project name. A phone number is not possible.
reference
string
Optional: your own id for this message, like an order number. Comes back in the message and its webhooks.
JSON
{
"to": "+447700900321",
"body": "Hi Sam, thanks for your order 10432 at Acme Shop. We are packing it now; you get a track and trace link as soon as it ships.",
"reference": "order-10432"
}
Present when this is the stored response to an earlier request with the same Idempotency-Key.
Field
Type
id (required)
string (uuid)
The id of the message.
to (required)
string
The recipient, in E.164.
from (required)
string
The sender the recipient sees: an alphanumeric name of up to 11 characters.
body (required)
string
The text as it was sent, after typographic quotes and dashes were made SMS-safe.
segments (required)
integer
How many SMS segments the text took. A segment is 160 GSM-7 or 70 UCS-2 characters (153 and 67 when split).
encoding (required)
"gsm7", "ucs2", null
gsm7, or ucs2 when the text has a character outside GSM-7 (an emoji, for example), which makes every segment shorter.
status (required)
"queued", "sent", "delivered", "failed"
queued: accepted. sent: handed to the mobile network. delivered: the phone confirmed it. failed: it did not arrive; see error.
error (required)
string or null
With status failed: a short code, like delivery_failed or sending_failed. Otherwise null.
reference (required)
string or null
Your own id for this message, as you sent it (an order number, for example).
created_at (required)
string (date-time)
When we accepted the message.
status_updated_at (required)
string or null (date-time)
When the status last changed.
test (required)
boolean
true for a message sent with a test key to a test number. It never reaches a phone.
JSON
{
"id": "8f7e6d5c-4b3a-4291-8f0e-1d2c3b4a5968",
"to": "+447700900321",
"from": "Acme Shop",
"body": "Hi Sam, thanks for your order 10432 at Acme Shop. We are packing it now; you get a track and trace link as soon as it ships.",
"segments": 1,
"encoding": "gsm7",
"status": "queued",
"error": null,
"reference": "order-10432",
"created_at": "2026-10-01T09:00:00.000Z",
"status_updated_at": "2026-10-01T09:00:00.000Z",
"test": true
}
The sender the recipient sees: an alphanumeric name of up to 11 characters.
body (required)
string
The text as it was sent, after typographic quotes and dashes were made SMS-safe.
segments (required)
integer
How many SMS segments the text took. A segment is 160 GSM-7 or 70 UCS-2 characters (153 and 67 when split).
encoding (required)
"gsm7", "ucs2", null
gsm7, or ucs2 when the text has a character outside GSM-7 (an emoji, for example), which makes every segment shorter.
status (required)
"queued", "sent", "delivered", "failed"
queued: accepted. sent: handed to the mobile network. delivered: the phone confirmed it. failed: it did not arrive; see error.
error (required)
string or null
With status failed: a short code, like delivery_failed or sending_failed. Otherwise null.
reference (required)
string or null
Your own id for this message, as you sent it (an order number, for example).
created_at (required)
string (date-time)
When we accepted the message.
status_updated_at (required)
string or null (date-time)
When the status last changed.
test (required)
boolean
true for a message sent with a test key to a test number. It never reaches a phone.
JSON
{
"id": "8f7e6d5c-4b3a-4291-8f0e-1d2c3b4a5968",
"to": "+447700900321",
"from": "Acme Shop",
"body": "Hi Sam, thanks for your order 10432 at Acme Shop. We are packing it now; you get a track and trace link as soon as it ships.",
"segments": 1,
"encoding": "gsm7",
"status": "queued",
"error": null,
"reference": "order-10432",
"created_at": "2026-10-01T09:00:00.000Z",
"status_updated_at": "2026-10-01T09:00:00.000Z",
"test": true
}
Text chat with the same assistant that answers your phone: same instructions, languages, opening hours and website. For a chat in your app or website, called from your backend.
Starts a text chat with the assistant of the project: the same assistant that answers your phone, with the same instructions, languages, opening hours and website (set with PUT /assistant). The response contains the fixed greeting as the first message; send the customer's messages with POST /conversations/{id}/messages. Call this from your backend, never from a browser: the API key is a server-side secret. A website chat goes through your own server, which adds the key, or uses the chat widget with a public chat token (POST /chat-tokens). A live key needs the company verification level (company details, spending cap, payment method, a verified phone and one payment confirmed with your bank: iDEAL or a card with 3D Secure). At most 20 new conversations per minute and 1000 per day per project. With a test key the assistant gives real answers: the first 20 replies per day are free, after that each reply is deducted from the prepaid balance of your live project the same way as live, billed per use (402 test_free_replies_used without balance or saved card). At most 25 test conversations per day. Send an Idempotency-Key so a retry returns the same conversation.
Scope: chat:write
Parameter
In
Type
Idempotency-Key
header
string
Optional but recommended: 1 to 255 visible ASCII characters without spaces. The same key within 24 hours returns the same conversation.
Every field is optional; an empty object starts a conversation.
Field
Type
channel
"chat"
The channel. Only chat for now; it is also the default.
reference
string
Optional: your own id for this conversation, like a session or ticket id. Comes back in the conversation and its webhook.
metadata
object
Optional: up to 20 keys with a string (at most 500 characters), number or boolean, for your own use. Stored and returned as is; the assistant never sees it.
customer
object
Optional: what your app already knows about the customer, for example from their login. The assistant may use it to address the customer and to confirm how to reach them; it is treated as data, never as instructions.
All messages, oldest first, starting with the fixed greeting.
summary (required)
one of
The summary, after the conversation closed. Same shape as the summary of a call.
JSON
{
"id": "2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f",
"channel": "chat",
"status": "closed",
"reference": "session-8841",
"metadata": {
"page": "/pricing",
"plan": "pro"
},
"customer": {
"name": "Sam Jones",
"phone": null,
"email": "sam@example.com"
},
"language": "en",
"turns": 2,
"max_turns": 30,
"created_at": "2026-10-01T09:00:00.000Z",
"last_message_at": "2026-10-01T09:01:05.000Z",
"closed_at": "2026-10-01T09:31:05.000Z",
"close_reason": "inactive",
"has_summary": true,
"summary_ready_at": "2026-10-01T09:31:09.000Z",
"test": false,
"messages": [
{
"id": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8",
"role": "assistant",
"text": "You are chatting with the digital assistant of Acme Plumbing. How can I help you?",
"created_at": "2026-10-01T09:00:00.000Z"
},
{
"id": "5f6a7b8c-9d0e-4f1a-b2c3-d4e5f6a7b8c9",
"role": "customer",
"text": "My kitchen tap is leaking. Can someone come today?",
"created_at": "2026-10-01T09:00:20.000Z"
},
{
"id": "6a7b8c9d-0e1f-4a2b-83c4-e5f6a7b8c9d0",
"role": "assistant",
"text": "I'm sorry about the leak. I can't plan a visit myself, but I'll pass it on: on which number can our team call you back within the hour?",
"created_at": "2026-10-01T09:00:22.000Z"
}
],
"summary": {
"text": "Sam Jones has a leaking kitchen tap and wants someone to come today. Sam left the number +447700900321 for a call back.",
"topics": [
"leak",
"visit"
],
"sentiment": "neutral",
"action_items": [
"Call Sam Jones back on +447700900321 within the hour to plan a visit."
],
"next_step": "Call back within the hour."
}
}
Sends what the customer typed and returns the assistant's reply in the same response. With Accept: text/event-stream the reply streams as Server-Sent Events: delta events with pieces of text, then one done event with the same body as the JSON response (or an error event with a problem+json). The text in done.reply is the reply as stored; display that text. The customer's text is never treated as an instruction to the assistant, and the assistant never shares its instructions or keys. In a chat the assistant cannot book, transfer or send anything: it takes a message, which ends up in the summary. At most 2000 characters per message, 30 turns per conversation (then it closes) and 12 messages per minute per conversation. In live mode, every assistant reply is billed per use, usually about one cent a reply: first from the AI credit in your plan, then from your prepaid balance (the greeting is free; a reply that fails costs nothing). The summary of a conversation is charged the same way when it closes. In test mode the first replies of the day are free, after that they are deducted from the balance of your live project. Send an Idempotency-Key (one per customer message) so a retry returns the same reply instead of a second turn.
Scope: chat:write
Parameter
In
Type
id (required)
path
string (uuid)
The id of the conversation.
Idempotency-Key
header
string
Optional but recommended: 1 to 255 visible ASCII characters without spaces, a new one per customer message. A retry with the same key within 24 hours returns the same reply.
Accept
header
"application/json", "text/event-stream"
text/event-stream streams the reply as Server-Sent Events; anything else returns a single JSON response.
Present when this is the stored response to an earlier request with the same Idempotency-Key.
Field
Type
message (required)
object
The customer's message as stored.
reply (required)
object
The assistant's reply. Show this text to the customer.
conversation (required)
object
The conversation after this turn. status closed means the turn limit was reached.
JSON
{
"message": {
"id": "5f6a7b8c-9d0e-4f1a-b2c3-d4e5f6a7b8c9",
"role": "customer",
"text": "My kitchen tap is leaking. Can someone come today?",
"created_at": "2026-10-01T09:00:20.000Z"
},
"reply": {
"id": "6a7b8c9d-0e1f-4a2b-83c4-e5f6a7b8c9d0",
"role": "assistant",
"text": "I'm sorry about the leak. I can't plan a visit myself, but I'll pass it on: on which number can our team call you back within the hour?",
"created_at": "2026-10-01T09:00:22.000Z"
},
"conversation": {
"id": "2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f",
"channel": "chat",
"status": "open",
"reference": "session-8841",
"metadata": {
"page": "/pricing",
"plan": "pro"
},
"customer": {
"name": "Sam Jones",
"phone": null,
"email": "sam@example.com"
},
"language": "en",
"turns": 1,
"max_turns": 30,
"created_at": "2026-10-01T09:00:00.000Z",
"last_message_at": "2026-10-01T09:00:20.000Z",
"closed_at": null,
"close_reason": null,
"has_summary": false,
"summary_ready_at": null,
"test": false
}
}
Closes the conversation: it accepts no more messages, and the summary follows as conversation.summary.ready and in GET /conversations/{id}. Without a customer message there is no summary. Closing an already closed conversation returns it unchanged. A conversation closes automatically after 30 minutes without messages.
A chat window on your website without your own backend: one script tag with a public chat token. Manage tokens with your API key (/chat-tokens); the widget itself calls the /public routes from the visitor's browser.
The project's public chat tokens for the chat widget, newest first, including revoked ones, each with its embed_code. daily_spend_limit_eur is the most the project's public chat may spend on replies per day (default 5 euro, up to 1000); change it in the portal.
Creates a public chat token and returns it with embed_code, the script tag to paste on the website. The widget then chats with the project's assistant (set with PUT /assistant) directly from the visitor's browser, with no backend of your own. The token is not a secret: it is in the page source. It can only chat, only from allowed_origins (exact origins like https://www.example.com, no path and no wildcard), with a bot check and its own limits, and you can revoke it. Your API key stays on the server. A live token needs at least one https origin; a test key creates a test token (hm_pubtest_) that also works from localhost. At most 10 origins per token, and at most 1000 new conversations per day per token (default 200).
A name for your own overview, like the website it is for.
display_name
string or null
The name the assistant uses for the business in the chat window, like "Knip": in the greeting ("You are chatting with the digital assistant of Knip.") and in its answers. Leave it out or null to use the name of the site the widget is on, and otherwise the project name.
allowed_origins (required)
string[]
The exact origins that may use this token, like https://www.example.com (scheme and host, no path, no wildcard), at most 10. A live token needs at least one https origin. A test token may always be used from localhost.
turnstile
boolean
Ask for a bot check before a conversation starts. Default true.
max_conversations_per_day
integer
New conversations per day through this token. Default 200, at most 1000.
The id of the public chat token. Use it to change or revoke the token.
name (required)
string
Your name for the token, like the website it is for.
display_name (required)
string or null
The name the assistant uses for the business in the chat window and its greeting, or null: then the name of the site the widget is on, and otherwise the project name.
token (required)
string
The public chat token: hm_pub_ (live) or hm_pubtest_ (test project) plus 24 characters. Not a secret: it goes in the page source of your website.
mode (required)
"live", "test"
live or test, from the project the key belongs to.
allowed_origins (required)
string[]
The exact origins that may use the token, like https://www.example.com. A test token also works from localhost.
turnstile (required)
boolean
Whether a visitor passes a bot check before a conversation starts.
max_conversations_per_day (required)
integer
New conversations per day through this token, counted until midnight, Europe/Amsterdam.
revoked (required)
boolean
true once the token is revoked: the widget stops working right away, also in open conversations.
revoked_at (required)
string or null (date-time)
When the token was revoked.
last_used_at (required)
string or null (date-time)
When a browser last used the token, to the minute.
created_at (required)
string (date-time)
When the token was created.
embed_code (required)
string
The script tag for your website, with this token in it. Paste it before
.
kind (required)
"chat", "form"
chat: the chat widget, the cart, the checkout and forms. form: only forms that post to a collection with public_write (made by Hypamoon for a form on a site); it cannot chat.
The id of the public chat token. Use it to change or revoke the token.
name (required)
string
Your name for the token, like the website it is for.
display_name (required)
string or null
The name the assistant uses for the business in the chat window and its greeting, or null: then the name of the site the widget is on, and otherwise the project name.
token (required)
string
The public chat token: hm_pub_ (live) or hm_pubtest_ (test project) plus 24 characters. Not a secret: it goes in the page source of your website.
mode (required)
"live", "test"
live or test, from the project the key belongs to.
allowed_origins (required)
string[]
The exact origins that may use the token, like https://www.example.com. A test token also works from localhost.
turnstile (required)
boolean
Whether a visitor passes a bot check before a conversation starts.
max_conversations_per_day (required)
integer
New conversations per day through this token, counted until midnight, Europe/Amsterdam.
revoked (required)
boolean
true once the token is revoked: the widget stops working right away, also in open conversations.
revoked_at (required)
string or null (date-time)
When the token was revoked.
last_used_at (required)
string or null (date-time)
When a browser last used the token, to the minute.
created_at (required)
string (date-time)
When the token was created.
embed_code (required)
string
The script tag for your website, with this token in it. Paste it before .
kind (required)
"chat", "form"
chat: the chat widget, the cart, the checkout and forms. form: only forms that post to a collection with public_write (made by Hypamoon for a form on a site); it cannot chat.
Changes the name, the display name (the business name in the chat window and its greeting), the allowed origins, the bot check or the daily limit of a token. Fields you leave out stay as they are; allowed_origins replaces the whole list. The token itself stays the same, so the snippet on the website keeps working. A revoked token cannot be changed (409 chat_token_revoked): create a new one.
Change a public chat token. Fields you leave out stay as they are.
Field
Type
name
string
display_name
string or null
The name the assistant uses for the business in the chat window, like "Knip": in the greeting ("You are chatting with the digital assistant of Knip.") and in its answers. null or an empty string goes back to the name of the site the widget is on, and otherwise the project name.
The id of the public chat token. Use it to change or revoke the token.
name (required)
string
Your name for the token, like the website it is for.
display_name (required)
string or null
The name the assistant uses for the business in the chat window and its greeting, or null: then the name of the site the widget is on, and otherwise the project name.
token (required)
string
The public chat token: hm_pub_ (live) or hm_pubtest_ (test project) plus 24 characters. Not a secret: it goes in the page source of your website.
mode (required)
"live", "test"
live or test, from the project the key belongs to.
allowed_origins (required)
string[]
The exact origins that may use the token, like https://www.example.com. A test token also works from localhost.
turnstile (required)
boolean
Whether a visitor passes a bot check before a conversation starts.
max_conversations_per_day (required)
integer
New conversations per day through this token, counted until midnight, Europe/Amsterdam.
revoked (required)
boolean
true once the token is revoked: the widget stops working right away, also in open conversations.
revoked_at (required)
string or null (date-time)
When the token was revoked.
last_used_at (required)
string or null (date-time)
When a browser last used the token, to the minute.
created_at (required)
string (date-time)
When the token was created.
embed_code (required)
string
The script tag for your website, with this token in it. Paste it before .
kind (required)
"chat", "form"
chat: the chat widget, the cart, the checkout and forms. form: only forms that post to a collection with public_write (made by Hypamoon for a form on a site); it cannot chat.
Revokes the token immediately: the widget on the website starts no new conversations, and open conversations through this token accept no more messages. Cannot be undone. Returns the token with revoked: true; revoking it again returns the same.
The id of the public chat token. Use it to change or revoke the token.
name (required)
string
Your name for the token, like the website it is for.
display_name (required)
string or null
The name the assistant uses for the business in the chat window and its greeting, or null: then the name of the site the widget is on, and otherwise the project name.
token (required)
string
The public chat token: hm_pub_ (live) or hm_pubtest_ (test project) plus 24 characters. Not a secret: it goes in the page source of your website.
mode (required)
"live", "test"
live or test, from the project the key belongs to.
allowed_origins (required)
string[]
The exact origins that may use the token, like https://www.example.com. A test token also works from localhost.
turnstile (required)
boolean
Whether a visitor passes a bot check before a conversation starts.
max_conversations_per_day (required)
integer
New conversations per day through this token, counted until midnight, Europe/Amsterdam.
revoked (required)
boolean
true once the token is revoked: the widget stops working right away, also in open conversations.
revoked_at (required)
string or null (date-time)
When the token was revoked.
last_used_at (required)
string or null (date-time)
When a browser last used the token, to the minute.
created_at (required)
string (date-time)
When the token was created.
embed_code (required)
string
The script tag for your website, with this token in it. Paste it before .
kind (required)
"chat", "form"
chat: the chat widget, the cart, the checkout and forms. form: only forms that post to a collection with public_write (made by Hypamoon for a form on a site); it cannot chat.
Called by the chat widget in the visitor's browser, with the public chat token in ?token= and the Origin header of the page. No API key. Returns what the widget shows before a conversation starts: the project name, the greeting, the language and whether a bot check is needed. Every /public route allows CORS only for an allowed origin (with an OPTIONS preflight), and any reason that concerns you rather than the visitor (balance, verification, the daily spending limit) returns 503 chat_unavailable, without details; the real reason is in the portal. All /public requests combined: at most 60 per minute per IP address.
The fixed greeting, in the language the assistant opens in.
language (required)
string
The language the assistant opens in (nl, en, de, fr or es).
languages (required)
string[]
The languages of the assistant.
mode (required)
"live", "test"
live or test, from the token.
turnstile (required)
boolean
Whether POST /public/conversations needs a turnstile_token.
max_message_length (required)
integer
The longest message a visitor can send, in characters.
max_turns (required)
integer
After this many turns the conversation closes.
JSON
{
"name": "Acme Plumbing",
"greeting": "You are chatting with the digital assistant of Acme Plumbing. How can I help you?",
"language": "en",
"languages": [
"en"
],
"mode": "live",
"turnstile": true,
"max_message_length": 2000,
"max_turns": 30
}
Called by the chat widget in the visitor's browser, with the public chat token in ?token= and the Origin header of the page. No API key. Starts a conversation (channel widget) with the assistant and returns the greeting and conversation_token: the secret of this conversation for this visitor, valid for 6 hours. Every later request for it needs that secret as X-Conversation-Token. With turnstile on (the default) send turnstile_token from the bot check; the widget shows that check in a frame on our host and does all of this for you. Limits: 3 new conversations per minute and 20 per hour per IP address, 30 per minute and 300 per hour per token, the token's daily limit (public_chat_limit_reached) and, in live mode, the project's daily spending limit. Your backend can read the conversation with GET /conversations/{id} and receives conversation.summary.ready like any other chat.
A conversation as the widget sees it: only what concerns the visitor. No metadata, customer details or summary; your backend reads those with GET /conversations/{id}.
The secret of this conversation, for this visitor only. Send it as X-Conversation-Token on every next request about this conversation. Keep it in the page (or sessionStorage), never in a URL.
expires_at (required)
string (date-time)
When conversation_token stops working (6 hours after the start).
JSON
{
"conversation": {
"id": "2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f",
"channel": "widget",
"status": "open",
"language": "en",
"turns": 0,
"max_turns": 30,
"created_at": "2026-10-01T09:00:00.000Z",
"last_message_at": "2026-10-01T09:00:00.000Z",
"closed_at": null,
"close_reason": null,
"test": false
},
"messages": [
{
"id": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8",
"role": "assistant",
"text": "You are chatting with the digital assistant of Acme Plumbing. How can I help you?",
"created_at": "2026-10-01T09:00:00.000Z"
}
],
"conversation_token": "v1.2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f.1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b.1790866800.Qm9vZ3N0ZWVuLXZvb3JiZWVsZC1oYW5kdGVrZW5pbmc",
"expires_at": "2026-10-01T15:00:00.000Z"
}
Called by the chat widget to restore the conversation after a page load. Needs the public chat token in ?token=, the Origin header and X-Conversation-Token. Returns only the messages and the state: no metadata, customer details or summary. A conversation from another token, or one started with your API key, is never reachable here.
Parameter
In
Type
id (required)
path
string (uuid)
The id of the conversation.
X-Conversation-Token (required)
header
string
The conversation_token from POST /public/conversations. Only the visitor who started the conversation has it.
A conversation as the widget sees it: only what concerns the visitor. No metadata, customer details or summary; your backend reads those with GET /conversations/{id}.
All messages, oldest first, starting with the greeting.
JSON
{
"conversation": {
"id": "2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f",
"channel": "widget",
"status": "open",
"language": "en",
"turns": 1,
"max_turns": 30,
"created_at": "2026-10-01T09:00:00.000Z",
"last_message_at": "2026-10-01T09:00:22.000Z",
"closed_at": null,
"close_reason": null,
"test": false
},
"messages": [
{
"id": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8",
"role": "assistant",
"text": "You are chatting with the digital assistant of Acme Plumbing. How can I help you?",
"created_at": "2026-10-01T09:00:00.000Z"
},
{
"id": "5f6a7b8c-9d0e-4f1a-b2c3-d4e5f6a7b8c9",
"role": "customer",
"text": "My kitchen tap is leaking. Can someone come today?",
"created_at": "2026-10-01T09:00:20.000Z"
},
{
"id": "6a7b8c9d-0e1f-4a2b-83c4-e5f6a7b8c9d0",
"role": "assistant",
"text": "I'm sorry about the leak. I can't plan a visit myself, but I'll pass it on: on which number can our team call you back within the hour?",
"created_at": "2026-10-01T09:00:22.000Z"
}
]
}
Send a visitor message and get the reply (public).
Called by the chat widget for every message the visitor types. Needs the public chat token in ?token=, the Origin header and X-Conversation-Token. Works like POST /conversations/{id}/messages: the same assistant, the same checks, and with Accept: text/event-stream the same Server-Sent Events (delta, then done or error). Limits: 2000 characters per message, 30 turns per conversation, 6 messages per minute per conversation and 20 per minute per IP address. In live mode, every reply is billed per use from your prepaid balance and counts toward the public chat's daily spending limit (counted at a safe 3 cents a reply); beyond that limit, the request returns public_chat_limit_reached.
Parameter
In
Type
id (required)
path
string (uuid)
The id of the conversation.
X-Conversation-Token (required)
header
string
The conversation_token from POST /public/conversations. Only the visitor who started the conversation has it.
Accept
header
"application/json", "text/event-stream"
text/event-stream streams the reply as Server-Sent Events; anything else returns a single JSON response.
The conversation after this turn. status closed means the turn limit was reached.
JSON
{
"message": {
"id": "5f6a7b8c-9d0e-4f1a-b2c3-d4e5f6a7b8c9",
"role": "customer",
"text": "My kitchen tap is leaking. Can someone come today?",
"created_at": "2026-10-01T09:00:20.000Z"
},
"reply": {
"id": "6a7b8c9d-0e1f-4a2b-83c4-e5f6a7b8c9d0",
"role": "assistant",
"text": "I'm sorry about the leak. I can't plan a visit myself, but I'll pass it on: on which number can our team call you back within the hour?",
"created_at": "2026-10-01T09:00:22.000Z"
},
"conversation": {
"id": "2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f",
"channel": "widget",
"status": "open",
"language": "en",
"turns": 1,
"max_turns": 30,
"created_at": "2026-10-01T09:00:00.000Z",
"last_message_at": "2026-10-01T09:00:20.000Z",
"closed_at": null,
"close_reason": null,
"test": false
}
}
An HTML page with the bot check, which the chat widget shows in a frame; the widget handles this for you. It sends the bot-check token with postMessage only to origin, which must be an allowed origin of the token, and only that origin may frame it. Query: token (the public chat token), origin (the origin of the page with the widget) and lang (en, nl, de, fr or es). An unknown or revoked token returns a plain 404, and an origin that is not allowed returns a plain 403.
Static sites at https://.hypamoon.app: send the files, and the site is live. Free, including in test mode. Pair it with a number and the assistant, or the chat widget. A password or an unlisted address shares a site with a few people; for sign-in per user, add an app with auth to the site.
Creates a static site and publishes it immediately at https://.hypamoon.app. Free, including in test mode. Send every file the browser loads, in full: at least /index.html, plus css, js, images and fonts as separate files or inline. There is no build step, so send the built output, never React or Vite source. Text files (html, css, js, json, txt, xml, svg, webmanifest, md) as plain text. Images (png, jpg, gif, webp, ico), fonts (woff, woff2), PDFs (pdf), video (mp4, webm) and audio (mp3, m4a) preferably as source_url, a public https URL that Hypamoon fetches, and base64-encoded in content only when there is no URL (never for video or audio). The type follows from the extension, and binary files must be that type in their first bytes. A PDF is served inline (Content-Disposition: inline); PDF, video and audio support Range requests (206), and count as one site request per started MB served. A PDF with JavaScript, a /Launch action, a form that submits to another host, or a brand name with a login or payment link is refused (site_blocked). source_url: Hypamoon fetches the file once from a public https URL on port 443 (no IP address or internal host, at most 3 redirects, each checked again; 10 s per file) within the plan's size limits. Its type must match the path: an image or font is recognized by its bytes, text must be UTF-8 and not a web page where a .css or .js was expected. The fetched file then goes through the same checks as one sent with content. A file that cannot be fetched returns 422 with the reason at files..source_url, and nothing changes. At most 100 files with source_url per request and 60 such requests per project per hour (429 rate_limited). A path without an extension also resolves to .html and /index.html, so /about serves /about.html. Your own /404.html is the not-found page. At most 5 new sites a day on the Free plan (no limit on the number of sites). At most 50 files, 1 MB per file (also PDF, video and audio) and 5 MB together. Keep one request under 4 MB: send a larger file through an upload link (POST /sites/uploads) and name it here with sha256 and bytes instead of content. Without slug, one is derived from name. Retrying with the same slug returns 409 slug_taken, never a second site. With version history, send message: what the site is, in one short sentence; it is the first entry in its history. preview is only for PUT /sites/{id}: a new site goes live right away. A static site has no logins or server-side code of its own. To share it with a few people, set password (one shared password, entered once per browser) or make it unlisted; paid access gives each buyer a personal sign-in link. For server-side code and sign-in per user, add an app with auth to the site (POST /apps). Not yet: files over the limits. Every deploy is checked before it goes live: input fields for passwords, PIN codes, card numbers or IBANs, forms that send to another site, hidden iframes, redirects to another site, obfuscated scripts, and login fields next to the name of a bank, government service, parcel or payment service or large platform return 422 site_blocked with the reasons. A new project has a lower limit on deploys per hour and on sites in its first week (deploy_limit_reached, site_limit_reached). With password, every visitor without the password sees a password form on every path (401, not indexed, ten attempts per address per ten minutes), and nothing of the site is cached by the CDN.
The address: https://.hypamoon.app. 3 to 40 characters, lowercase letters, digits and hyphens. Leave it out to derive it from name (with a short suffix if that one is taken).
name (required)
string
A name for your own overview, like the business or the page.
files (required)
object[]
All files of the site: complete, ready for a browser, with at least /index.html. At most 50 files, 1 MB per file (also PDF, video and audio) and 5 MB together.
password
string or null
Optional. One shared password for the whole site (4 to 72 characters): visitors enter it once per browser, like password protection on a preview. Not a login system: no accounts, no per-visitor access. null or an empty string removes it. Never returned; the site shows password_protected instead.
media
string[]
Optional. Files from the project's media library (list_media) to put on the site, without uploading them again: folders like "portfolio/" (with subfolders), file ids, or paths like /media/logo.webp. Each file lands at its library path with its 640 version next to it; private originals never go on a site. A file you also send in files wins.
message
string
Optional, but always send it: what changed and why, in one short sentence (up to 200 characters), like a commit message: "New opening hours for the summer" or "Spring menu, the user asked for it". It shows in the version history; without it Hypamoon writes a short summary like "Changed /index.html".
The files of the site, sorted by path, without their content.
created_at (required)
string (date-time)
When the site was created.
updated_at (required)
string (date-time)
When the site or its files last changed.
version (required)
string
The version that is live now. Changes on every update; every file on the site carries it as its ETag, so you can check that a page shows this version.
password_protected (required)
boolean
true when visitors have to enter the site's password first (set with password on POST /sites or PUT /sites/{id}). The password itself is never returned.
test (required)
boolean
true for a site of a test project. It is public all the same.
taken_offline (required)
object or null
null while the site is live. Set when Hypamoon took the site offline because of abuse: visitors get a page that says it is unavailable, the files are kept, and PUT and DELETE return 403 site_offline. To object, the owner replies to the email or writes to hello@hypamoon.com.
warnings
one of[]
Only in the answer of a deploy, and only when there is something to tell: files a deploy without keep_existing removed, and build hints. Warnings never block a deploy.
For files too large to send inline (one request to the API stays under 4 MB). Send path, sha256 and bytes for each file; the response contains an upload_url for each file. PUT the exact bytes to it within 15 minutes (curl -T file '<upload_url>'); the upload checks size, SHA-256 and the type of the bytes (422 type_mismatch) and stores the file. Video and audio above the inline limit (paid plans: up to the bytes_per_media limit) can only be uploaded this way. Then deploy with POST /sites or PUT /sites/{id}, naming the file with path, sha256 and bytes instead of content. A file another site of this project already has needs no new upload. The size is checked against the limits of the project's plan. At most 50 files, 1 MB per file (also PDF, video and audio) and 5 MB together.
One upload link per file, in the order you sent them.
hint (required)
string
What to do when the PUT to upload_url fails from your environment (a sandbox may block it).
limits (required)
object
The site limits of this project's plan.
JSON
{
"data": [
{
"path": "/img/hero.jpg",
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"bytes": 2483112,
"content_type": "image/jpeg",
"upload_url": "https://hypamoon.app/_hypamoon/upload?t=eyJvIjoi...",
"method": "PUT",
"expires_at": "2026-10-06T10:15:00.000Z"
}
],
"hint": "If your environment cannot reach the upload_url (a sandbox may block it), send images under ~3 MB as base64 in deploy_site content instead, or create a drop link (create_drop_link) so the user uploads the files in a browser. Files that are already online go as source_url in deploy_site (up to 100 per call). For many local files, ask the user to add them at https://app.hypamoon.com/media, then deploy with the same path, sha256 and bytes.",
"limits": {
"files": 200,
"bytes_per_file": 5242880,
"bytes_per_site": 52428800,
"bytes_per_pdf": 10485760,
"bytes_per_media": 10485760
}
}
Create a drop link where the user uploads files in a browser.
For when your environment cannot reach an upload_url (a sandbox in a chat app may block it), or the user has the files and you do not. Returns an upload page, https://app.hypamoon.com/drop/, open for 24 hours or until the user presses Done, for this project (and the site, with site_id). The user opens it without signing in and drags files, a whole folder (folders stay folders under /media//) or a zip, or chooses or takes photos on a phone; up to 100 files per link, within the plan's limits. Photos are made web-ready in the browser: a WebP of at most 2048 px plus a 640 px version, and the original is kept privately next to them (only an original larger than the plan allows per file is left out). An SVG (a logo or icon, at most 1 MB) goes on the site as it is, but only when it is plain drawing: an SVG with scripts, event handlers or links outside the file is refused. With site_id, Done puts the files on the site as a new version (site_version in GET /sites/drops/{id}); send only the HTML with keep_existing. Without site_id, GET /sites/drops/{id} lists the files with path, sha256 and bytes; deploy them with POST /sites or PUT /sites/{id} without content. The url is only in this response; the token is stored as a hash. user_message is a sentence to give the user with the link.
Create a drop link: a page where the user uploads files and whole folders in a browser.
Field
Type
site_id
string (uuid)
Optional. The site the files are for (from deploy_site or list_sites). Leave it out for a new site.
strip
"location", "all", "none"
Optional. What the upload page removes, in the user's browser before upload, from photos (JPEG, PNG, WebP) it keeps as they are: originals the user chooses to keep, and photos that would not get smaller as WebP. location (default) removes only the GPS location and keeps camera, date, orientation and copyright; all removes all metadata (the orientation stays so photos stay upright); none keeps the files as they are. Web versions are re-encoded and carry no metadata at all. Use all or none only when the user asks for it.
folder
string
Optional. The folder under /media/ where the files land, like portfolio or menu/2026. Folders the user drags keep their structure inside it: /media/portfolio/project-12/03.webp. Leave it out to use the user's own folder names.
as_is
boolean
Optional. For moving an existing site (like WordPress uploads): true keeps every file exactly as it is, with no web versions, the same name and extension, and the exact folder path the user drags, under /media//.
The id of the drop link; pass it to get_drop (GET /sites/drops/{id}).
status (required)
"open", "done", "expired"
open: waiting for the user. done: the user uploaded the files. expired: 24 hours passed; create a new one.
site_id (required)
string or null (uuid)
The site the link is for, or null.
created_at (required)
string
expires_at (required)
string
When the link stops working (24 hours after it was made).
completed_at (required)
string or null
When the user finished uploading, or null.
images_only (required)
boolean
True when the link only takes images (an editor made it).
as_is
boolean
True when files are kept exactly as they are (for moving a site): no web versions, same names, and paths that follow the user's folders exactly.
strip (required)
"location", "all", "none"
What the upload page removes from photos before upload: location (the default, only the GPS location), all (all metadata) or none. Web versions are re-encoded and carry no metadata at all.
folder (required)
string or null
The folder under /media/ the files land in, or null for the user's own folder names.
max_files (required)
integer
How many files the user can add with this link (a photo with its versions counts once).
files (required)
object[]
The files that arrived, in order. Private originals are not listed.
originals_kept (required)
integer
How many originals were kept. They are private: never on a site, only downloadable in the portal.
site_version (required)
object or null
With site_id, after the user pressed Done: the new version of the site with these files (waiting: it waits for four-eyes approval). The files are already on the site; send only HTML with keep_existing.
deploy_files (required)
object[]
The same files in the form deploy_site takes: add them to files, without content.
url
string
Only when the link is created: the page to give the user. It is not shown again.
user_message
string
A sentence to give the user.
next_step
string
What to do next.
JSON
{
"id": "3f1c2a9e-5b7d-4e8f-9a0b-1c2d3e4f5a6b",
"status": "open",
"site_id": null,
"created_at": "2026-10-01T09:00:00.000Z",
"expires_at": "2026-10-02T09:00:00.000Z",
"completed_at": null,
"images_only": false,
"strip": "location",
"folder": "portfolio",
"max_files": 100,
"files": [],
"originals_kept": 0,
"site_version": null,
"deploy_files": [],
"url": "https://app.hypamoon.com/drop/hmd_4fT0aQ8xK2mPq7RsV1wYz3Bc5De6Fg9H",
"user_message": "Open this link in your browser and drag your files or a whole folder onto the page (on a phone: choose or take photos). Press Done when everything is in, then come back here and tell me. The link works for 24 hours.",
"next_step": "Give the user the url and wait until they say they are done. Then call get_drop with drop_id 3f1c2a9e-5b7d-4e8f-9a0b-1c2d3e4f5a6b, and deploy_site with its deploy_files added to files (no content; you may change a path, keep sha256 and bytes). Never send the images through the chat."
}
status is open (waiting for the user), done (the user pressed Done) or expired (after 24 hours). files lists what arrived (photos with variant 2048 or 640, width and height; private originals are not listed); deploy_files is the same list in the form POST /sites and PUT /sites/{id} take (path, sha256, bytes, no content). With site_id and status done, site_version is the new version of the site that already has these files: send only the HTML. You may give a file another path in the deploy; keep its sha256 and bytes.
The id of the drop link; pass it to get_drop (GET /sites/drops/{id}).
status (required)
"open", "done", "expired"
open: waiting for the user. done: the user uploaded the files. expired: 24 hours passed; create a new one.
site_id (required)
string or null (uuid)
The site the link is for, or null.
created_at (required)
string
expires_at (required)
string
When the link stops working (24 hours after it was made).
completed_at (required)
string or null
When the user finished uploading, or null.
images_only (required)
boolean
True when the link only takes images (an editor made it).
as_is
boolean
True when files are kept exactly as they are (for moving a site): no web versions, same names, and paths that follow the user's folders exactly.
strip (required)
"location", "all", "none"
What the upload page removes from photos before upload: location (the default, only the GPS location), all (all metadata) or none. Web versions are re-encoded and carry no metadata at all.
folder (required)
string or null
The folder under /media/ the files land in, or null for the user's own folder names.
max_files (required)
integer
How many files the user can add with this link (a photo with its versions counts once).
files (required)
object[]
The files that arrived, in order. Private originals are not listed.
originals_kept (required)
integer
How many originals were kept. They are private: never on a site, only downloadable in the portal.
site_version (required)
object or null
With site_id, after the user pressed Done: the new version of the site with these files (waiting: it waits for four-eyes approval). The files are already on the site; send only HTML with keep_existing.
deploy_files (required)
object[]
The same files in the form deploy_site takes: add them to files, without content.
url
string
Only when the link is created: the page to give the user. It is not shown again.
The files of the site, sorted by path, without their content.
created_at (required)
string (date-time)
When the site was created.
updated_at (required)
string (date-time)
When the site or its files last changed.
version (required)
string
The version that is live now. Changes on every update; every file on the site carries it as its ETag, so you can check that a page shows this version.
password_protected (required)
boolean
true when visitors have to enter the site's password first (set with password on POST /sites or PUT /sites/{id}). The password itself is never returned.
test (required)
boolean
true for a site of a test project. It is public all the same.
taken_offline (required)
object or null
null while the site is live. Set when Hypamoon took the site offline because of abuse: visitors get a page that says it is unavailable, the files are kept, and PUT and DELETE return 403 site_offline. To object, the owner replies to the email or writes to hello@hypamoon.com.
warnings
one of[]
Only in the answer of a deploy, and only when there is something to tell: files a deploy without keep_existing removed, and build hints. Warnings never block a deploy.
Change the files, the name or the password of a site.
Changes the name, the files, the password, or a combination. The slug and the url stay the same. The usual way is a partial update with keep_existing: true: only the files in files are added or replaced, delete_paths removes files, every other file stays, so images already on the site are never sent again. A partial update needs if_match_version, the version from GET; if the site has changed since, or if_match_version is missing, the request returns 409 version_conflict with current_version and nothing changes. Without keep_existing, files is the complete new set of files: a file you leave out is removed, and the response lists those paths in warnings (code files_removed). With version history, send message (what changed and why) so the history reads like a logbook; without it Hypamoon writes a short summary like "Replaced 6 files with 1, -88% size". Guardrail: an update without keep_existing that removes more than half of the files, makes the site more than half smaller, or rewrites more than 70% of /index.html while other files disappear, does not go live. It becomes a preview (preview.url, version_id) and the response has guardrail with what would disappear in plain words; the version that was live gets auto_label "before big change". Ask the user, then publish the preview with POST /sites/{id}/versions/{versionId}/promote, or send the update again with confirm_replace: true. preview: true makes a preview on purpose. Files can come as source_url instead of content, as with POST /sites. source_url: Hypamoon fetches the file once from a public https URL on port 443 (no IP address or internal host, at most 3 redirects, each checked again; 10 s per file) within the plan's size limits. Its type must match the path: an image or font is recognized by its bytes, text must be UTF-8 and not a web page where a .css or .js was expected. The fetched file then goes through the same checks as one sent with content. A file that cannot be fetched returns 422 with the reason at files..source_url, and nothing changes. At most 100 files with source_url per request and 60 such requests per project per hour (429 rate_limited). The limits, the abuse checks and four-eyes approval always apply to the whole site as it will be after the update; a 202 requests approval for that whole site. The new version is live as soon as this request returns, for every visitor and as a whole: all files switch together, never a new page with old css. version in the response is the new one. A static site has no logins or server-side code of its own. To share it with a few people, set password (one shared password, entered once per browser) or make it unlisted; paid access gives each buyer a personal sign-in link. For server-side code and sign-in per user, add an app with auth to the site (POST /apps). Not yet: files over the limits. Every deploy is checked before it goes live: input fields for passwords, PIN codes, card numbers or IBANs, forms that send to another site, hidden iframes, redirects to another site, obfuscated scripts, and login fields next to the name of a bank, government service, parcel or payment service or large platform return 422 site_blocked with the reasons. A new project has a lower limit on deploys per hour and on sites in its first week (deploy_limit_reached, site_limit_reached). Setting or changing password logs every visitor out (they enter the new one once); null or an empty string removes it and makes the site open to everyone. Sending the same password again changes nothing. A site that was open may still come from the CDN for up to a minute after its first password is set; after that nothing of it is cached.
Change a site: a new name, new files, a password (or null to remove it), or a combination. The slug stays the same. The usual way: send only the changed files with keep_existing: true and if_match_version, images preferably as source_url.
Field
Type
name
string
A name for your own overview, like the business or the page.
files
object[]
Usually with keep_existing: true: only the files you add or change; every other file stays as it is, so never send images that are already on the site again. Without keep_existing, files is the complete new set of files: a file you leave out is removed, and the answer lists it in warnings.
keep_existing
boolean
true: a partial update. Only the files in files are added or replaced, delete_paths removes files, and every other file stays. Needs if_match_version. The limits, the checks and four-eyes approval apply to the whole site as it will be after the update.
delete_paths
string[]
Only with keep_existing: true. Paths of existing files to remove, like /old.html. A path that is not on the site is an error.
media
string[]
Optional. Files from the project's media library (list_media) to put on the site, without uploading them again: folders like "portfolio/" (with subfolders), file ids, or paths like /media/logo.webp. Each file lands at its library path with its 640 version next to it; private originals never go on a site. A file you also send in files wins.
if_match_version
string
The version of the site you are changing, from list_sites, get_site or the last deploy (version). Required with keep_existing. If the site changed since, you get 409 version_conflict with current_version and nothing changes: read the site again and redo your change.
password
string or null
Optional. One shared password for the whole site (4 to 72 characters): visitors enter it once per browser, like password protection on a preview. Not a login system: no accounts, no per-visitor access. null or an empty string removes it. Never returned; the site shows password_protected instead.
message
string
Optional, but always send it: what changed and why, in one short sentence (up to 200 characters), like a commit message: "New opening hours for the summer" or "Spring menu, the user asked for it". It shows in the version history; without it Hypamoon writes a short summary like "Changed /index.html".
preview
boolean
true: do not put this change live. It becomes a preview version at a secret link (preview.url in the answer, not indexed), and the live site stays as it is. Put it live later with promote_site_version. Only files (with keep_existing as usual), no name or password. Use it for large changes the user should see first.
confirm_replace
boolean
Only after the user agreed. A deploy without keep_existing that removes more than half of the files, makes the site more than half smaller, or rewrites most of /index.html while other files disappear, is held as a preview instead of going live (guardrail in the answer). Send confirm_replace: true only when the user has said yes to replacing the site like that; then it goes live right away.
The files of the site, sorted by path, without their content.
created_at (required)
string (date-time)
When the site was created.
updated_at (required)
string (date-time)
When the site or its files last changed.
version (required)
string
The version that is live now. Changes on every update; every file on the site carries it as its ETag, so you can check that a page shows this version.
password_protected (required)
boolean
true when visitors have to enter the site's password first (set with password on POST /sites or PUT /sites/{id}). The password itself is never returned.
test (required)
boolean
true for a site of a test project. It is public all the same.
taken_offline (required)
object or null
null while the site is live. Set when Hypamoon took the site offline because of abuse: visitors get a page that says it is unavailable, the files are kept, and PUT and DELETE return 403 site_offline. To object, the owner replies to the email or writes to hello@hypamoon.com.
warnings
one of[]
Only in the answer of a deploy, and only when there is something to tell: files a deploy without keep_existing removed, and build hints. Warnings never block a deploy.
Takes the site offline right away and removes it from the list. Its files and versions are kept for 30 days: POST /sites/{id}/restore puts it back, and GET /sites/deleted lists what can still be restored. After 30 days everything is erased for good. With permanent=true everything is erased right away and the site cannot be restored, also for a site that was already deleted; use it only when the owner explicitly asks for that. The slug stays taken, so an old link never shows someone else's site. Deleting it again returns 204 as well. A site Hypamoon took offline (taken_offline) cannot be deleted: 403 site_offline.
Scope: sites:write
Parameter
In
Type
id (required)
path
string (uuid)
The id of the site.
permanent
query
boolean
true erases the site and its files right away, without the 30 days to restore it. Only when the owner explicitly asks for it.
The sites deleted in the last 30 days that can still be restored with POST /sites/{id}/restore, most recently deleted first, each with restore_until. A site deleted with permanent=true is not listed.
Puts a site that was deleted in the last 30 days back live at its old address, with its files, password, visibility and versions. Not restored: an app that ran on the site (deploy it again) and a custom domain (connect it again); restore_notes says so. The plan's site limit applies, as for a new site. A site that was deleted permanently, or more than 30 days ago, cannot be restored (409).
The files of the site, sorted by path, without their content.
created_at (required)
string (date-time)
When the site was created.
updated_at (required)
string (date-time)
When the site or its files last changed.
version (required)
string
The version that is live now. Changes on every update; every file on the site carries it as its ETag, so you can check that a page shows this version.
password_protected (required)
boolean
true when visitors have to enter the site's password first (set with password on POST /sites or PUT /sites/{id}). The password itself is never returned.
test (required)
boolean
true for a site of a test project. It is public all the same.
taken_offline (required)
object or null
null while the site is live. Set when Hypamoon took the site offline because of abuse: visitors get a page that says it is unavailable, the files are kept, and PUT and DELETE return 403 site_offline. To object, the owner replies to the email or writes to hello@hypamoon.com.
warnings
one of[]
Only in the answer of a deploy, and only when there is something to tell: files a deploy without keep_existing removed, and build hints. Warnings never block a deploy.
visibility
"public", "unlisted"
public or unlisted, as before the site was deleted.
restore_notes (required)
string[]
What did not come back with the site (an app on it, a custom domain), and how to set it up again.
JSON
{
"id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
"slug": "acme-plumbing",
"name": "Acme Plumbing",
"url": "https://acme-plumbing.hypamoon.app",
"files": [
{
"path": "/index.html",
"content_type": "text/html",
"bytes": 272,
"updated_at": "2026-10-01T09:00:00.000Z"
},
{
"path": "/style.css",
"content_type": "text/css",
"bytes": 98,
"updated_at": "2026-10-01T09:00:00.000Z"
}
],
"created_at": "2026-10-01T09:00:00.000Z",
"updated_at": "2026-10-01T09:00:00.000Z",
"version": "k3Q9vXbT2mLc8Rw1",
"password_protected": false,
"test": false,
"taken_offline": null,
"restore_notes": [
"A custom domain was disconnected from the site; connect it again with connect_domain."
]
}
Checks the address the user wants (slug) and suggests up to three available ones based on name. Use it before POST /sites to show the user a suggestion and let them pick or change it. Nothing is reserved. Names that look like a bank, login, payment or support page, a brand, or anything with hypamoon in it are refused.
Scope: sites:read
Parameter
In
Type
name
query
string
The name of the site or business, like Acme Plumbing.
slug
query
string
The address the user would like, like acme-plumbing.
Renames a site (slug) and/or switches it between public and unlisted. After a rename the old address redirects (301) to the new one for 90 days, then stays reserved for this project for 30 more days without redirecting, so nobody can grab it right away; after that it is free again. At most 2 old addresses redirect at a time (a new rename stops the oldest), every redirect goes straight to the current address, and a site that is offline, unlisted or deleted has no redirects. A site's address can change at most 3 times per 30 days, and at most 10 times per project per 30 days: past that the answer is 429 rate_limited with retry_at and a user_message that says when it can change again. A connected domain stays on the site, and public tokens keep working on every redirecting address of the same site. unlisted puts the site on a secret link (url in the response, 26 random characters, no name in it): the readable address and old addresses give 404, pages are sent with noindex and no-referrer. A secret link is not a login: anyone who has it can open the site. For real protection set a password as well (PUT /sites/{id}). A site on a domain cannot be unlisted. Four-eyes approval applies, just as for a deploy.
Change the address of a site, its visibility, or both.
Field
Type
slug
string
The new address: https://.hypamoon.app, 3 to 40 characters, lowercase letters, digits and hyphens. The old address keeps redirecting (301) to the new one and stays reserved for this project, so nobody else can take it. A connected domain stays on the site.
visibility
"public", "unlisted"
public: the site lives at https://.hypamoon.app. unlisted: the site only lives at a secret link (https://.hypamoon.app, 26 random characters, no name in it), the readable address gives 404, and search engines are told not to index it. A secret link is not a login: anyone who has the link can open the site. For real protection set a password as well.
The files of the site, sorted by path, without their content.
created_at (required)
string (date-time)
When the site was created.
updated_at (required)
string (date-time)
When the site or its files last changed.
version (required)
string
The version that is live now. Changes on every update; every file on the site carries it as its ETag, so you can check that a page shows this version.
password_protected (required)
boolean
true when visitors have to enter the site's password first (set with password on POST /sites or PUT /sites/{id}). The password itself is never returned.
test (required)
boolean
true for a site of a test project. It is public all the same.
taken_offline (required)
object or null
null while the site is live. Set when Hypamoon took the site offline because of abuse: visitors get a page that says it is unavailable, the files are kept, and PUT and DELETE return 403 site_offline. To object, the owner replies to the email or writes to hello@hypamoon.com.
warnings
one of[]
Only in the answer of a deploy, and only when there is something to tell: files a deploy without keep_existing removed, and build hints. Warnings never block a deploy.
visibility (required)
"public", "unlisted"
public: the site lives at https://.hypamoon.app. unlisted: url is the secret link (https://.hypamoon.app) and the readable address gives 404.
Gives an unlisted site a new secret link; the old one stops working within seconds, everywhere. Use it when the link reached the wrong people. Takes effect immediately, even when four-eyes approval is on.
The files of the site, sorted by path, without their content.
created_at (required)
string (date-time)
When the site was created.
updated_at (required)
string (date-time)
When the site or its files last changed.
version (required)
string
The version that is live now. Changes on every update; every file on the site carries it as its ETag, so you can check that a page shows this version.
password_protected (required)
boolean
true when visitors have to enter the site's password first (set with password on POST /sites or PUT /sites/{id}). The password itself is never returned.
test (required)
boolean
true for a site of a test project. It is public all the same.
taken_offline (required)
object or null
null while the site is live. Set when Hypamoon took the site offline because of abuse: visitors get a page that says it is unavailable, the files are kept, and PUT and DELETE return 403 site_offline. To object, the owner replies to the email or writes to hello@hypamoon.com.
warnings
one of[]
Only in the answer of a deploy, and only when there is something to tell: files a deploy without keep_existing removed, and build hints. Warnings never block a deploy.
visibility (required)
"public", "unlisted"
public: the site lives at https://.hypamoon.app. unlisted: url is the secret link (https://.hypamoon.app) and the readable address gives 404.
The history of a site, newest first: every deploy that changed its files (including partial deploys, imports, approved deploys, rollbacks and promoted previews) is a version with who made it, through which client, its message and the changed paths. Previews are in the list too (preview: true, with preview_url). Versions older than the plan's retention period are removed, unless they have a label or are a checkpoint (auto_label "before big change"). auto_label also marks versions that stayed live for at least 24 hours and were not rolled back ("stable"). Included on every plan; the plan sets the retention period and the number of labels.
For each path, whether it was added, modified, removed or replaced between two versions of a site. Text files (html, css, js, json, txt, svg) get a unified diff; binary files (images, fonts) only their old and new size. Without to: the current version. Without from: the version before to (for a preview: the live version). Large diffs are left out (diff_omitted) to keep the response small.
Puts the files of an earlier version back live immediately. The rollback is itself a new version (cause rollback), so the history never loses anything and you can roll forward again. The same checks as for a deploy apply (abuse scan, plan limits, four-eyes approval). If the site already has exactly these files, nothing changes and unchanged is true: safe to retry. Send if_match_version to make sure nobody deployed in between. To restore only a few files, use POST /sites/{id}/restore-files.
Which version to put back live: version_id or number.
Field
Type
version_id
string (uuid)
The id of the version to go back to, from list_site_versions.
number
integer
Instead of version_id: the number of the version.
if_match_version
string
Optional. The version of the site you are changing (version from get_site); if the site changed since, you get 409 version_conflict and nothing changes.
message
string
Optional. A short description for the version history, like "Back to the old menu".
JSON
{
"version_id": "0b6c7e2a-1f3d-4e5a-9c8b-7d6e5f4a3b21",
"message": "Back to the old opening hours"
}
Puts only the files in paths back as they were in an earlier version, live immediately; every other file stays as it is now. Use it when one page, script or stylesheet broke and the rest of the site is fine. The result is a new version (cause file_restore) with a message, so it can be undone too. A path that was not in that version is a 422. The same checks as for a deploy apply (abuse scan, plan limits, four-eyes approval). If the files are already like that, nothing changes and unchanged is true.
The id of the version to take the files from, from list_site_versions.
number
integer
Instead of version_id: the number of the version.
paths (required)
string[]
The paths to restore, like ["/app.js", "/style.css"]. Every other file stays as it is now.
if_match_version
string
Optional. The version of the site you are changing (version from get_site); if the site changed since, you get 409 version_conflict and nothing changes.
message
string
Optional. A short description for the version history, like "Back to the old menu".
JSON
{
"number": 4,
"paths": [
"/app.js",
"/style.css"
],
"message": "Back to the working app.js and style.css"
}
Publishes a preview (deploy_site with preview: true) as a new version (cause promote). The same checks as for a deploy apply, including four-eyes approval. Promoting twice changes nothing the second time.
Optional. The version of the site you are changing (version from get_site); if the site changed since, you get 409 version_conflict and nothing changes.
message
string
Optional. A short description for the version history, like "Back to the old menu".
Gives a version a name, like "before launch", unique per site. A labeled version is kept beyond the plan's retention period; the plan sets how many labels a site can have. label null removes it.
What made it: a deploy, a partial deploy (keep_existing), an import, a deploy after four-eyes approval, a rollback, a promoted preview, a preview, a few files restored from an older version (file_restore), or baseline (how the site was when its history started).
message (required)
string or null
The message sent with the deploy, if any.
author (required)
object
Who made it, and through which client.
changes (required)
object
The paths that changed compared to the version before.
files (required)
integer
bytes (required)
integer
current (required)
boolean
true for the version that is live now.
preview (required)
boolean
true for a preview that never went live.
preview_url (required)
string or null
The secret address of a preview (not indexed). Share it only with people who should see it.
label (required)
string or null
labeled_at (required)
string or null
restored_from (required)
string or null (uuid)
For a rollback or promote: the version it came from.
expires_at (required)
string or null
When the retention of the plan removes it; null for the current version, labeled versions and checkpoints.
auto_label (required)
"before big change", "stable", "held by guardrail", null
A marker Hypamoon sets itself, apart from label: "before big change" is the version that was live when a deploy hit the guardrail (kept like a labeled version), "stable" a version that stayed live for at least 24 hours and was not rolled back (both good versions to go back to), and "held by guardrail" a preview the guardrail made instead of putting a deploy live.
Check whether a site is ready for the App Store and Google Play.
Checks a site against what Apple (guideline 4.2: more than a website in a shell; 2.1: complete; 3.1.1: digital goods through in-app purchase) and Google Play expect: native value, a privacy policy URL, a support contact, an icon of the right size, complete content without placeholder pages, no external payment pages for digital goods, and no tracking without permission. Returns ready or not ready for each store, with concrete next steps, and what the user must arrange with Apple and Google themselves (their own developer account, a D-U-N-S number for a company, the 14-day closed test for a new personal Play account). Read-only; changes nothing.
Parts of the project that could not be read; treat them carefully.
next_steps (required)
string[]
JSON
{
"site": {
"id": "8d2c4b1e-3f5a-4c6d-9e7f-1a2b3c4d5e6f",
"name": "Acme Plumbing",
"app_url": "https://acme-plumbing.hypamoon.app",
"test": false
},
"stores": {
"apple": {
"ready": true,
"blocking": [],
"warnings": [
"native_value"
],
"next_steps": [
"Strengthen it with: push notifications: the exported app registers each device with Hypamoon; sending notifications from the rails comes in phase 2; offline: the exported app shows its own offline screen; a service worker on the site makes pages work without a connection; sharing: a share button that calls navigator.share opens the phone's share sheet."
]
},
"google_play": {
"ready": true,
"blocking": [],
"warnings": [],
"next_steps": []
}
},
"checks": [
{
"id": "site_online",
"title": "The site is online",
"status": "pass",
"stores": [
"apple",
"google_play"
],
"detail": "The app loads https://acme-plumbing.hypamoon.app."
},
{
"id": "content_complete",
"title": "Content is complete",
"status": "pass",
"stores": [
"apple",
"google_play"
],
"detail": "3 pages with real content."
},
{
"id": "privacy_policy",
"title": "Privacy policy URL",
"status": "pass",
"stores": [
"apple",
"google_play"
],
"detail": "Found: /privacy.html. Make sure it also covers the app (push tokens, what the forms send)."
},
{
"id": "support_contact",
"title": "Support contact",
"status": "pass",
"stores": [
"apple",
"google_play"
],
"detail": "Found contact page /contact.html, an email address, a phone number. Apple needs a support URL; Google Play needs a contact email."
},
{
"id": "app_icon",
"title": "App icon",
"status": "pass",
"stores": [
"apple",
"google_play"
],
"detail": "/icon-1024.png is 1024x1024: good for every size."
},
{
"id": "native_value",
"title": "More than a website in a shell",
"status": "warn",
"stores": [
"apple"
],
"detail": "Present: forms that send data (contact, booking or sign-up); live chat with the assistant. This helps with guideline 4.2, but Apple decides per review; push notifications and an offline screen make the case stronger.",
"fix": "Strengthen it with: push notifications: the exported app registers each device with Hypamoon; sending notifications from the rails comes in phase 2; offline: the exported app shows its own offline screen; a service worker on the site makes pages work without a connection; sharing: a share button that calls navigator.share opens the phone's share sheet."
},
{
"id": "native_value_play",
"title": "Useful beyond a website",
"status": "pass",
"stores": [
"google_play"
],
"detail": "Present: forms that send data (contact, booking or sign-up); live chat with the assistant."
},
{
"id": "digital_payments",
"title": "Digital goods use the store's billing",
"status": "pass",
"stores": [
"apple",
"google_play"
],
"detail": "No payment links or checkout found."
},
{
"id": "tracking",
"title": "No tracking without permission",
"status": "pass",
"stores": [
"apple",
"google_play"
],
"detail": "No third-party trackers found."
}
],
"native_value": {
"present": [
"forms that send data (contact, booking or sign-up)",
"live chat with the assistant"
],
"can_add": [
"push notifications: the exported app registers each device with Hypamoon; sending notifications from the rails comes in phase 2",
"offline: the exported app shows its own offline screen; a service worker on the site makes pages work without a connection",
"sharing: a share button that calls navigator.share opens the phone's share sheet"
],
"summary": "2 interactive parts. Apple judges guideline 4.2 per app; the more the app does that a browser does not, the better."
},
"accounts": {
"apple": {
"steps": [
"The app is published under the user's own Apple Developer account, never under Hypamoon's (guideline 4.2.6: apps from an app generator must be submitted by the owner of the content).",
"A company (Acme Plumbing B.V.) enrolls as an organization: it needs a D-U-N-S number (requested from Dun & Bradstreet, can take a few days), a legal entity name and a website on the company's domain. The company name is shown as the seller.",
"In App Store Connect the user accepts the agreements and, for paid apps, fills in tax and banking. Only the account holder can do this.",
"Later phases connect through an App Store Connect API key that the user adds in the Hypamoon portal (secret vault), never in this chat."
],
"fee": "99 USD per year (Apple Developer Program)",
"enroll_url": "https://developer.apple.com/programs/enroll/"
},
"google_play": {
"steps": [
"The app is published under the user's own Google Play developer account. Google verifies the identity of the account holder.",
"An organization account (Acme Plumbing B.V.) needs a D-U-N-S number and an organization website; it can publish to production without the closed test.",
"Data safety and the content rating questionnaire are filled in by the user in the Play Console.",
"Later phases connect through a Google Play service account that the user adds in the Hypamoon portal (secret vault), never in this chat."
],
"fee": "25 USD once (Google Play Console registration)",
"enroll_url": "https://play.google.com/console/signup"
}
},
"unknown": [],
"next_steps": [
"Strengthen it with: push notifications: the exported app registers each device with Hypamoon; sending notifications from the rails comes in phase 2; offline: the exported app shows its own offline screen; a service worker on the site makes pages work without a connection; sharing: a share button that calls navigator.share opens the phone's share sheet.",
"Ask the user to set up the developer accounts (see accounts); nobody can do that for them.",
"get_privacy_answers for App Privacy and Data safety.",
"generate_store_listing for the texts and the list of screenshots.",
"export_app_project for the app project."
]
}
A first draft of the App Store and Google Play listing from the site's own text, within each store's limits: name, subtitle, promotional text, description and keywords (Apple), title, short and full description (Google), a category, review notes, and which screenshots and graphics to make per store. In English; source_language says when the site is in another language and the texts need translating. No prices and no purchase links.
Scope: sites:read
Parameter
In
Type
id (required)
path
string (uuid)
The id of the site.
app_name
query
string
The name of the app, if the user chose one. Leave it out to use the site's name.
{
"site": {
"id": "8d2c4b1e-3f5a-4c6d-9e7f-1a2b3c4d5e6f",
"app_url": "https://acme-plumbing.hypamoon.app"
},
"source_language": "en",
"note": "Drafts from the site's own text. Read them with the user and make them sound like the business; never promise features the app does not have.",
"apple": {
"name": "Acme Plumbing",
"subtitle": "Book a visit",
"promotional_text": "Fast, friendly plumbing in Rotterdam. Book a visit online or call us, day and night.",
"description": "Fast, friendly plumbing in Rotterdam. Book a visit online or call us, day and night. Leaking tap, blocked drain or a new boiler: our plumbers are on the road every day across Rotterdam and nearby towns. Send the form and we confirm a time within the hour, or call us for an emergency and we come the same day. Questions? Read our privacy policy, visit the contact page, mail hello@acme-plumbing.example or call +44 7700 900123. What you can do in the app: • Send a request or booking in a few taps • Chat with us and get an answer right away, day and night • Call us with one tap • Get notified when there is news (you choose whether you want notifications) • Clear message when you are offline, and back in one tap Acme Plumbing is the official app of Acme Plumbing B.V..",
"keywords": "call,day,visit,book,rotterdam,across,blocked,boiler,come,confirm,contact,drain,emergency,every",
"primary_category": "Business",
"secondary_category": "Productivity",
"support_url": "https://acme-plumbing.hypamoon.app/contact.html",
"privacy_policy_url": "https://acme-plumbing.hypamoon.app/privacy.html"
},
"google_play": {
"title": "Acme Plumbing",
"short_description": "Fast, friendly plumbing in Rotterdam. Book a visit online or call us, day and…",
"full_description": "Fast, friendly plumbing in Rotterdam. Book a visit online or call us, day and night. Leaking tap, blocked drain or a new boiler: our plumbers are on the road every day across Rotterdam and nearby towns. Send the form and we confirm a time within the hour, or call us for an emergency and we come the same day. Questions? Read our privacy policy, visit the contact page, mail hello@acme-plumbing.example or call +44 7700 900123. What you can do in the app: • Send a request or booking in a few taps • Chat with us and get an answer right away, day and night • Call us with one tap • Get notified when there is news (you choose whether you want notifications) • Clear message when you are offline, and back in one tap Acme Plumbing is the official app of Acme Plumbing B.V..",
"category": "Business",
"contact_email": "hello@acme-plumbing.example",
"privacy_policy_url": "https://acme-plumbing.hypamoon.app/privacy.html"
},
"category_reason": "Nothing specific found; Business fits most company apps. Change it if another category describes the app better.",
"review_notes": "Acme Plumbing is the app of the website https://acme-plumbing.hypamoon.app, published by its owner, Acme Plumbing B.V.. The content is the owner's own.\nNative features: the app asks permission for push notifications and registers the device for updates from the owner; it shows an offline screen with a retry button when there is no connection; forms that send data (contact, booking or sign-up); live chat with the assistant.\nNo account is needed; every feature is available without signing in.\nPhone links open the phone's own dialer; calls go to the owner's business number.\nThe chat is answered by the owner's assistant; it says it is a digital assistant.\nQuestions about the review: use the contact details in App Review Information.",
"screenshots": {
"required": [
{
"store": "apple",
"device": "iPhone 6.9-inch display",
"size": "1320 x 2868 or 1290 x 2796 pixels (portrait)",
"count": "3 to 10",
"required": true
},
{
"store": "apple",
"device": "iPad 13-inch display",
"size": "2064 x 2752 pixels (portrait)",
"count": "3 to 10",
"required": false
},
{
"store": "google_play",
"device": "Phone",
"size": "1080 x 1920 pixels or larger, 9:16 (each side 320 to 3840 pixels)",
"count": "2 to 8 (4 or more recommended)",
"required": true
},
{
"store": "google_play",
"device": "7-inch and 10-inch tablet",
"size": "1200 x 1920 and 1600 x 2560 pixels",
"count": "up to 8 each",
"required": false
}
],
"graphics": [
{
"store": "apple",
"what": "App icon",
"size": "1024 x 1024 PNG without transparency (comes from the app project)",
"required": true
},
{
"store": "google_play",
"what": "App icon",
"size": "512 x 512 PNG, 32-bit",
"required": true
},
{
"store": "google_play",
"what": "Feature graphic",
"size": "1024 x 500 PNG or JPG",
"required": true
}
],
"screens_to_capture": [
"The home screen (first page of the app)",
"Privacy (/privacy.html)",
"Contact (/contact.html)",
"A form filled in, just before sending",
"The chat open with a question and its answer",
"The notification permission prompt or a notification on the lock screen"
],
"ipad_note": "iPad screenshots are only required when the app runs on iPad. The exported project explains how to build it for iPhone only (Xcode: Targets > App > General > Supported Destinations, remove iPad)."
},
"limits": {
"apple": {
"name": 30,
"subtitle": 30,
"promotional_text": 170,
"description": 4000,
"keywords": 100
},
"google_play": {
"title": 30,
"short_description": 80,
"full_description": 4000
}
}
}
Answers for Apple App Privacy and Google Play Data safety.
Derives the answers for App Privacy (Apple) and Data safety (Google) from what the site and the project actually use on Hypamoon: forms and their fields, the chat widget, the bot check, payments and paid access, push registration, sign-in forms, location and third-party scripts on the site. Each answer has its reason and source. Cautious by design: when in doubt, a data type is declared as collected. Includes a click-by-click list for each console, because only the user can fill in both forms there.
The rails and scripts the answers come from: forms, chat_widget, bot_check, payments, paid_access, push, sign_in, location, third_party:.
apple (required)
object
google_play (required)
object
not_collected (required)
object[]
notes (required)
string[]
JSON
{
"site": {
"id": "8d2c4b1e-3f5a-4c6d-9e7f-1a2b3c4d5e6f",
"name": "Acme Plumbing",
"app_url": "https://acme-plumbing.hypamoon.app"
},
"sources": [
"forms",
"chat_widget",
"bot_check",
"push"
],
"apple": {
"data_collected": true,
"used_for_tracking": false,
"data_types": [
{
"category": "Contact Info",
"type": "Name",
"linked_to_user": true,
"used_for_tracking": false,
"purposes": [
"App Functionality"
],
"reason": "A form on the site sends this to the owner's data collection on Hypamoon (and by email to the owner); it is stored with the entry, so it is linked to the person who sent it.",
"source": "forms"
},
{
"category": "Contact Info",
"type": "Email Address",
"linked_to_user": true,
"used_for_tracking": false,
"purposes": [
"App Functionality"
],
"reason": "A form on the site sends this to the owner's data collection on Hypamoon (and by email to the owner); it is stored with the entry, so it is linked to the person who sent it.",
"source": "forms"
},
{
"category": "Contact Info",
"type": "Phone Number",
"linked_to_user": true,
"used_for_tracking": false,
"purposes": [
"App Functionality"
],
"reason": "A form on the site sends this to the owner's data collection on Hypamoon (and by email to the owner); it is stored with the entry, so it is linked to the person who sent it.",
"source": "forms"
},
{
"category": "User Content",
"type": "Other User Content",
"linked_to_user": true,
"used_for_tracking": false,
"purposes": [
"App Functionality"
],
"reason": "A form on the site sends this to the owner's data collection on Hypamoon (and by email to the owner); it is stored with the entry, so it is linked to the person who sent it. The text of chat messages.",
"source": "forms, chat_widget"
},
{
"category": "User Content",
"type": "Customer Support",
"linked_to_user": true,
"used_for_tracking": false,
"purposes": [
"App Functionality"
],
"reason": "Chat messages go to the owner's assistant on Hypamoon and are stored as a conversation the owner can read. People can type their name or contact details in a message, so we treat it as linked.",
"source": "chat_widget"
},
{
"category": "Diagnostics",
"type": "Other Diagnostic Data",
"linked_to_user": false,
"used_for_tracking": false,
"purposes": [
"App Functionality"
],
"reason": "Forms and the chat use a bot check against abuse; it reads signals from the browser for a moment and does not identify the person.",
"source": "bot_check"
},
{
"category": "Identifiers",
"type": "Device ID",
"linked_to_user": false,
"used_for_tracking": false,
"purposes": [
"App Functionality"
],
"reason": "The app registers a push token with Hypamoon so the owner can send notifications. It is stored per site, without a name or account. Apple does not always call a push token a device id; we declare it to be safe.",
"source": "push"
}
],
"console_steps": [
"Open App Store Connect, choose the app, then App Privacy in the sidebar.",
"Next to Privacy Policy, click Edit and paste the privacy policy URL of the site. Save.",
"Under Data Types, click Get Started (or Edit).",
"\"Do you or your third-party partners collect data from this app?\" Answer Yes, we collect data from this app.",
"Tick every data type below, then Save. App Store Connect then asks about each one in turn.",
"Contact Info > Name: tick it, choose the purposes App Functionality, answer \"Is this data linked to the user's identity?\" with Yes, and \"Is this data used for tracking?\" with No.",
"Contact Info > Email Address: tick it, choose the purposes App Functionality, answer \"Is this data linked to the user's identity?\" with Yes, and \"Is this data used for tracking?\" with No.",
"Contact Info > Phone Number: tick it, choose the purposes App Functionality, answer \"Is this data linked to the user's identity?\" with Yes, and \"Is this data used for tracking?\" with No.",
"User Content > Other User Content: tick it, choose the purposes App Functionality, answer \"Is this data linked to the user's identity?\" with Yes, and \"Is this data used for tracking?\" with No.",
"User Content > Customer Support: tick it, choose the purposes App Functionality, answer \"Is this data linked to the user's identity?\" with Yes, and \"Is this data used for tracking?\" with No.",
"Diagnostics > Other Diagnostic Data: tick it, choose the purposes App Functionality, answer \"Is this data linked to the user's identity?\" with No, and \"Is this data used for tracking?\" with No.",
"Identifiers > Device ID: tick it, choose the purposes App Functionality, answer \"Is this data linked to the user's identity?\" with No, and \"Is this data used for tracking?\" with No.",
"Click Publish at the top right. The labels go live with the next version you submit."
]
},
"google_play": {
"collects_or_shares": true,
"encrypted_in_transit": true,
"deletion": "People can ask the owner to delete their data by email to hello@acme-plumbing.example. Google asks for a web link where people can request deletion; point it at the privacy policy or contact page.",
"data_types": [
{
"category": "Personal info",
"type": "Name",
"collected": true,
"shared": false,
"processed_ephemerally": false,
"required": false,
"purposes": [
"App functionality"
],
"reason": "A form on the site sends this to the owner's data collection on Hypamoon (and by email to the owner); it is stored with the entry, so it is linked to the person who sent it.",
"source": "forms"
},
{
"category": "Personal info",
"type": "Email address",
"collected": true,
"shared": false,
"processed_ephemerally": false,
"required": false,
"purposes": [
"App functionality"
],
"reason": "A form on the site sends this to the owner's data collection on Hypamoon (and by email to the owner); it is stored with the entry, so it is linked to the person who sent it.",
"source": "forms"
},
{
"category": "Personal info",
"type": "Phone number",
"collected": true,
"shared": false,
"processed_ephemerally": false,
"required": false,
"purposes": [
"App functionality"
],
"reason": "A form on the site sends this to the owner's data collection on Hypamoon (and by email to the owner); it is stored with the entry, so it is linked to the person who sent it.",
"source": "forms"
},
{
"category": "App activity",
"type": "Other user-generated content",
"collected": true,
"shared": false,
"processed_ephemerally": false,
"required": false,
"purposes": [
"App functionality"
],
"reason": "A form on the site sends this to the owner's data collection on Hypamoon (and by email to the owner); it is stored with the entry, so it is linked to the person who sent it.",
"source": "forms"
},
{
"category": "Messages",
"type": "Other in-app messages",
"collected": true,
"shared": false,
"processed_ephemerally": false,
"required": false,
"purposes": [
"App functionality"
],
"reason": "Chat messages are stored as a conversation the owner can read. An AI model answers them as a service provider for the owner, which Google does not count as sharing.",
"source": "chat_widget"
},
{
"category": "Device or other IDs",
"type": "Device or other IDs",
"collected": true,
"shared": false,
"processed_ephemerally": false,
"required": true,
"purposes": [
"Fraud prevention, security, and compliance",
"App functionality"
],
"reason": "A bot check looks at the browser against abuse when someone sends a form or starts a chat; processed only for that check. The app registers a push token so the owner can send notifications; people can refuse notifications.",
"source": "bot_check, push"
}
],
"console_steps": [
"Open the Play Console, choose the app, then Policy and programs > App content > Data safety, and click Start.",
"\"Does your app collect or share any of the required user data types?\" Answer Yes.",
"\"Is all of the user data collected by your app encrypted in transit?\" Answer Yes (every page and API call uses HTTPS).",
"\"Which of the following methods of account creation does your app support?\" Pick what the site really offers; if people cannot create an account, choose \"My app does not allow users to create an account\".",
"\"Do you provide a way for users to request that their data is deleted?\" Answer Yes, and give the URL of the privacy policy or contact page (people ask by email to hello@acme-plumbing.example).",
"Next. Tick each data type below, then Next again and answer the questions per type.",
"Personal info > Name: tick Collected; \"Is this data processed ephemerally?\" No; \"Is this data required?\" Users can choose; purposes: App functionality.",
"Personal info > Email address: tick Collected; \"Is this data processed ephemerally?\" No; \"Is this data required?\" Users can choose; purposes: App functionality.",
"Personal info > Phone number: tick Collected; \"Is this data processed ephemerally?\" No; \"Is this data required?\" Users can choose; purposes: App functionality.",
"App activity > Other user-generated content: tick Collected; \"Is this data processed ephemerally?\" No; \"Is this data required?\" Users can choose; purposes: App functionality.",
"Messages > Other in-app messages: tick Collected; \"Is this data processed ephemerally?\" No; \"Is this data required?\" Users can choose; purposes: App functionality.",
"Device or other IDs > Device or other IDs: tick Collected; \"Is this data processed ephemerally?\" No; \"Is this data required?\" Required; purposes: Fraud prevention, security, and compliance, App functionality.",
"Check the preview, then Save and submit for review."
]
},
"not_collected": [
{
"what": "Audio and call recordings",
"reason": "Calls start in the phone's own dialer through a tel: link, outside the app. If the app ever records audio itself, this changes."
},
{
"what": "Usage analytics by Hypamoon",
"reason": "The site host counts requests per site for capacity, without IP addresses, cookies or any identifier of the visitor."
},
{
"what": "Location",
"reason": "The site does not ask for the location."
},
{
"what": "Financial info",
"reason": "No checkout, payment links or paid access found."
}
],
"notes": [
"Run this again after every change to the site: new forms, scripts or payments change the answers.",
"These answers are a careful starting point. The user is responsible for them and submits them in the console."
]
}
Builds a Capacitor project that opens the site at its connected domain or hypamoon.app address, with the icon and splash from the site, a proposed app id in reverse domain form, an offline screen and push registration through Hypamoon, plus a README for building in Android Studio and Xcode. Returns a signed download link to a zip, valid for 15 minutes; a new export replaces the previous one. The zip contains no keys, tokens or passwords. Building and submitting in the cloud will come in a later phase.
Reverse domain form, like com.example.app. Leave it out for a proposal from the domain or the site's address. Cannot change after the first upload to a store.
app_name
string
The name under the icon. Leave it out to use the site's name.
icon_path
string
Path of an image on the site to use as the icon, like /icon-1024.png (square PNG, 1024x1024). Leave it out to pick the best icon the site has.
background_color
string
Background of the icon and splash screen as a hex color, like #ffffff. Leave it out to use the site's theme color.
Check what works on a site and what is missing before going live.
Reads the live files of a site, detects its kind (or takes type) and checks what can be checked automatically: terms, privacy, returns and contact pages with company details; forms that post to a real collection (not nowhere, not only mailto, not localStorage); buy buttons and a cart that lead to a real checkout; prices with currency; no placeholder text; no test wording on a live project; internal links that work; a 404 page; no password checked in JavaScript. It also checks findability: a title (10 to 60 characters) and a meta description (50 to 160) per page, exactly one h1, the page language, a share preview (og:title, og:description and an absolute og:image), alt text on images, images over 500 KB, a viewport meta tag and a favicon; and structured data (JSON-LD): existing blocks are checked for valid JSON, their required fields and leftover placeholders, and when there is none and the kind is known it offers a ready snippet to fill with the user's real details. Returns what works, what is missing (each with a plain explanation and the next tool), and what only the owner can confirm (check_with_owner). Advice only: it never blocks a deploy.
What cannot be checked on the site; ask the owner, never require an answer.
pages_checked (required)
integer
note (required)
string
summary_markdown (required)
string
JSON
{
"site": {
"id": "7a1c2e0f-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
"url": "https://bakkerij-jansen.hypamoon.app",
"live": true
},
"type": "webshop",
"type_source": "given",
"region": "eu",
"country": "NL",
"works": [
{
"id": "privacy_page",
"title": "Privacy statement",
"detail": "Found /privacy, linked from the site."
},
{
"id": "contact_details",
"title": "Contact and company details",
"detail": "Found an email address, an address, the KvK number."
},
{
"id": "forms_saved",
"title": "Forms save what visitors send",
"detail": "1 form found, each sent to collection contact."
},
{
"id": "no_fake_login",
"title": "No password checked in JavaScript",
"detail": "No password is checked in JavaScript."
},
{
"id": "no_local_storage_db",
"title": "No visitor data kept in localStorage",
"detail": "localStorage is not used for visitor data."
},
{
"id": "no_test_wording",
"title": "No test wording on the live site",
"detail": "No test wording found on the live site."
},
{
"id": "prices_currency",
"title": "Prices with currency",
"detail": "Prices are shown with their currency."
},
{
"id": "no_placeholders",
"title": "No placeholder text",
"detail": "No placeholder text found."
},
{
"id": "seo_h1",
"title": "One main heading per page",
"detail": "Every page has exactly one h1 heading (all 4 pages checked)."
},
{
"id": "seo_image_alt",
"title": "Alt text on images",
"detail": "The pages have no <img> elements."
},
{
"id": "seo_image_size",
"title": "Image file sizes",
"detail": "The site has no image files of its own."
}
],
"missing": [
{
"id": "terms_page",
"title": "Terms or policy page",
"explanation": "No terms and conditions page was found. Before you go live, you might want to add one and link it in the footer.",
"next_step": {
"tool": "deploy_site",
"how": "Add /terms with keep_existing: true and link it from the footer of every page. Write it with the user; never invent their policies."
}
},
{
"id": "returns_page",
"title": "Returns and withdrawal page",
"explanation": "No returns or withdrawal page was found. Before you go live, you might want to add one and link it in the footer.",
"next_step": {
"tool": "deploy_site",
"how": "Add /returns with the 14-day withdrawal information and the model withdrawal form with keep_existing: true and link it from the footer of every page. Write it with the user; never invent their policies."
}
},
{
"id": "checkout_real",
"title": "Buttons and cart lead somewhere real",
"explanation": "The button \"In winkelwagen\" on /index.html does nothing: it is outside a form and no script handles it. The link \"Bestel nu\" on /producten.html goes nowhere (href #). The site has a cart, but nothing leads to a real checkout or an order request; a visitor can fill the cart and never order.",
"next_step": {
"tool": "get_checkout_snippet",
"how": "Use the real checkout (get_checkout_snippet) or a form that saves (get_form_snippet); if that is not available, change the text so it does not promise it."
},
"paths": [
"/index.html",
"/producten.html"
]
},
{
"id": "internal_links",
"title": "Internal links work",
"explanation": "1 internal link lead to a page that does not exist: /voorwaarden (on /producten).",
"next_step": {
"tool": "deploy_site",
"how": "Add the missing pages or fix the links, with keep_existing: true."
},
"paths": [
"/producten.html"
]
},
{
"id": "not_found_page",
"title": "Not-found page",
"explanation": "The site has no own not-found page; visitors who follow an old link see a plain error.",
"next_step": {
"tool": "deploy_site",
"how": "Add /404.html with a link back home and the contact details, with keep_existing: true."
}
},
{
"id": "seo_title",
"title": "Page titles for search",
"explanation": "3 pages without a good title: /producten (no title), /contact (no title), /privacy (no title). Search engines show the title as the headline of the result. Before you go live, you might want to give every page its own title of 10 to 60 characters.",
"next_step": {
"tool": "deploy_site",
"how": "Add a <title> of 10 to 60 characters to the head of each page, saying what that page is about (like \"Fresh sourdough bread | Bakery name\"), and deploy with keep_existing: true."
},
"paths": [
"/producten.html",
"/contact.html",
"/privacy.html"
]
},
{
"id": "seo_description",
"title": "Meta descriptions for search",
"explanation": "4 pages without a good meta description: / (no description), /producten (no description), /contact (no description), /privacy (no description). Search engines often show it as the text under the headline. Before you go live, you might want to give every page a short description of 50 to 160 characters.",
"next_step": {
"tool": "deploy_site",
"how": "Add <meta name=\"description\" content=\"...\"> of 50 to 160 characters to the head of each page, in the user's own words about that page, and deploy with keep_existing: true."
},
"paths": [
"/index.html",
"/producten.html",
"/contact.html",
"/privacy.html"
]
},
{
"id": "seo_lang",
"title": "Page language set",
"explanation": "3 pages without a language: /producten (no lang), /contact (no lang), /privacy (no lang). The lang attribute helps search engines show the page to the right audience and lets screen readers pronounce it correctly. Before you go live, you might want to add it.",
"next_step": {
"tool": "deploy_site",
"how": "Set <html lang=\"nl\"> (or the language the site is written in, like en or de) on each page and deploy with keep_existing: true."
},
"paths": [
"/producten.html",
"/contact.html",
"/privacy.html"
]
},
{
"id": "seo_open_graph",
"title": "Share preview (Open Graph)",
"explanation": "4 pages without a complete share preview: / (no og:title, og:description, og:image), /producten (no og:title, og:description, og:image), /contact (no og:title, og:description, og:image), /privacy (no og:title, og:description, og:image). When someone shares the link in WhatsApp, LinkedIn or Facebook, these tags decide the title, text and image of the preview. Before you go live, you might want to add og:title, og:description and og:image.",
"next_step": {
"tool": "deploy_site",
"how": "Add <meta property=\"og:title\">, <meta property=\"og:description\"> and <meta property=\"og:image\" content=\"https://...\"> (a full https address to an image of about 1200 by 630 pixels) to the head of each page, and deploy with keep_existing: true."
},
"paths": [
"/index.html",
"/producten.html",
"/contact.html",
"/privacy.html"
]
},
{
"id": "seo_viewport",
"title": "Mobile viewport",
"explanation": "4 pages without a viewport meta tag: / (no viewport), /producten (no viewport), /contact (no viewport), /privacy (no viewport). Without it phones show the page zoomed out and tiny, and search engines treat it as not mobile friendly. Before you go live, you might want to add it.",
"next_step": {
"tool": "deploy_site",
"how": "Add <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> to the head of each page and deploy with keep_existing: true."
},
"paths": [
"/index.html",
"/producten.html",
"/contact.html",
"/privacy.html"
]
},
{
"id": "seo_favicon",
"title": "Favicon",
"explanation": "The site has no favicon, the small icon in the browser tab and next to the site in search results. Sites on hypamoon.app get no default icon, so browsers show an empty one. Before you go live, you might want to add one.",
"next_step": {
"tool": "deploy_site",
"how": "Add /favicon.ico or /favicon.svg (the user's logo, or a simple icon from their initial) and <link rel=\"icon\" href=\"/favicon.svg\"> in the head of each page, with keep_existing: true."
}
},
{
"id": "structured_data",
"title": "Structured data for search",
"explanation": "The site has no structured data (JSON-LD). Before you go live, you might want to add a Product block so search engines can show more than a plain link: Search engines can show the product with its price and whether it is in stock. Use one block per product page, with that product's own details.",
"next_step": {
"tool": "deploy_site",
"how": "Add this block to the head of each product page (one block per product, with that product's details), replace every YOUR ... placeholder with the user's real details (ask them; never invent an address, phone number, price or hours, and leave out optional fields you do not know), then deploy with keep_existing: true: <script type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"Product\",\"name\":\"YOUR PRODUCT NAME\",\"description\":\"YOUR PRODUCT DESCRIPTION\",\"image\":\"YOUR PRODUCT IMAGE URL (absolute, https)\",\"offers\":{\"@type\":\"Offer\",\"price\":\"YOUR PRICE (like 19.95)\",\"priceCurrency\":\"YOUR CURRENCY CODE (like EUR)\",\"availability\":\"https://schema.org/InStock\",\"url\":\"YOUR PRODUCT PAGE URL\"}}</script>"
}
}
],
"check_with_owner": [
{
"id": "vat_number",
"level": "usually_required",
"description": "The VAT number on the site, if the business has one (not every small business or private seller does).",
"why": "Businesses with a VAT number are usually expected to show it on their site.",
"how_on_hypamoon": "Ask the user for these details (never invent them), put them on /contact and in the footer of every page with deploy_site."
},
{
"id": "cookie_notice",
"level": "recommended",
"description": "A cookie notice with a real choice, but only if the site uses non-essential cookies or trackers (analytics with personal data, advertising pixels, embedded videos that set cookies). A site with only functional storage needs no banner.",
"why": "The ePrivacy rules (EU) and PECR (UK) require consent before non-essential cookies are set. A banner that is not needed only gets in the way.",
"how_on_hypamoon": "Hypamoon sites, forms, checkout and the chat widget need no cookie banner by themselves. If the user adds a tracker, add a consent banner that loads it only after a yes."
},
{
"id": "accessibility",
"level": "recommended",
"description": "Basic accessibility: readable contrast, alt text on images, labels on form fields, keyboard navigation, and an accessibility statement for online shops and services.",
"why": "The European Accessibility Act applies to e-commerce and many consumer services since 28 June 2025; micro-enterprises (fewer than 10 staff and at most 2 million euro turnover) are usually exempt for services. Accessible sites also sell better.",
"how_on_hypamoon": "Build the pages with labels, alt text and enough contrast; add /accessibility if the business is not a micro-enterprise."
},
{
"id": "withdrawal_function",
"level": "usually_required",
"description": "An easy way to withdraw online: a clearly labelled withdrawal function (like \"withdraw from contract here\") where the customer confirms their withdrawal, followed by a confirmation.",
"why": "An EU amendment to the Consumer Rights Directive (Directive 2023/2673) asks for an online withdrawal function for contracts made online, applying from 19 June 2026.",
"how_on_hypamoon": "A form on /returns that saves the withdrawal in a collection (create_collection with public_write and email_on_insert, get_form_snippet) and shows a confirmation."
},
{
"id": "shipping_costs_upfront",
"level": "usually_required",
"description": "Shipping costs, delivery restrictions (countries) and the expected delivery time shown before the customer starts ordering.",
"why": "In the EU and UK this information must be clear at the start of the ordering process; everywhere, hidden costs at the end are the main reason people abandon a purchase.",
"how_on_hypamoon": "Show shipping costs on the product pages and next to the order button (deploy_site)."
},
{
"id": "payment_methods_upfront",
"level": "usually_required",
"description": "The accepted payment methods shown at the latest at the start of the ordering process.",
"why": "The Consumer Rights Directive asks for this at the start of the order.",
"how_on_hypamoon": "List the methods of the user's payment account on the product page or in the footer."
},
{
"id": "order_button_text",
"level": "usually_required",
"description": "The final order button says clearly that ordering means paying, like \"Order with obligation to pay\" or in Dutch \"Bestellen en betalen\"; not just \"Next\" or \"Continue\".",
"why": "The EU Consumer Rights Directive and the UK rules require this; without it the customer may not be bound by the order.",
"how_on_hypamoon": "Set the button text on the checkout button (get_checkout_snippet html) or the order request button."
},
{
"id": "order_confirmation",
"level": "usually_required",
"description": "An order confirmation by email (a durable medium) with what was ordered, the price, the company details and the withdrawal information.",
"why": "EU and UK consumer law ask for a confirmation on a durable medium at the latest at delivery; customers everywhere expect one right away.",
"how_on_hypamoon": "Check what the checkout or the payment provider already sends; add one with send_email (Mail module) if needed, or tell the user to send it."
},
{
"id": "legal_guarantee",
"level": "recommended",
"description": "A line about the legal guarantee: products must be as promised, and consumers keep their legal rights when something is wrong.",
"why": "The legal guarantee applies anyway (at least two years in most EU countries; in the Netherlands what the buyer may reasonably expect); saying so avoids disputes.",
"how_on_hypamoon": "Add it to /terms or /returns with deploy_site."
},
{
"id": "complaints",
"level": "recommended",
"description": "How to complain, with an email address and a reasonable answer time, and the dispute body if the business is a member of one (in the Netherlands for example the Geschillencommissie or a trade association).",
"why": "The EU ODR platform closed on 20 July 2025 and the link is no longer needed, but a clear complaint route is still expected, and members of a dispute body must mention it.",
"how_on_hypamoon": "Add it to /terms or /contact with deploy_site."
},
{
"id": "age_check",
"level": "usually_required",
"description": "An age check when selling age-restricted products (alcohol, tobacco and vapes, knives, some games): ask for confirmation online and check the ID at delivery.",
"why": "Selling these to minors is forbidden; in the Netherlands the Alcoholwet requires an age check at the door for alcohol delivered to the home.",
"how_on_hypamoon": "Only if relevant: add an age statement to the order form and tell the user to arrange the check at delivery."
},
{
"id": "vat_registration",
"level": "recommended",
"description": "VAT set up correctly: the right rate in the prices, and the EU One Stop Shop (OSS) when sales to consumers in other EU countries pass 10,000 euro a year.",
"why": "Above that threshold the VAT of the buyer's country applies.",
"how_on_hypamoon": "Nothing to build; mention it to the user so they check it with their accountant."
}
],
"pages_checked": 4,
"note": "Advice only: nothing here blocks a deploy or requires the user to answer anything. Share what is missing in plain words and offer to fix it. This is guidance to help the user prepare, not legal or tax advice. Rules differ per country and change over time; for anything that matters, the user checks with an advisor or the official sources (in the Netherlands for example ACM ConsuWijzer for consumer law, the Belastingdienst for VAT and the Autoriteit Persoonsgegevens for privacy).",
"summary_markdown": "## Readiness of https://bakkerij-jansen.hypamoon.app (webshop)\n**Works (11):** Privacy statement; Contact and company details; Forms save what visitors send; No password checked in JavaScript; No visitor data kept in localStorage; No test wording on the live site; Prices with currency; No placeholder text; One main heading per page; Alt text on images; Image file sizes.\n**Before you go live, you might want to look at (12):**\n- Terms or policy page: No terms and conditions page was found. Before you go live, you might want to add one and link it in the footer. Next: deploy_site.\n- Returns and withdrawal page: No returns or withdrawal page was found. Before you go live, you might want to add one and link it in the footer. Next: deploy_site.\n- Buttons and cart lead somewhere real: The button \"In winkelwagen\" on /index.html does nothing: it is outside a form and no script handles it. The link \"Bestel nu\" on /producten.html goes nowhere (href #). The site has a cart, but nothing leads to a real checkout or an order request; a visitor can fill the cart and never order. Next: get_checkout_snippet.\n- Internal links work: 1 internal link lead to a page that does not exist: /voorwaarden (on /producten). Next: deploy_site.\n- Not-found page: The site has no own not-found page; visitors who follow an old link see a plain error. Next: deploy_site.\n- Page titles for search: 3 pages without a good title: /producten (no title), /contact (no title), /privacy (no title). Search engines show the title as the headline of the result. Before you go live, you might want to give every page its own title of 10 to 60 characters. Next: deploy_site.\n- Meta descriptions for search: 4 pages without a good meta description: / (no description), /producten (no description), /contact (no description), /privacy (no description). Search engines often show it as the text under the headline. Before you go live, you might want to give every page a short description of 50 to 160 characters. Next: deploy_site.\n- Page language set: 3 pages without a language: /producten (no lang), /contact (no lang), /privacy (no lang). The lang attribute helps search engines show the page to the right audience and lets screen readers pronounce it correctly. Before you go live, you might want to add it. Next: deploy_site.\n- Share preview (Open Graph): 4 pages without a complete share preview: / (no og:title, og:description, og:image), /producten (no og:title, og:description, og:image), /contact (no og:title, og:description, og:image), /privacy (no og:title, og:description, og:image). When someone shares the link in WhatsApp, LinkedIn or Facebook, these tags decide the title, text and image of the preview. Before you go live, you might want to add og:title, og:description and og:image. Next: deploy_site.\n- Mobile viewport: 4 pages without a viewport meta tag: / (no viewport), /producten (no viewport), /contact (no viewport), /privacy (no viewport). Without it phones show the page zoomed out and tiny, and search engines treat it as not mobile friendly. Before you go live, you might want to add it. Next: deploy_site.\n- Favicon: The site has no favicon, the small icon in the browser tab and next to the site in search results. Sites on hypamoon.app get no default icon, so browsers show an empty one. Before you go live, you might want to add one. Next: deploy_site.\n- Structured data for search: The site has no structured data (JSON-LD). Before you go live, you might want to add a Product block so search engines can show more than a plain link: Search engines can show the product with its price and whether it is in stock. Use one block per product page, with that product's own details. Next: deploy_site.\n**Check with the owner (12):** vat_number, cookie_notice, accessibility, withdrawal_function, shipping_costs_upfront, payment_methods_upfront, order_button_text, order_confirmation, legal_guarantee, complaints, age_check, vat_registration.\n\n_This is advice, not a gate: it never blocks a deploy. This is guidance to help the user prepare, not legal or tax advice. Rules differ per country and change over time; for anything that matters, the user checks with an advisor or the official sources (in the Netherlands for example ACM ConsuWijzer for consumer law, the Belastingdienst for VAT and the Autoriteit Persoonsgegevens for privacy)._"
}
Proves to Google Search Console that a site or domain belongs to the user, with the token from Search Console (the value alone, google-site-verification=..., or the whole meta tag). method dns adds the TXT record google-site-verification= to a domain connected in this project with nameservers (Search Console property type Domain). method meta adds the meta tag to /index.html as a new site version labelled "search console verification" (property type URL prefix; also on .hypamoon.app). A site that is unlisted, behind a password or paid access, or offline is refused: Google cannot read it. Give site_id or domain. Calling it again with the same token changes nothing. Returns the property, the next steps for the user in Search Console and the sitemap to submit.
added: done now. already_present: it was there already. waiting_for_approval: the project needs a second person to approve the new version first.
site_id (required)
string or null
domain (required)
string or null
property (required)
string
What to enter in Search Console: sc-domain: for a domain property, or the address for a URL prefix property.
record
object
version
integer or null
The new site version with the tag (meta), when versions are on.
sitemap (required)
string
The sitemap to submit in Search Console under Sitemaps; the site serves it automatically.
next_steps (required)
string[]
JSON
{
"method": "dns",
"status": "added",
"site_id": "5b1f3c2a-8d4e-4f6a-9b7c-1d2e3f4a5b6c",
"domain": "bakkerijjansen.nl",
"property": "sc-domain:bakkerijjansen.nl",
"record": {
"type": "TXT",
"name": "bakkerijjansen.nl",
"content": "google-site-verification=Abc123dEf456gHi789jKl012mNo345pQr678sTu901v"
},
"sitemap": "https://bakkerijjansen.nl/sitemap.xml",
"next_steps": [
"In Search Console (https://search.google.com/search-console), add a property of the type Domain for bakkerijjansen.nl (the one you got this token for) and click Verify.",
"Keep the TXT record: removing it ends the verification.",
"After verifying, go to Sitemaps and submit https://bakkerijjansen.nl/sitemap.xml."
]
}
Page views of a site of this project per day, the top pages and where visitors came from (direct, internal, search, social, email, shortener, external). Counted at the edge without cookies; crawlers and scripts are left out. Page views, not unique visitors. Data is kept for 90 days; paths and the bot filter exist since 9 October 2026.
Fetches one path of a site or app of this project from the public internet, through Hypamoon's server, and returns the status, the main headers (never set-cookie), the final URL after redirects and the start of the body as text. The way to prove that a deployed site or app endpoint works live, for a client that cannot open the address itself. Only addresses of this project: the site's own address (also the secret link of an unlisted site) and its connected domains; give path (on the site's address) or a full url on one of them. Redirects are followed up to 3 times, and only to addresses of this project; otherwise redirect_blocked says why and the redirect itself is returned. GET or HEAD only, without cookies or keys, a 10 second timeout per hop, the body cut at 20 KB (truncated), private addresses refused. At most 30 requests per minute per project (429 rate_limited). A site that does not answer gives 502 upstream_error.
Scope: sites:read
Parameter
In
Type
id (required)
path
string (uuid)
The id of the site.
path
query
string
The path on the site's address, like / or /contact or /api/health, with a query if needed. Give this or url.
url
query
string (uri)
A full https URL on an address of this site, like a connected domain. Give this or path.
Fetches an existing static site from a domain this project has proven it owns (active here, or proven with the TXT record) and publishes it as a site, through the same checks as POST /sites. Only the same host, respecting robots.txt, within the site limits; links become relative, and forms that need a server lose their action. The response is the same as for POST /sites (or PUT /sites/{id}), with an import report.
Reads what any visitor can see of a WordPress site (its public content API and home page) and reports pages, posts, media, forms, products, the permalink structure, and what cannot move because it is not public (drafts, form entries, users, orders). Read-only, for any public https address; no login is ever used or accepted. Limited per project and per site.
Imports the published pages and posts of a WordPress site on a domain this project has proven it owns, as clean pages that keep their addresses, with images within the site limits, a /_redirects file for addresses that change, a collection per form (the form on the new site posts to it), products to the product collection where payments are available, and puts posts that do not fit into a private collection. Publishes through the same checks as POST /sites (or PUT /sites/{id}). The response is the same as for POST /sites, with import (the report) and data (the collections).
The https address of the WordPress site, on a domain this project has proven it owns (like https://www.example.com).
site_id
string (uuid)
Optional. An existing site of this project to put the import in. Without it, a new site is created.
keep_existing
boolean
Only with site_id. true: a partial update. The imported files are added or replaced and every other file of the site stays; images already on the site are not fetched again. Needs if_match_version.
if_match_version
string
With keep_existing: the version of the site from get_site or list_sites. If the site changed since, nothing changes (409).
name
string
Optional. A name for the new site; default the site's own name.
slug
string
Optional. The address of a new site, like deploy_site's slug.
max_posts
integer
Optional. Read at most this many posts, newest first (default all, at most 500).
The project's media library: photos and files the user uploads once (in the portal or with an upload page), private until you put them on a site with media in POST /sites or PUT /sites/{id}. No bytes go through a chat.
Every photo and file the user uploaded to the project's media library (in the portal under Media, or with an upload link from POST /media/links), newest first, with the folders. Per file: path under /media/, size, width and height, the 640 version for srcset, alt text, and the sites that use it. A file on no site has url null: it is private until you put it on a site with POST /sites or PUT /sites/{id} and media. Never contains the bytes. folder narrows to a folder (with its subfolders), path to one file, site_id to the files on one site.
Scope: sites:read
Parameter
In
Type
folder
query
string
Only this folder under /media/, with its subfolders, like portfolio.
path
query
string
Only the file with this path, like /media/logo.webp.
Returns an upload page, https://app.hypamoon.com/drop/, open for 24 hours or until the user presses Done. The user drags files or whole folders, or picks photos on a phone; photos are made web-ready in the browser (WebP at most 2048 px plus a 640 px version) and the original is kept privately. Everything lands in the media library under folder; with site_id, Done also puts the files on that site as a new version. Then list them with GET /media and put them on a site with media in POST /sites or PUT /sites/{id}. The url is only in this response.
Optional. The folder under /media/ where the files land, like portfolio or menu/2026. Folders the user drags keep their structure inside it: /media/portfolio/project-12/03.webp. Leave it out to use the user's own folder names.
site_id
string (uuid)
Optional. With a site, Done also puts the files on that site as a new version.
images_only
boolean
Optional. true: the page only takes images.
strip
"location", "all", "none"
Optional. What the upload page removes, in the user's browser before upload, from photos (JPEG, PNG, WebP) it keeps as they are: originals the user chooses to keep, and photos that would not get smaller as WebP. location (default) removes only the GPS location and keeps camera, date, orientation and copyright; all removes all metadata (the orientation stays so photos stay upright); none keeps the files as they are. Web versions are re-encoded and carry no metadata at all. Use all or none only when the user asks for it.
The id of the drop link; pass it to get_drop (GET /sites/drops/{id}).
status (required)
"open", "done", "expired"
open: waiting for the user. done: the user uploaded the files. expired: 24 hours passed; create a new one.
site_id (required)
string or null (uuid)
The site the link is for, or null.
created_at (required)
string
expires_at (required)
string
When the link stops working (24 hours after it was made).
completed_at (required)
string or null
When the user finished uploading, or null.
images_only (required)
boolean
True when the link only takes images (an editor made it).
as_is
boolean
True when files are kept exactly as they are (for moving a site): no web versions, same names, and paths that follow the user's folders exactly.
strip (required)
"location", "all", "none"
What the upload page removes from photos before upload: location (the default, only the GPS location), all (all metadata) or none. Web versions are re-encoded and carry no metadata at all.
folder (required)
string or null
The folder under /media/ the files land in, or null for the user's own folder names.
max_files (required)
integer
How many files the user can add with this link (a photo with its versions counts once).
files (required)
object[]
The files that arrived, in order. Private originals are not listed.
originals_kept (required)
integer
How many originals were kept. They are private: never on a site, only downloadable in the portal.
site_version (required)
object or null
With site_id, after the user pressed Done: the new version of the site with these files (waiting: it waits for four-eyes approval). The files are already on the site; send only HTML with keep_existing.
deploy_files (required)
object[]
The same files in the form deploy_site takes: add them to files, without content.
url
string
Only when the link is created: the page to give the user. It is not shown again.
user_message
string
A sentence to give the user.
next_step
string
What to do next.
JSON
{
"id": "3f1c2a9e-5b7d-4e8f-9a0b-1c2d3e4f5a6b",
"status": "open",
"site_id": null,
"created_at": "2026-10-01T09:00:00.000Z",
"expires_at": "2026-10-02T09:00:00.000Z",
"completed_at": null,
"images_only": false,
"strip": "location",
"folder": "portfolio",
"max_files": 100,
"files": [],
"originals_kept": 0,
"site_version": null,
"deploy_files": [],
"url": "https://app.hypamoon.com/drop/hmd_4fT0aQ8xK2mPq7RsV1wYz3Bc5De6Fg9H",
"user_message": "Open this link in your browser and drag your files or a whole folder onto the page (on a phone: choose or take photos). Press Done when everything is in, then come back here and tell me. The link works for 24 hours.",
"next_step": "Give the user the url and wait until they say they are done. Then call list_media with folder portfolio and put the files on a site with deploy_site media: [\"portfolio/\"]. Never send the images through the chat."
}
Where the file goes on a site when you add it with deploy_site media, like /media/portfolio/project-12/03.webp.
folder (required)
string
The folder under /media/, empty for /media/ itself.
name (required)
string
The file name in the library.
original_name (required)
string or null
The name on the user's computer, when it differs.
content_type (required)
string
bytes (required)
integer
width (required)
integer or null
height (required)
integer or null
alt (required)
string or null
The alt text, set with update_media.
url (required)
string or null
Where the file is served, on the first site that has it at this path. null: it is on no site yet; it is private until you add it with deploy_site media.
variants (required)
object
For photos: 640, the small version for srcset and thumbnails.
srcset (required)
string or null
A ready srcset for an img tag, once both versions are on a site.
original_kept (required)
boolean
The full-size original is kept privately. It never goes on a site; the user downloads it in the portal.
sites (required)
object[]
The sites that use this file right now, with the path and url there.
source (required)
"upload", "portaal", "site", "import"
upload: an upload link; portaal: the portal; site: it was already on a site; import: an import.
name and folder change the path in the library (the extension stays); the 640 version moves with it. A site keeps the file under the path it has until you deploy again. alt sets the alt text; null removes it. 409 conflict when another file already has the new path.
Where the file goes on a site when you add it with deploy_site media, like /media/portfolio/project-12/03.webp.
folder (required)
string
The folder under /media/, empty for /media/ itself.
name (required)
string
The file name in the library.
original_name (required)
string or null
The name on the user's computer, when it differs.
content_type (required)
string
bytes (required)
integer
width (required)
integer or null
height (required)
integer or null
alt (required)
string or null
The alt text, set with update_media.
url (required)
string or null
Where the file is served, on the first site that has it at this path. null: it is on no site yet; it is private until you add it with deploy_site media.
variants (required)
object
For photos: 640, the small version for srcset and thumbnails.
srcset (required)
string or null
A ready srcset for an img tag, once both versions are on a site.
original_kept (required)
boolean
The full-size original is kept privately. It never goes on a site; the user downloads it in the portal.
sites (required)
object[]
The sites that use this file right now, with the path and url there.
source (required)
"upload", "portaal", "site", "import"
upload: an upload link; portaal: the portal; site: it was already on a site; import: an import.
Refused with 409 conflict while a site uses the file (sites lists which, and under which path): remove it from the site first. The stored bytes are removed once nothing refers to them anymore.
The data layer of a project: collections of JSON documents that your agent shapes itself. A contact form on your site, sign-ups, reservations, or facts for the assistant. Free, including in test mode. Forms post to /public/collections/{name}/documents with form.js and the public token.
A collection holds JSON documents: the entries of a contact form, sign-ups, reservations, or facts for the assistant. Free, including in test mode. access decides who may use it besides your API key: private (all false, the default); public_write for a form on your website (with the public token, a bot check and limits); public_read to show the newest documents on a page; assistant_read to give the assistant the newest documents added through the API. triggers: email_on_insert emails every new document to the project's billing email address (live only, at most 50 emails per collection per day; beyond that, the document is still stored and the webhook is still sent), webhook_on_insert sends document.created. schema is an optional JSON Schema that every document must match. At most 20 collections per project on Free, 50 on Online, 100 on Reachable, 200 on Business.
The name of the collection, unique in the project: 2 to 40 characters, a lowercase letter first, then lowercase letters, digits and underscores (contact, signups, menu). A form posts to it by this name.
description
string
What the collection is for, for your own overview.
access
object
Who may use the collection besides your API key. Leave it out for private: all false.
triggers
object
What happens when a document is added, through the API or a form.
email_routing
object
Where the email of email_on_insert goes, instead of the billing email address: at most 5 different addresses in to and rules together. An address must be confirmed before it gets anything: the billing email, the email of a project member and an address on a domain connected to the project are confirmed right away; any other address gets one email with a link to confirm (live projects only). When none of the chosen addresses is confirmed, the email goes to the billing email address. Reply-To stays the email field of the entry.
schema
object
Optional JSON Schema (a subset of draft 2020-12, at most 16 kB) that every document must match, with type "object" at the root. Supported: type, properties, required, additionalProperties, items, minItems, maxItems, enum, const, minLength, maxLength, pattern, format (email, uri, date, date-time, time), minimum, maximum, exclusiveMinimum, exclusiveMaximum, title and description.
Changes the name, description, access, triggers or schema. Fields you leave out stay as they are, including inside access and triggers. A new schema applies to new and changed documents; existing documents are not checked again. A new name breaks a form that posts to the old one.
Change a collection. Fields you leave out stay as they are.
Field
Type
name
string
The name of the collection, unique in the project: 2 to 40 characters, a lowercase letter first, then lowercase letters, digits and underscores (contact, signups, menu). A form posts to it by this name.
description
string or null
What the collection is for, for your own overview.
access
object
Who may use the collection besides your API key. Leave it out for private: all false.
triggers
object
What happens when a document is added, through the API or a form.
email_routing
object or null
Where the email of email_on_insert goes, instead of the billing email address: at most 5 different addresses in to and rules together. An address must be confirmed before it gets anything: the billing email, the email of a project member and an address on a domain connected to the project are confirmed right away; any other address gets one email with a link to confirm (live projects only). When none of the chosen addresses is confirmed, the email goes to the billing email address. Reply-To stays the email field of the entry.
schema
object or null
Optional JSON Schema (a subset of draft 2020-12, at most 16 kB) that every document must match, with type "object" at the root. Supported: type, properties, required, additionalProperties, items, minItems, maxItems, enum, const, minLength, maxLength, pattern, format (email, uri, date, date-time, time), minimum, maximum, exclusiveMinimum, exclusiveMaximum, title and description.
Deletes the collection: its documents can no longer be reached, a form that posts to it receives 404, and the name becomes available again. Deleting it again returns 204 as well.
The documents of a collection, newest first. Follow next_cursor while has_more is true, or pass before to start at a point in time. filter matches top-level fields exactly: filter={"status":"new"} returns only documents whose status is new.
Scope: data:read
Parameter
In
Type
id (required)
path
string
The id of the collection, or its name.
limit
query
integer
Documents per page, default 25. A value above 100 is capped at 100.
cursor
query
string
The next_cursor of the previous page.
before
query
string (date-time)
Only documents added before this moment, in ISO 8601.
filter
query
string
A JSON object of top-level fields and the exact value they must have (string, number, true, false or null), like {"status":"new"}. URL-encode it.
Adds a JSON document of at most 64 kB to the collection; with a schema on the collection, it must match. At most 10000 documents per collection. The collection's triggers fire just as for a form: document.created if webhook_on_insert is on (including in test mode), and an email if email_on_insert is on (not in test mode).
api: added with an API key. public: sent from a form on a website.
created_at (required)
string (date-time)
When the document was added.
updated_at (required)
string (date-time)
When the document last changed.
test (required)
boolean
true for a document of a test project. No email was sent for it.
JSON
{
"id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
"collection_id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
"data": {
"name": "Anna de Vries",
"email": "anna@example.com",
"message": "Can you come and look at a leaking tap on Friday?"
},
"source": "api",
"created_at": "2026-10-01T09:30:00.000Z",
"updated_at": "2026-10-01T09:30:00.000Z",
"test": false
}
api: added with an API key. public: sent from a form on a website.
created_at (required)
string (date-time)
When the document was added.
updated_at (required)
string (date-time)
When the document last changed.
test (required)
boolean
true for a document of a test project. No email was sent for it.
JSON
{
"id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
"collection_id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
"data": {
"name": "Anna de Vries",
"email": "anna@example.com",
"message": "Can you come and look at a leaking tap on Friday?"
},
"source": "public",
"created_at": "2026-10-01T09:30:00.000Z",
"updated_at": "2026-10-01T09:30:00.000Z",
"test": false
}
Send data to replace the whole document, or merge to change some fields (JSON Merge Patch, RFC 7396: a field you send replaces that field, null removes it, an object is merged field by field). Not both. The result must match the schema of the collection. Triggers do not fire on a change.
api: added with an API key. public: sent from a form on a website.
created_at (required)
string (date-time)
When the document was added.
updated_at (required)
string (date-time)
When the document last changed.
test (required)
boolean
true for a document of a test project. No email was sent for it.
JSON
{
"id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
"collection_id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
"data": {
"name": "Anna de Vries",
"email": "anna@example.com",
"message": "Can you come and look at a leaking tap on Friday?",
"status": "answered"
},
"source": "public",
"created_at": "2026-10-01T09:30:00.000Z",
"updated_at": "2026-10-01T09:30:00.000Z",
"test": false
}
The newest documents of a collection with access.public_read, from a page on an allowed origin, with the public token in ?token=. Only data and created_at, newest first, 100 at most. Useful for a list of events, a menu or reviews that you add with your API key.
Called from a form on a website by form.js (); you rarely call it yourself. Needs the project's public token in ?token= (the same token as the chat widget, from POST /chat-tokens), an Origin header in its allowed_origins, a collection with access.public_write, and always a bot token from GET /public/challenge in turnstile_token. Limits: 30 entries per hour per IP address and 500 per day per collection. The response contains the id and created_at, never the data. If the honeypot is filled in, the response is the same but nothing is stored. The collection's triggers fire as they do for the API.
The fields of the form as a JSON object, like { "name": "Anna", "email": "anna@example.com", "message": "..." }.
turnstile_token
string
The bot-check token from the challenge frame (GET /public/challenge). Required; form.js does this for you.
honeypot
string
The value of a hidden field that people leave empty. When it is filled in, the answer is 201 but nothing is stored.
JSON
{
"data": {
"name": "Anna de Vries",
"email": "anna@example.com",
"message": "Can you come and look at a leaking tap on Friday?"
},
"turnstile_token": "0.zXq3...from-the-challenge-frame"
}
Test mode only: runs a simulated call on one of your test numbers, with a transcript and a summary, and sends the real webhooks (call.completed, and call.summary.ready when there is a summary). A simulated caller talks to your own assistant, which answers with the same instructions, knowledge and tools as on a real call; tools are not executed. caller_goal (optional, at most 300 characters) says what the caller wants; without it the caller asks something that fits the scenario (reservation, callback_request, question). The scenario missed is a call nobody picked up: no transcript, no summary. Nothing reaches the carrier. The first 10 simulated calls per day are free; after that each one is deducted from the prepaid balance of your live project, billed per use (402 test_free_replies_used without balance or saved card). Send it with your test key; a live key gets 403 test_key_required. At most 30 simulated calls per hour per test project.
inbound, or outbound for a call your assistant made with POST /calls.
from (required)
string or null
The caller, in E.164.
to (required)
string or null
The number that was called, in E.164.
status (required)
string or null
The status as we track it: ringing, active, completed, missed, failed, voicemail, ai_handled. New values may be added; treat an unknown value like completed.
created_at (required)
string or null (date-time)
When the call reached us.
started_at (required)
string or null (date-time)
When the call started.
answered_at (required)
string or null (date-time)
When the platform picked up. With a call plan the platform always picks up first, so this does not prove a person answered.
ended_at (required)
string or null (date-time)
When the call ended.
duration_sec (required)
integer or null
The length of the call in seconds.
hangup_cause (required)
string or null
Why the call ended, as reported by the carrier, like normal_clearing.
has_summary (required)
boolean
Whether the call has a summary. Fetch it with GET /calls/{id}.
summary_ready_at (required)
string or null (date-time)
When the summary of a recorded call was ready. Empty for a call handled by the voice AI; look at has_summary instead.
test (required)
boolean
true for a simulated call from POST /test/calls or POST /calls with a test key (test mode). Also in webhooks, so you can keep test traffic apart.
outbound (required)
object or null
The request behind an outbound call started with POST /calls.
transcript (required)
string or null
The transcript. Only here, never in a webhook.
summary (required)
one of
The summary of a call.
JSON
{
"id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
"direction": "inbound",
"from": "+447700900456",
"to": "+447700900042",
"status": "ai_handled",
"created_at": "2026-10-01T09:20:00.000Z",
"started_at": "2026-10-01T09:18:52.000Z",
"answered_at": "2026-10-01T09:18:56.000Z",
"ended_at": "2026-10-01T09:20:00.000Z",
"duration_sec": 64,
"hangup_cause": "normal_clearing",
"has_summary": true,
"summary_ready_at": "2026-10-01T09:20:00.000Z",
"test": true,
"outbound": null,
"transcript": "Assistant: You are speaking with the digital assistant of Acme Plumbing. How can I help you?\nCaller: Hello, I have a leak under my kitchen sink and I'd like someone to call me back.\nAssistant: I'm sorry to hear that. I'll make sure someone calls you back. What is your name and address?\nCaller: Tom Baker, 40 Main Street.\nAssistant: Thank you, Tom. Is the water still running, or have you been able to shut it off?\nCaller: I closed the tap under the sink, so it's under control for now.\nAssistant: Good. I've noted a leak under the kitchen sink at 40 Main Street. A colleague will call you back on this number within the hour.",
"summary": {
"text": "Tom Baker has a leak under his kitchen sink at 40 Main Street. He has shut off the water himself and asks to be called back.",
"topics": [
"leak",
"callback request"
],
"sentiment": "neutral",
"action_items": [
"Call Tom Baker back within the hour about the leak at 40 Main Street"
],
"next_step": null
}
}
Data behind the WordPress login, without passwords: the Hypamoon Connect plugin sends an inventory, the user chooses what moves (in the chat, the portal or the plugin), and the plugin sends only that into private collections of the project.
A one-time code of 8 characters (like ABCD-EF23), valid for 15 minutes and bound to this project. The user enters it in the Hypamoon Connect plugin in WordPress (Tools > Hypamoon). The plugin then sends an inventory of the site (counts, periods and field names, never content), and later only the data the user selected: drafts, private posts, custom post types with ACF fields, form entries, WooCommerce products, orders and customers, users. No WordPress password is ever needed.
The WordPress sites connected to this project with the Hypamoon Connect plugin, newest first, each with its status, selection, the collections the selection fills and the last export round.
One connected WordPress site with its inventory: per kind of data (key) the count, the period (oldest and newest), the statuses, the field names, whether it holds personal data, the collection it goes to, and the current choice (selected). Personal data (form entries, orders, customers, users, reviews, subscriptions) is off until the user turns it on. next_step says what to do now.
Per inventory key: on or off, a period (since, until), which statuses for content, and fields to leave out (like phone or ip). Keys you leave out keep their choice. Send version (from the inventory) so you never overwrite a choice the user made meanwhile in WordPress or the portal (409). The plugin shows the same selection and sends only that, in rounds; a later round adds or updates without connecting again.
Per inventory key (like content:post, orders or form_entries:wpforms:12) what moves. Keys you leave out keep their current choice.
version
integer or null
The selection version you showed the user (selection.version). If it changed meanwhile, nothing changes (409). Leave it out to change it anyway.
from_suggestion
boolean or null
true: apply the open suggestion of suggest_wordpress_selection (items, if given, go on top of it).
include_personal_data
boolean or null
Only after the user explicitly said yes to the question this route returns (409 with question): true turns on the kinds of data with personal data in this change; false leaves them off and saves the rest.
confirm_text
string or null
With include_personal_data true: the user's answer to the question, word for word. It is kept in the audit log.
start_round
boolean or null
true: the plugin starts sending this selection the next time it checks in (when the admin opens Tools > Hypamoon, or within a few minutes by WP-cron).
Read the compact inventory and the open suggestion.
The inventory in its smallest form, structure only: per kind of data the group, count, period, a few field names, whether it holds personal data and why, the fields that point to other records (like opdrachtgever_id) and what is selected now. Never values. Also the open suggestion, if there is one.
Stores a suggestion, per inventory key on or off with a short reason, based on what the user wants. It is never applied: the user reviews and applies it in WordPress (Tools > Hypamoon > Move data), or says yes in the chat, after which PUT /selection with from_suggestion applies it. Personal data is marked and never ticked automatically. A new suggestion replaces the open one.
What the user wants, in their words, like: move my hours portal, or only my pages and blog.
items (required)
object
Per inventory key what you suggest: enabled true or false, a reason, and optionally since, until, statuses, omit_fields, include_meta and names (options only by name). Keys you leave out stay as they are.
JSON
{
"goal": "Move my hours portal",
"items": {
"table:uren": {
"enabled": true,
"reason": "The hour history, linked to users via opdrachtgever_id."
},
"content:page": {
"enabled": false,
"reason": "The pages stay on the WordPress site."
}
}
}
What the Hypamoon Connect plugin puts on the connected WordPress site: the chat window (on or off, colour, position, title, welcome) and the forms that post to collections with public_write, each with its fields and its shortcode. public_token is the public token the site uses for both; problems lists what blocks it. Per site, not per connection: a new connection to the same site keeps these settings.
Turns the chat window on or off on the connected WordPress site and chooses the forms the plugin offers (a block and the shortcode [hypamoon_form id=...]). Forms need a collection with public_write; a field named email becomes the Reply-To of the notification email. Makes sure a public token allows the site's origin: the chosen one gets the origin added, or an existing one is used, or a new one is created. The plugin picks the settings up when the admin opens Tools > Hypamoon or presses Refresh. Send version to avoid overwriting a change made meanwhile (409).
Called by the Hypamoon Connect plugin, not by you. Exchanges the one-time code for the plugin's own token (hm_wp_...), bound to this project and this WordPress site. Limited per IP address. The token is valid for 30 days and can be revoked. Answers with the token (shown once), the connection id, the project, and the limits: 200 records and 3 MB per batch, 50000 records and 200 MB per round.
Called by the plugin right after connecting and on Refresh: per kind of data the count, period, statuses and field names (ACF field groups, form fields), never content. Form entries, orders, customers, users, reviews and subscriptions are always marked as personal data. Answers with the selection, with defaults for new keys (content on, personal data off).
Per inventory key (like content:post, orders or form_entries:wpforms:12) what moves. Keys you leave out keep their current choice.
version
integer or null
The selection version you showed the user (selection.version). If it changed meanwhile, nothing changes (409). Leave it out to change it anyway.
from_suggestion
boolean or null
true: apply the open suggestion of suggest_wordpress_selection (items, if given, go on top of it).
include_personal_data
boolean or null
Only after the user explicitly said yes to the question this route returns (409 with question): true turns on the kinds of data with personal data in this change; false leaves them off and saves the rest.
confirm_text
string or null
With include_personal_data true: the user's answer to the question, word for word. It is kept in the audit log.
start_round
boolean or null
true: the plugin starts sending this selection the next time it checks in (when the admin opens Tools > Hypamoon, or within a few minutes by WP-cron).
Called by the plugin when the admin applied and saved the suggestion (outcome=applied) or dismissed it (outcome=dismissed). The suggestion itself comes with GET /wordpress/connection.
Called by the plugin when the admin opens its screen or presses Refresh: the chat window and the forms for this site, with the public token. The plugin stores them; visitors talk to the public widget and form endpoints, never with the plugin's token.
Called by the plugin when the admin turns the chat window on or off, or sets up the forms. Makes sure a public token allows the site. Send the version it showed; a newer one gives 409.
Called by the plugin: a round sends only what is selected. Send the selection version the plugin shows; a newer one gives 409. A running round of the same connection stops.
Called by the plugin: at most 200 records and 3 MB per batch, 50000 records and 200 MB per round, 120 requests per minute. Every record has wp_id; the same record in a later round updates its document. A batch key that was already received changes nothing (duplicate: true). The selection is applied again here: what is not selected, outside the period or in omit_fields is not stored. Passwords, hashes and tokens are always removed.
The MCP server for agents (Claude Code, Cursor, Lovable and other MCP hosts). Stateless Streamable HTTP: one JSON-RPC 2.0 message per POST, answered with JSON; notifications get 202. Authenticate with the same API key as the REST API, or with an OAuth access token (Claude.ai, ChatGPT and other hosts that connect by URL); the scopes of the key or the connection determine which tools are listed. Without valid credentials the response is 401 with a WWW-Authenticate header that points to the OAuth metadata. See https://hypamoon.com/docs/mcp.
Whether Hypamoon is working, per component (sites, API, portal, phone, AI receptionist, email, payments), and any reported problem that is not resolved yet. From the public status page (https://status.hypamoon.com); when that page does not answer, a minimal check of our own. Use it when a step fails and you wonder whether Hypamoon is down: a blocked upload_url or a network error from your own environment is not an outage. user_message is a sentence for the user. Public: no key needed.
Agents that keep working for your project: a trigger, instructions in plain language, a small set of actions on Hypamoon, and limits. Actions that involve money or have an outside effect wait for the owner's approval by default.
{
"data": [
{
"id": "6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7",
"name": "Contact request follow-up",
"instructions": "When a new contact request comes in, email me a short summary (who, what they ask, how to reach them). If they left a phone number, propose a call back: start a call to that number in which the assistant says we received their request and asks when we can help.",
"trigger": {
"type": "document.created",
"collection": "contact_requests",
"collection_id": "0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5",
"phone_field": "phone"
},
"actions": {
"email_owner": {
"mode": "auto"
},
"start_call": {
"mode": "approve"
}
},
"limits": {
"runs_per_day": 20,
"actions_per_run": 3
},
"status": "active",
"mode": "test",
"next_run_at": null,
"pending_approvals": 1,
"created_by": "key:c16a7b81-92a3-44b5-8fc6-f708192a3b4d",
"created_at": "2026-10-05T09:00:00.000Z",
"updated_at": "2026-10-05T09:00:00.000Z"
}
]
}
An agent keeps working for your project after the chat: on a trigger (a new document in a collection, a finished call, a closed chat conversation, or a schedule of at least every 15 minutes) it follows your instructions with a small set of actions on Hypamoon: read or update a document, email the owner, send an SMS, start a call, send a webhook to one of your own endpoints. Default deny: an action that is not turned on is refused. SMS and calls go only to the phone number of the event (trigger.phone_field of the document, the other side of the call, or the customer of the chat), at most once per number per day, never to the project's own numbers; they wait for the owner's approval, and always do so when the trigger can come from a stranger (a public form, a call, a chat). In test mode they are simulated; in live mode they need a verified project. Limits: runs per day (default 20) and actions per run (default 3). At most 10 agents per project. Every run is logged with its trigger, a short thought, each action and the model cost. The key needs the scope of every action it turns on (calls:write for start_call, messages:write for send_sms, data:write for update_document, webhooks:write for send_webhook) and of the trigger.
What the agent should do, in plain language, like: Send the owner a short summary of every new contact request and propose a call back.
trigger (required)
one of
When the agent runs: a new document in a collection, a finished call, a closed chat conversation (once its summary is ready), or a schedule (every_minutes, at least 15).
actions (required)
object
The actions this agent may take. Anything not listed is denied.
limits
object
The limits of the agent.
status
"active", "paused"
active (default) or paused.
JSON
{
"name": "Contact request follow-up",
"instructions": "When a new contact request comes in, email me a short summary (who, what they ask, how to reach them). If they left a phone number, propose a call back: start a call to that number in which the assistant says we received their request and asks when we can help.",
"trigger": {
"type": "document.created",
"collection": "contact_requests",
"phone_field": "phone"
},
"actions": {
"email_owner": {
"mode": "auto"
},
"start_call": {
"mode": "approve"
}
},
"limits": {
"runs_per_day": 20,
"actions_per_run": 3
}
}
The key (key:) or connected app (oauth:) that created the agent.
created_at (required)
string
updated_at (required)
string
JSON
{
"id": "6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7",
"name": "Contact request follow-up",
"instructions": "When a new contact request comes in, email me a short summary (who, what they ask, how to reach them). If they left a phone number, propose a call back: start a call to that number in which the assistant says we received their request and asks when we can help.",
"trigger": {
"type": "document.created",
"collection": "contact_requests",
"collection_id": "0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5",
"phone_field": "phone"
},
"actions": {
"email_owner": {
"mode": "auto"
},
"start_call": {
"mode": "approve"
}
},
"limits": {
"runs_per_day": 20,
"actions_per_run": 3
},
"status": "active",
"mode": "test",
"next_run_at": null,
"pending_approvals": 0,
"created_by": "key:c16a7b81-92a3-44b5-8fc6-f708192a3b4d",
"created_at": "2026-10-05T09:00:00.000Z",
"updated_at": "2026-10-05T09:00:00.000Z"
}
The key (key:) or connected app (oauth:) that created the agent.
created_at (required)
string
updated_at (required)
string
recent_runs (required)
object[]
JSON
{
"id": "6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7",
"name": "Contact request follow-up",
"instructions": "When a new contact request comes in, email me a short summary (who, what they ask, how to reach them). If they left a phone number, propose a call back: start a call to that number in which the assistant says we received their request and asks when we can help.",
"trigger": {
"type": "document.created",
"collection": "contact_requests",
"collection_id": "0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5",
"phone_field": "phone"
},
"actions": {
"email_owner": {
"mode": "auto"
},
"start_call": {
"mode": "approve"
}
},
"limits": {
"runs_per_day": 20,
"actions_per_run": 3
},
"status": "active",
"mode": "test",
"next_run_at": null,
"pending_approvals": 1,
"created_by": "key:c16a7b81-92a3-44b5-8fc6-f708192a3b4d",
"created_at": "2026-10-05T09:00:00.000Z",
"updated_at": "2026-10-05T09:00:00.000Z",
"recent_runs": [
{
"id": "7a2e3c4d-5b6f-4071-8b82-a3c4d5e6f708",
"agent_id": "6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7",
"status": "done",
"trigger": {
"type": "document.created",
"id": "9c40e5f6-7d81-4293-ad04-c5e6f708192a",
"scheduled_for": null
},
"thought": "A new contact request from Sam about a kitchen quote: I email the owner a summary and propose a call back.",
"outcome": "email_owner simulated; start_call waiting for approval. The owner has the summary; the call waits for approval.",
"error": null,
"model_turns": 2,
"input_tokens": 4210,
"output_tokens": 312,
"created_at": "2026-10-05T09:01:00.000Z",
"started_at": "2026-10-05T09:01:02.000Z",
"finished_at": "2026-10-05T09:01:06.000Z",
"actions": [
{
"id": "a94f5e6f-7081-4293-8da4-d5e6f708192b",
"run_id": "7a2e3c4d-5b6f-4071-8b82-a3c4d5e6f708",
"agent_id": "6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7",
"type": "email_owner",
"input": {
"subject": "New contact request: Sam, kitchen quote",
"body": "Sam asks for a quote for a new kitchen. Phone +447700900123."
},
"status": "simulated",
"reason": null,
"result": {
"to": "owner",
"subject": "New contact request: Sam, kitchen quote"
},
"decided_by": null,
"decided_at": null,
"expires_at": null,
"created_at": "2026-10-05T09:01:04.000Z",
"executed_at": "2026-10-05T09:01:04.000Z"
},
{
"id": "8b3f4d5e-6c70-4182-9c93-b4d5e6f70819",
"run_id": "7a2e3c4d-5b6f-4071-8b82-a3c4d5e6f708",
"agent_id": "6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7",
"type": "start_call",
"input": {
"to": "+447700900123",
"assistant_instructions": "Call Sam back: we received the contact request about a quote for a new kitchen and ask when we can help."
},
"status": "pending_approval",
"reason": null,
"result": null,
"decided_by": null,
"decided_at": null,
"expires_at": "2026-10-08T09:01:05.000Z",
"created_at": "2026-10-05T09:01:05.000Z",
"executed_at": null
}
]
}
]
}
Changes the name, instructions, trigger, actions (the whole set: anything not listed is denied), limits or status. Fields you leave out stay as they are. status paused stops new runs; active starts them again.
Changes to an agent. Fields you leave out stay as they are.
Field
Type
name
string
A short name, like New contact request follow-up.
instructions
string
What the agent should do, in plain language, like: Send the owner a short summary of every new contact request and propose a call back.
trigger
one of
When the agent runs: a new document in a collection, a finished call, a closed chat conversation (once its summary is ready), or a schedule (every_minutes, at least 15).
actions
object
The actions this agent may take. Anything not listed is denied.
The key (key:) or connected app (oauth:) that created the agent.
created_at (required)
string
updated_at (required)
string
JSON
{
"id": "6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7",
"name": "Contact request follow-up",
"instructions": "When a new contact request comes in, email me a short summary (who, what they ask, how to reach them). If they left a phone number, propose a call back: start a call to that number in which the assistant says we received their request and asks when we can help.",
"trigger": {
"type": "document.created",
"collection": "contact_requests",
"collection_id": "0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5",
"phone_field": "phone"
},
"actions": {
"email_owner": {
"mode": "auto"
},
"start_call": {
"mode": "approve"
}
},
"limits": {
"runs_per_day": 20,
"actions_per_run": 3
},
"status": "active",
"mode": "test",
"next_run_at": null,
"pending_approvals": 1,
"created_by": "key:c16a7b81-92a3-44b5-8fc6-f708192a3b4d",
"created_at": "2026-10-05T09:00:00.000Z",
"updated_at": "2026-10-05T09:00:00.000Z"
}
Stops the agent permanently: no new runs, actions waiting for approval expire, and the agent's own key is revoked. Its log stays readable until the project is deleted. Deleting it again returns 204 as well.
The log of an agent, newest first: for each run the trigger, what the agent thought, each action with its status (including refused and pending ones), the outcome and the tokens used. Thoughts, outcomes and action input are kept for 90 days. Paginate with before (the created_at of the last run you have).
The owner's decision on an action awaiting approval (status pending_approval). approve runs it now, through the same checks as any other request (verification, balance, test mode); reject drops it. An SMS or a call can only be approved in the portal (403 forbidden here); rejecting does work here. Approving also needs the action's scope. A paused agent's actions can only be rejected. Repeating the same decision returns the action unchanged; a different decision on an action that was already decided or expired returns 409. Every decision is recorded in the project's audit log.
running, done, simulated (test mode), pending_approval (waiting for the owner), rejected (by the owner), refused (by the limits or the rules, see reason), failed (see reason), expired (no decision within 72 hours).
reason (required)
string or null
result (required)
object or null
decided_by (required)
string or null
decided_at (required)
string or null
expires_at (required)
string or null
When a pending action expires.
created_at (required)
string
executed_at (required)
string or null
JSON
{
"id": "8b3f4d5e-6c70-4182-9c93-b4d5e6f70819",
"run_id": "7a2e3c4d-5b6f-4071-8b82-a3c4d5e6f708",
"agent_id": "6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7",
"type": "start_call",
"input": {
"to": "+447700900123",
"assistant_instructions": "Call Sam back: we received the contact request about a quote for a new kitchen and ask when we can help."
},
"status": "simulated",
"reason": null,
"result": {
"call_id": "b05f6a70-8192-43a4-9eb5-e6f708192a3c",
"status": "completed",
"to": "+447700900123"
},
"decided_by": "user:d27b8c92-a3b4-45c6-90d7-08192a3b4c5e",
"decided_at": "2026-10-05T09:10:00.000Z",
"expires_at": null,
"created_at": "2026-10-05T09:01:05.000Z",
"executed_at": "2026-10-05T09:10:01.000Z"
}
Server code on your site: /api on the same address runs your code, isolated, with allowed hosts, secrets from the vault, the project's data and schedules.
An app is a site with server code: requests to /api and everything below it on the site go to your code, all other paths keep serving the site's static files. Same address, no CORS; the site's password, paid access and own domain apply to /api too. The code is one bundled ES module (plus optional extra modules) in the standard module worker format (export default { fetch, scheduled }); no build step runs here. fetch() only reaches allowed_hosts and the Hypamoon API (the project's own data, with a key Hypamoon adds). To use a secret from the vault, send the header x-hypamoon-secret: ; the code never sees its value. Each app runs isolated, with limits per request (CPU time, fetch calls) and an outbound budget per minute and per day. The first deploy for a site creates the app (201); a deploy for a site that already has one publishes a new version (200). On a new version, every setting you leave out (allowed_hosts, secrets, schedules, vars, auth, database, fetch_pages) keeps its current value, and the answer lists them in kept. Clear one explicitly: [] or {} for a list or vars, null for auth, false for fetch_pages. database: false on an app that has a database is refused (409): a deploy never disconnects a database. The code works the same way: leave main_module and modules out to keep the running code (to change only settings, or to move a test app to live), or send only the changed modules with keep_existing_modules: true. A new app always needs main_module and modules. The ten newest versions are kept for rollback. Before anything goes live the code is checked for mining, hidden or downloaded code and fake login pages; there is no option to skip that check. A live app needs a verified project. An app that runs in test mode moves to live when you deploy it with a live key once the project is verified: it keeps its database and its users. A live app cannot go back to test (key_mode_mismatch).
The site the app belongs to (from deploy_site or list_sites). Requests to /api and everything below it on that site go to the app; all other paths stay the site's static files.
name
string
A name for your own overview. Defaults to the site's name.
main_module
string
The module with export default { async fetch(request, env, ctx) { ... } } (and optionally scheduled). Usually index.js.
modules
object[]
The bundled server code: the main module plus at most 19 more (ES modules, JSON or text). No WebAssembly, no npm install: bundle dependencies into the module.
allowed_hosts
string[]
Hosts the app may call with fetch(): exact names like api.example.com, or *.example.com for its subdomains. Everything else is blocked. At most 20. The Hypamoon API (for the project's data) is always allowed.
secrets
string[]
Names of secrets from the project's vault (request_secret) the app may use. The code never sees a value: it sends the header x-hypamoon-secret: and Hypamoon puts the secret in the request, only to the hosts of that secret. At most 10.
schedules
object[]
When the app's scheduled(event, env, ctx) handler runs. At most 3.
vars
object
Plain settings in env, like {"CITY": "Delft"}. Never a key or password: use secrets. At most 20, names in UPPER_CASE.
auth
object or null
Sign-in for the people who use the app. They sign in on the site itself (/_hypamoon/auth/login) with a link by email; Hypamoon keeps only their email address and name. Hypamoon checks the sign-in before a request reaches the code and hands the person over ready to use: env.HYPAMOON.user is { id, email, name, roles, entitlements, groups, is(role), has(entitlement) } or null, env.HYPAMOON.requireUser(request) gives the user or a ready 401 JSON Response, and env.HYPAMOON.requireRole(request, role) the user or a ready 401 or 403 (if (user instanceof Response) return user). Never verify the signature of the x-hypamoon-user headers yourself and never use HYPAMOON_AUTH_SECRET in the code; only forward those two headers unchanged to env.HYPAMOON_API (/app-files, /app-users). The first role in roles is the admin role everywhere.
database
boolean
true gives the app its own SQL database (SQLite) as env.DB: env.DB.prepare(sql).bind(...).all(). Create and change tables with apply_app_migration. The database stays when you deploy a version without it; delete_app removes it.
fetch_pages
boolean
true turns on env.HYPAMOON.fetchPage(url) for this app (off by default; needs auth). It fetches one public web page for the signed-in user, for example to import a recipe or read a product page from any site, without listing that site in allowed_hosts: const page = await env.HYPAMOON.fetchPage(url) gives { url, status, contentType, body, truncated }, with url the final address after redirects. Only GET, http and https, text, HTML or JSON (also JSON-LD), at most 2 MB (truncated: true when cut off) and 10 seconds, at most 3 redirects. Private addresses and Hypamoon hosts are blocked. No cookies or x-hypamoon headers are sent. Each signed-in user can fetch 20 pages per hour, and the fetches count against the app's outbound budget. A refusal throws an Error with .status: 401 not signed in, 403 blocked address, 415 not text, 429 limit reached, 501 off for this app, 502 or 504 the page failed or was too slow. On an update, leave it out to keep the current setting; false turns it off.
keep_existing_modules
boolean
On an update: true keeps every running module you do not send, and replaces or adds only the modules you send (like keep_existing on deploy_site).
JSON
{
"site_id": "5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d",
"name": "Contact form",
"main_module": "index.js",
"modules": [
{
"name": "index.js",
"content": "export default {\n async fetch(request, env) {\n const url = new URL(request.url);\n if (url.pathname === \"/api/contact\" && request.method === \"POST\") {\n const form = await request.formData();\n const city = String(form.get(\"city\") || \"Delft\");\n // The secret weather_key is added by Hypamoon; this code never sees it.\n const weather = await fetch(\"https://api.weatherapi.com/v1/current.json?q=\" + encodeURIComponent(city), {\n headers: { \"x-hypamoon-secret\": \"weather_key\" },\n }).then((r) => r.json());\n // The project's data: Hypamoon adds the app's own key.\n await fetch(env.HYPAMOON_API + \"/collections/contact_requests/documents\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ data: { name: form.get(\"name\"), email: form.get(\"email\"), city } }),\n });\n return Response.json({ ok: true, temperature_c: weather.current?.temp_c ?? null });\n }\n return new Response(\"Not found\", { status: 404 });\n },\n async scheduled(event, env) {\n // Runs on the schedule, for example a daily summary.\n },\n};\n"
}
],
"allowed_hosts": [
"api.weatherapi.com"
],
"secrets": [
"weather_key"
],
"schedules": [
{
"cron": "0 7 * * *"
}
],
"vars": {
"DEFAULT_CITY": "Delft"
}
}
offline: Hypamoon took the site offline; the app does not run.
version (required)
integer
The version that runs now.
allowed_hosts (required)
string[]
secrets (required)
string[]
schedules (required)
object[]
limits (required)
object
Per request: CPU time and fetch calls. Per app: outbound requests per minute and per day.
auth
object or null
Sign-in for the app's users; null when it is off. login_url is where people sign in.
database
object or null
The app's own database (env.DB); null when it has none. full: above the plan's data limit, so the app can only read and delete until there is room.
kept
string[]
Only in the answer to a deploy of an existing app: the settings that were left out and kept from the running version, like "allowed_hosts (20)", "vars (2)", "database", "auth".
warnings
string[]
Only in the answer to a deploy: advice about the app's data, never a block. For example that export and delete per user won't cover the data (no table marked with set_table_owner_column), or tables that look like backups or copies. Tell the user in plain words and offer to fix it.
offline: Hypamoon took the site offline; the app does not run.
version (required)
integer
The version that runs now.
allowed_hosts (required)
string[]
secrets (required)
string[]
schedules (required)
object[]
limits (required)
object
Per request: CPU time and fetch calls. Per app: outbound requests per minute and per day.
auth
object or null
Sign-in for the app's users; null when it is off. login_url is where people sign in.
database
object or null
The app's own database (env.DB); null when it has none. full: above the plan's data limit, so the app can only read and delete until there is room.
kept
string[]
Only in the answer to a deploy of an existing app: the settings that were left out and kept from the running version, like "allowed_hosts (20)", "vars (2)", "database", "auth".
warnings
string[]
Only in the answer to a deploy: advice about the app's data, never a block. For example that export and delete per user won't cover the data (no table marked with set_table_owner_column), or tables that look like backups or copies. Tell the user in plain words and offer to fix it.
One app with the settings of the running version (main_module, allowed_hosts, secrets by name only, schedules, vars with their values, auth, database, fetch_pages), its stored versions and its most recent errors. A deploy that leaves a setting out keeps the value shown here.
offline: Hypamoon took the site offline; the app does not run.
version (required)
integer
The version that runs now.
allowed_hosts (required)
string[]
secrets (required)
string[]
schedules (required)
object[]
limits (required)
object
Per request: CPU time and fetch calls. Per app: outbound requests per minute and per day.
auth
object or null
Sign-in for the app's users; null when it is off. login_url is where people sign in.
database
object or null
The app's own database (env.DB); null when it has none. full: above the plan's data limit, so the app can only read and delete until there is room.
kept
string[]
Only in the answer to a deploy of an existing app: the settings that were left out and kept from the running version, like "allowed_hosts (20)", "vars (2)", "database", "auth".
warnings
string[]
Only in the answer to a deploy: advice about the app's data, never a block. For example that export and delete per user won't cover the data (no table marked with set_table_owner_column), or tables that look like backups or copies. Tell the user in plain words and offer to fix it.
created_at (required)
string
updated_at (required)
string
main_module (required)
string
The main module of the running version.
vars (required)
object
The plain settings in env of the running version, with their values. Secrets are not here: secrets lists only their names.
fetch_pages (required)
boolean
Whether env.HYPAMOON.fetchPage is on in the running version.
versions (required)
object[]
The kept versions, newest first (at most 10).
recent_errors (required)
object[]
The newest errors (last 7 days), if the log could be read.
Removes the server code, its versions, its schedules and its key; within seconds, /api is an ordinary path on the site again. The site itself remains (delete_site removes it). Deleting it again returns 204 as well.
Makes a stored version live again, with the settings it had (allowed hosts, secrets, schedules, vars). Without version: the version before the current one.
offline: Hypamoon took the site offline; the app does not run.
version (required)
integer
The version that runs now.
allowed_hosts (required)
string[]
secrets (required)
string[]
schedules (required)
object[]
limits (required)
object
Per request: CPU time and fetch calls. Per app: outbound requests per minute and per day.
auth
object or null
Sign-in for the app's users; null when it is off. login_url is where people sign in.
database
object or null
The app's own database (env.DB); null when it has none. full: above the plan's data limit, so the app can only read and delete until there is room.
kept
string[]
Only in the answer to a deploy of an existing app: the settings that were left out and kept from the running version, like "allowed_hosts (20)", "vars (2)", "database", "auth".
warnings
string[]
Only in the answer to a deploy: advice about the app's data, never a block. For example that export and delete per user won't cover the data (no table marked with set_table_owner_column), or tables that look like backups or copies. Tell the user in plain words and offer to fix it.
The newest requests, errors, scheduled runs and outbound calls of the last 7 days, newest first. Headers, cookies, query strings and bodies are never logged; in paths, parts that look like an email address, a long number or a token are replaced by :x. New lines can take up to a minute to appear.
Lets this person sign in to the app with the given role, and emails them a sign-in link (unless send_email is false). The app needs auth (deploy_app with auth). With signup invite (the default) only invited people can sign in. Invitations with an email are limited per app to 20 per hour and 100 per day, and the same address once per 10 minutes (429 with Retry-After).
The sign-in links, codes, invitations and account emails the app sent, newest first, with their delivery status: queued, sent (accepted by the mail provider), delivered (accepted by the recipient's mail server), bounced, complained or failed, and the reason. Recipients are masked (a***@example.com); links and codes are never shown. Use it when someone saw "Check your email" and nothing arrived: delivered means it is in their spam, junk or quarantine.
{
"data": [
{
"sent_at": "2026-10-13T12:38:30.723Z",
"kind": "sign_in_link",
"to": "a***@example.com",
"status": "delivered",
"reason": null,
"meaning": "Accepted by the recipient's mail server. If it is not in the inbox, it is in spam, junk or quarantine.",
"status_at": "2026-10-13T12:40:02.000Z",
"provider_ref": "01a13545"
}
]
}
Makes the people in a collection (default wp_users, from the Hypamoon Connect plugin for WordPress) users of the app, with role_map from WordPress roles to app roles. No passwords: people sign in with a link or code by email. send_email false (the default) adds them silently; true emails each new person an invitation, within the invitation limits (20 per hour and 100 per day per app; the rest is reported as remaining with retry_after). Idempotent on the WordPress id and the email address: people who can already sign in are left as they are. The app user id is written back into each document (hypamoon_app_users). dry_run shows the plan without changing anything.
The collection with the people, default wp_users (from the Hypamoon Connect plugin). Each document needs email; name comes from display_name, name or first_name and last_name; roles from roles (a list) or role.
role_map
object
WordPress role to app role, like { "administrator": "admin", "opdrachtgever": "opdrachtgever", "kandidaat": "kandidaat" }. A person gets the app role of the first of their roles that is in the map. Without role_map, a WordPress role with the same name as an app role is used.
default_role
string or null
The app role for people whose roles are not in role_map. Leave it out (or null) to skip them; without role_map the app's default_role is used.
send_email
boolean
false (the default): add them silently, no email; they sign in later with a link or code they request on the site. true: email each new person an invitation (counts toward the app's invitation limits and the email budget).
invite_existing
boolean
Only with send_email true: also email people who were added earlier (for example silently) and never signed in. Default false, so running the import again sends nothing twice.
dry_run
boolean
true: change nothing, only show who would be added with which role.
created: added now (invited: of those, emailed). existing: could already sign in (matched on WordPress id or email; their role is not changed). remaining: not done yet because of a limit; call again.
users (required)
object[]
Per person what happened, in the order of the collection.
retry_after
integer
Seconds to wait before the remaining invitations can go out.
The person can no longer sign in; open sessions stop within seconds. Without another app in the project their email address and name are deleted too. With delete_data=true everything of this person in the app goes as well (like DELETE /apps/{id}/users/{userId}/data); ask the owner of the app which they want.
Scope: sites:write
Parameter
In
Type
id (required)
path
string (uuid)
The id of the app.
userId (required)
path
string (uuid)
The id of the user (sub in x-hypamoon-user).
delete_data
query
boolean
Also delete their rows in per-user tables, their files and their passkeys. Without it, their data stays.
included: the per-user tables (set_table_owner_column) were handled; unmarked_tables may still hold data. not_included: the app has no per-user tables, so no row of the app's own database was exported or deleted; check those tables yourself. no_database: the app has no database.
unmarked_tables (required)
string[]
Tables that are not marked per-user: Hypamoon cannot tell which rows there belong to this user, so they may still hold data about them.
Everything of one user of the app as JSON: what Hypamoon keeps (account, membership, passkeys without the keys), every row in the per-user tables (set with set_table_owner_column) and every file of theirs with a download link for 15 minutes. unmarked_tables lists the tables that are not per-user: Hypamoon cannot tell which rows there are theirs, so check those yourself. app_tables not_included: the app has no per-user tables at all, so no row of its database is in the export.
What Hypamoon keeps about the person; null if the account is already gone.
membership (required)
object or null
Their place in this app; null if they were removed from it.
passkeys (required)
object[]
Their passkeys for this app, without the keys themselves.
tables (required)
object
Per per-user table, every row of this user.
files (required)
object[]
Every file of this user.
app_tables (required)
"included", "not_included", "no_database"
included: the per-user tables (set_table_owner_column) were handled; unmarked_tables may still hold data. not_included: the app has no per-user tables, so no row of the app's own database was exported or deleted; check those tables yourself. no_database: the app has no database.
unmarked_tables (required)
string[]
Tables that are not marked per-user: Hypamoon cannot tell which rows there belong to this user, so they may still hold data about them.
note (required)
string or null
JSON
{
"app_id": "9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6",
"user_id": "7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5",
"exported_at": "2026-10-07T12:00:00.000Z",
"account": {
"id": "7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5",
"email": "sam@example.com",
"name": "Sam",
"created_at": "2026-10-06T10:00:00.000Z"
},
"membership": {
"role": "member",
"created_at": "2026-10-06T10:00:00.000Z",
"last_sign_in_at": "2026-10-06T10:05:00.000Z",
"active_sessions": 1
},
"passkeys": [
{
"name": "iPhone",
"rp_id": "wardrobe-demo.hypamoon.app",
"created_at": "2026-10-06T10:06:00.000Z",
"last_used_at": "2026-10-07T08:00:00.000Z"
}
],
"tables": {
"items": {
"owner_column": "user_id",
"rows": [
{
"id": "c1",
"user_id": "7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5",
"name": "Blue coat",
"photo_file_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"created_at": "2026-10-06T10:10:00.000Z"
}
],
"truncated": false
}
},
"files": [
{
"id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"name": "coat.jpg",
"content_type": "image/jpeg",
"bytes": 48213,
"status": "ready",
"created_at": "2026-10-06T10:10:00.000Z",
"download_url": "https://wardrobe-demo.hypamoon.app/_hypamoon/files/5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9?t=eyJvIjoi...",
"expires_at": "2026-10-07T12:15:00.000Z"
}
],
"app_tables": "included",
"unmarked_tables": [
"outfits_shared"
],
"note": "These tables are not marked as per-user, so Hypamoon cannot tell which rows belong to this user. They may still hold data about them: check them yourself (or mark them with set_table_owner_column)."
}
Permanently deletes everything of one user of the app: their rows in the per-user tables, their files, their sessions (signed out within seconds), their membership and passkeys, and without another app in the project their email address and name. unmarked_tables lists the tables that are not per-user and may still hold data about them. app_tables not_included: the app has no per-user tables at all, so no row of its database was deleted. Safe to repeat.
included: the per-user tables (set_table_owner_column) were handled; unmarked_tables may still hold data. not_included: the app has no per-user tables, so no row of the app's own database was exported or deleted; check those tables yourself. no_database: the app has no database.
unmarked_tables (required)
string[]
Tables that are not marked per-user: Hypamoon cannot tell which rows there belong to this user, so they may still hold data about them.
Marks a table of the app's database as per-user: column holds the owner (the user's id, sub in x-hypamoon-user). From then on the app reads and writes it only with env.DB.mine(table) (select, get, count, insert, update, delete), always for the signed-in user; plain SQL that names the table is refused, and so are migrations that would read it around that (views, copies). admin_role lets users with that role use all rows with env.DB.mine(table, { all: true }). The app needs auth and database; the running version picks it up within seconds.
The column that holds the owner: the user's id (sub from x-hypamoon-user). Defaults to user_id. It must exist; make it TEXT NOT NULL with an index.
admin_role
string or null
Optional. A role of the app (like admin) whose users may use all rows with env.DB.mine(table, { all: true }). Leave it out and nobody sees other users' rows, admins included.
offline: Hypamoon took the site offline; the app does not run.
version (required)
integer
The version that runs now.
allowed_hosts (required)
string[]
secrets (required)
string[]
schedules (required)
object[]
limits (required)
object
Per request: CPU time and fetch calls. Per app: outbound requests per minute and per day.
auth
object or null
Sign-in for the app's users; null when it is off. login_url is where people sign in.
database
object or null
The app's own database (env.DB); null when it has none. full: above the plan's data limit, so the app can only read and delete until there is room.
kept
string[]
Only in the answer to a deploy of an existing app: the settings that were left out and kept from the running version, like "allowed_hosts (20)", "vars (2)", "database", "auth".
warnings
string[]
Only in the answer to a deploy: advice about the app's data, never a block. For example that export and delete per user won't cover the data (no table marked with set_table_owner_column), or tables that look like backups or copies. Tell the user in plain words and offer to fix it.
offline: Hypamoon took the site offline; the app does not run.
version (required)
integer
The version that runs now.
allowed_hosts (required)
string[]
secrets (required)
string[]
schedules (required)
object[]
limits (required)
object
Per request: CPU time and fetch calls. Per app: outbound requests per minute and per day.
auth
object or null
Sign-in for the app's users; null when it is off. login_url is where people sign in.
database
object or null
The app's own database (env.DB); null when it has none. full: above the plan's data limit, so the app can only read and delete until there is room.
kept
string[]
Only in the answer to a deploy of an existing app: the settings that were left out and kept from the running version, like "allowed_hosts (20)", "vars (2)", "database", "auth".
warnings
string[]
Only in the answer to a deploy: advice about the app's data, never a block. For example that export and delete per user won't cover the data (no table marked with set_table_owner_column), or tables that look like backups or copies. Tell the user in plain words and offer to fix it.
Runs new SQL migrations on the app's own database (env.DB), in order, each as one batch: all or nothing. A migration that was already applied with the same content is skipped; a new name must sort after the last applied one. Only DDL and DML on that database: CREATE TABLE, CREATE INDEX, CREATE VIEW, ALTER, DROP, INSERT, UPDATE, DELETE. No ATTACH, PRAGMA, VACUUM, triggers or transactions. The app needs database: true.
New migrations in order. Ones already applied (same name and content) are skipped.
JSON
{
"migrations": [
{
"name": "0001_init.sql",
"sql": "CREATE TABLE expenses (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, amount_cents INTEGER NOT NULL, note TEXT, file_id TEXT, created_at TEXT NOT NULL);\nCREATE INDEX expenses_user ON expenses (user_id, created_at);"
}
]
}
Read-only. The tables of the app's own database (env.DB) with their columns, indexes, number of rows and whether they are per-user (set_table_owner_column), the views, the applied migrations and a name for the next one. Use it to see where a build stands before you change the tables.
Read-only. main_module and the modules of the running version, or of a kept earlier version with version. Use it to continue building after a break or in a new chat: read the code, change what is needed, and deploy only the changed modules with keep_existing_modules: true. One answer holds at most about 200,000 characters of code; a module that does not fit comes without content (truncated: true) and can be read alone with module.
Scope: sites:read
Parameter
In
Type
id (required)
path
string (uuid)
The id of the app.
version
query
integer
A kept version (get_app lists them). Leave it out for the running version.
Runs one SELECT on the app's own database and returns at most 50 rows, to see what is in it while building or debugging. It only reads: anything that writes or changes the schema is refused, and the query runs as a subquery with the row limit outside it. Email addresses and phone numbers in the result are masked, long text is cut and binary values show only their size. Per-user tables can be read here (the project owns the data); every query is logged in the project's activity, without its SQL. At most 120 queries per hour per project.
For an app's own server code only: the uploaded files, newest first. With the signed user headers forwarded: only that user's files (a user with the admin role, the first of auth.roles, gets everyone's with all=true). Without them (the app itself, for example in a schedule): all files, optionally only those of one user.
Scope: data:read
Parameter
In
Type
user_id
query
string (uuid)
Only the files of this user (sub from x-hypamoon-user).
limit
query
integer
Files, 1 to 500.
all
query
boolean
With the signed headers of a user with the admin role (the first of auth.roles): the files of every user.
For an app's own server code only: fetch(env.HYPAMOON_API + "/app-files", { method: "POST", ... }) without an Authorization header; Hypamoon adds the app's key, and any other key gets 403. Returns a path on the site's own address where the browser PUTs the exact bytes, once, within 15 minutes. The type is checked against the bytes, PDFs and text go through the abuse check, and the size counts toward the files in the plan. Forward the x-hypamoon-user and x-hypamoon-user-sig headers of the request to make the file the signed-in user's own: it is stored in their own folder, only they (and users with the admin role, the first of auth.roles) can download it, and it goes with them on export or deletion. Only forward the headers; never check their signature in the code.
The type: images (png, jpeg, gif, webp, heic, heif), pdf, video (mp4, webm, mov), audio (mp3, m4a, webm), plain text, csv or json. The bytes are checked against it.
size (required)
integer
The exact size in bytes (File.size in the browser).
user_id
string (uuid)
Leave it out. To make the file the signed-in person's own, forward the x-hypamoon-user and x-hypamoon-user-sig headers of the request; if you send user_id, it must be that same person.
For an app's own server code only: a path on the site's own address that serves the file for 15 minutes. With the signed user headers forwarded, only for a file of that user (or any file for the admin role, the first of auth.roles). A file of a user only opens in a browser where that user, or a user with the admin role, is signed in. With public=true the link opens without signing in, for 15 minutes: use it only in a share route of the app, for a file the owner chose to share (like the photos of a shared recipe).
Scope: data:read
Parameter
In
Type
id (required)
path
string (uuid)
The id of the file.
public
query
boolean
true: the link opens without signing in (for a share link of the app). Leave it out for a link that needs the owner's sign-in.
For an app's own server code only, on behalf of the signed-in user: fetch(env.HYPAMOON_API + "/app-users", ...) without an Authorization header, forwarding the x-hypamoon-user and x-hypamoon-user-sig headers of the request unchanged. Only forward them: never check the signature in the app code yourself, Hypamoon does. Without them 401; a signed-in user without the app's first role in auth.roles (admin by default) 403. The people who may sign in to this app, with their role and when they last signed in. Use it for a people or family screen in the app, shown only to admins. In app code, env.HYPAMOON.users.list() does this call for you; it is usually answered next to the app, without a round trip to this API.
For an app's own server code only, on behalf of the signed-in user: fetch(env.HYPAMOON_API + "/app-users", ...) without an Authorization header, forwarding the x-hypamoon-user and x-hypamoon-user-sig headers of the request unchanged. Only forward them: never check the signature in the app code yourself, Hypamoon does. Without them 401; a signed-in user without the app's first role in auth.roles (admin by default) 403. Lets this person sign in with the given role and emails them a sign-in link that works once and stays valid for 7 days. The email comes in the app's own style and language and names the admin who invited them. Limits per app: 20 invitations per hour and 100 per day, and the same address once per 10 minutes (429 with Retry-After). Someone who is already a user gives 409.
For an app's own server code only, on behalf of the signed-in user: fetch(env.HYPAMOON_API + "/app-users", ...) without an Authorization header, forwarding the x-hypamoon-user and x-hypamoon-user-sig headers of the request unchanged. Only forward them: never check the signature in the app code yourself, Hypamoon does. Without them 401; a signed-in user without the app's first role in auth.roles (admin by default) 403. One person who may sign in to this app, with their role. In app code, env.HYPAMOON.users.get(id) does this call for you (null when there is no such user).
Scope: data:read
Parameter
In
Type
id (required)
path
string (uuid)
The id of the user (their sub in x-hypamoon-user).
For an app's own server code only, on behalf of the signed-in user: fetch(env.HYPAMOON_API + "/app-users", ...) without an Authorization header, forwarding the x-hypamoon-user and x-hypamoon-user-sig headers of the request unchanged. Only forward them: never check the signature in the app code yourself, Hypamoon does. Without them 401; a signed-in user without the app's first role in auth.roles (admin by default) 403. Sets one of the app's roles; the user sees it within seconds. The last user with the first role cannot give it up (409): give someone else that role first.
Scope: data:write
Parameter
In
Type
id (required)
path
string (uuid)
The id of the user (their sub in x-hypamoon-user).
For an app's own server code only, on behalf of the signed-in user: fetch(env.HYPAMOON_API + "/app-users", ...) without an Authorization header, forwarding the x-hypamoon-user and x-hypamoon-user-sig headers of the request unchanged. Only forward them: never check the signature in the app code yourself, Hypamoon does. Without them 401; a signed-in user without the app's first role in auth.roles (admin by default) 403. The person can no longer sign in; open sessions stop within seconds. Their data in the app stays. The last user with the first role cannot be removed (409).
Scope: data:write
Parameter
In
Type
id (required)
path
string (uuid)
The id of the user (their sub in x-hypamoon-user).
Email from the project's own domain (an extra module the owner turns on in the portal): sending domains with their DNS records, emails with cc, bcc and attachments, and batches of separate emails, only to people the project already knows.
The project's emails, newest first, each with its status, a short reason when it bounced, was marked as spam, is delayed or was not sent, the status per recipient and a timeline. Plus the state of the Mail module: on or off, paused, the daily limit and what was sent in the last 24 hours. Filter on the app that sent them (app_id), a recipient (to), a status and a start time (since).
Scope: messages:read
Parameter
In
Type
limit
query
integer
Emails per page, 1 to 100.
cursor
query
string
The next_cursor of the previous page.
app_id
query
string (uuid)
Only emails this app sent with env.HYPAMOON.sendEmail (an id from list_apps).
Sends one email from an address on a verified sending domain of this project, like Resend: to is one address or a list, with optional cc and bcc (at most 50 recipients together). Everyone in to and cc sees the others; use bcc or POST /emails/batch when people must not see each other. Every recipient must appear in the project (a form submission, a document, a chat conversation, a user of one of your apps) or be confirmed by the owner. category other (the default) adds an unsubscribe link and header plus the project's postal address (from PUT /project/company, required by the US CAN-SPAM Act; without it the email is refused with company_details_missing) and goes to exactly one recipient; several recipients need category transactional (a reply to something they all did). attachments: at most 10 files, 10 MB together, each with a filename and one of content (base64), media_id (a file from Media) or path (an https address on one of this project's sites). Allowed: PDF, PNG, JPG, GIF, WebP, and ICS, TXT or CSV as UTF-8 text; the type is checked on the file's first bytes. Keep the request under about 4 MB; use media_id or path for larger files. Every email passes a content check (no phishing, no scripts or forms, no links that hide where they go). Limits count every recipient: a daily limit per project, 200 recipients and 20 calls per minute, and a cap on new recipients a day that only appear in documents your app wrote (25 to start, growing to 100 and 500 as emails are delivered without bounces or complaints; see mail.new_recipients in GET /emails). Each recipient also counts as one email for the bundle. Sending pauses automatically when bounces exceed 5% or spam complaints exceed 0.1% over the last 200 emails. With a test key nothing is sent: after all the same checks, the email gets status simulated. An Idempotency-Key (header or idempotency_key) returns the same email instead of a second one. template_id with data sends a template (POST /email-templates) instead of subject, html and text. send_at sends it later (at most 30 days ahead): the answer is then a scheduled email (202), which POST /scheduled-emails/{id}/cancel stops.
Scope: messages:write
Parameter
In
Type
Idempotency-Key
header
string
Optional. The same key returns the same email instead of sending it again.
The sender: an address on a verified sending domain of this project, optionally with a name, like "Acme Plumbing hello@acme.com".
to (required)
one of
One address or a list (at most 50 recipients in to, cc and bcc together). Everyone in to and cc sees the others, as in any email; use bcc, or a batch with one email per person, when they must not. Every address must appear in this project (a form submission, a document, a chat conversation, a user of one of your apps) or be confirmed by the owner.
cc
string[]
Optional. Copy recipients; they see the to and cc addresses. The same rules as to.
bcc
string[]
Optional. Hidden recipients; nobody sees them. The same rules as to.
subject
string
The subject line. Required without template_id; with template_id it comes from the template.
text
string
The plain text body. Send text, html or both.
html
string
The HTML body. No scripts, no forms; links must show where they go.
reply_to
string
Optional. Where replies go, like the user's own mailbox.
attachments
object[]
Optional. At most 10 files, 10 MB together: PDF, PNG, JPG, GIF, WebP, and ICS, TXT or CSV as UTF-8 text. Never executables, HTML, SVG or archives.
category
"transactional", "other"
transactional: a reply to something the recipients did (an order, a booking, a form they sent). other (default): anything else; it gets an unsubscribe link with the project's postal address (needs a business address in the company details) and a List-Unsubscribe header, is not sent to people who unsubscribed, and goes to exactly one recipient (use a batch for more people).
in_reply_to
string (uuid)
The id of the form submission (a document from a public form) or the chat conversation this email answers. Required when the recipient appears in the project only through a form or a chat; the address must be in that submission or conversation.
check_recipients
boolean
Optional, default true: before the first email to an address, check that its domain exists and accepts email, and refuse likely typos (like gmial.com) and, for category other, throwaway addresses. false skips those checks; an address that bounced before is always refused.
idempotency_key
string
Optional. The same key returns the same email instead of sending a second one (also as the Idempotency-Key header).
template_id
string (uuid)
Optional. A template (create_email_template): the subject, html and text come from it, filled with data. Leave out subject, html and text.
data
object
With template_id: the values for its variables, like { "name": "Sam" }.
send_at
string
Optional. Send later, like 2026-11-02T09:00:00Z, at most 30 days ahead: the answer is a scheduled email (202) that cancel_scheduled_email can stop. All checks except the form run when it is sent.
JSON
{
"from": "Acme Plumbing <hello@acmeplumbing.com>",
"to": "sam@example.org",
"subject": "We received your request",
"text": "Hi Sam, thanks for your request. We will call you back today.",
"reply_to": "office@acmeplumbing.com",
"category": "transactional",
"attachments": [
{
"filename": "quote.pdf",
"content": "JVBERi0xLjcKJcfsj6IK..."
}
]
}
scheduled (waits for send_at), sending, sent (email_id is the email), canceled, skipped (the person unsubscribed or left the list or sequence), failed (reason says why).
send_at (required)
string (date-time)
to (required)
string or null
subject (required)
string or null
template_id (required)
string or null (uuid)
email_id (required)
string or null (uuid)
The email once it was sent; get_email shows whether it was delivered.
For an app's own server code only: await env.HYPAMOON.listEmails({ to, status, since, limit, cursor }) does this call without an Authorization header; Hypamoon adds the app's key, and any other key gets 403. Only the emails this app sent with sendEmail or sendEmailBatch, newest first, never those of another app or of the API. Each with its status, reason, status per recipient and timeline, as GET /emails/{id}.
For an app's own server code only: await env.HYPAMOON.sendEmail({ to, subject, text }) does this call, POST env.HYPAMOON_API + "/app-emails" without an Authorization header; Hypamoon adds the app's key, and any other key gets 403 (other keys use POST /emails with messages:write). The same rules as POST /emails, for the app's project: the Mail module on, recipients (to, cc, bcc) that the project knows (a user of the app, a form submission, a document, a chat conversation) or that the owner confirmed, attachments, the daily and per-minute limits per recipient, and the content check. In the app, an attachment's content may be a Uint8Array (like the result of env.HYPAMOON.renderPdf); the wrapper sends it as base64. from is optional: without it the email comes from noreply@ on the project's verified sending domain (the oldest one). No signed-in user is needed, so a scheduled job can send too. With a test key nothing is sent (status simulated).
Scope: data:write
Parameter
In
Type
Idempotency-Key
header
string
Optional. The same key returns the same email instead of sending it again.
Optional. An address on a verified sending domain of this project, optionally with a name. Without it: noreply@ on the project's oldest verified sending domain.
to (required)
one of
One address or a list (at most 50 recipients in to, cc and bcc together). Everyone in to and cc sees the others, as in any email; use bcc, or a batch with one email per person, when they must not. Every address must appear in this project (a form submission, a document, a chat conversation, a user of one of your apps) or be confirmed by the owner.
cc
string[]
Optional. Copy recipients; they see the to and cc addresses. The same rules as to.
bcc
string[]
Optional. Hidden recipients; nobody sees them. The same rules as to.
subject (required)
string
The subject line.
text
string
The plain text body. Send text, html or both.
html
string
The HTML body. No scripts, no forms; links must show where they go.
reply_to
string
Optional. Where replies go, like the user's own mailbox.
attachments
object[]
Optional. At most 10 files, 10 MB together: PDF, PNG, JPG, GIF, WebP, and ICS, TXT or CSV as UTF-8 text. Never executables, HTML, SVG or archives.
category
"transactional", "other"
transactional: a reply to something the recipients did (an order, a booking, a form they sent). other (default): anything else; it gets an unsubscribe link with the project's postal address (needs a business address in the company details) and a List-Unsubscribe header, is not sent to people who unsubscribed, and goes to exactly one recipient (use a batch for more people).
in_reply_to
string (uuid)
The id of the form submission (a document from a public form) or the chat conversation this email answers. Required when the recipient appears in the project only through a form or a chat; the address must be in that submission or conversation.
check_recipients
boolean
Optional, default true: before the first email to an address, check that its domain exists and accepts email, and refuse likely typos (like gmial.com) and, for category other, throwaway addresses. false skips those checks; an address that bounced before is always refused.
idempotency_key
string
Optional. The same key returns the same email instead of sending a second one (also as the Idempotency-Key header).
JSON
{
"to": "sam@example.org",
"subject": "Your hours for week 41 are approved",
"text": "Hi Sam, your hours for week 41 were approved.",
"category": "transactional"
}
Sends 1 to 100 separate emails in one call, like Resend's batch: each email has its own to (and its own text), so everyone sees only their own email, for example a personal invitation per person. Each email follows the rules of POST /emails, except that a batch has no attachments (send those one by one). All or nothing: every email is checked first, and if one is refused, none is sent and the error says which (index). Every email and every recipient counts toward the limits and the bundle; the whole batch is one call for the per-minute limit. An Idempotency-Key (header or idempotency_key) returns the same emails instead of sending them again. With a test key nothing is sent (status simulated).
Scope: messages:write
Parameter
In
Type
Idempotency-Key
header
string
Optional. The same key returns the same emails instead of sending them again.
1 to 100 separate emails, each with its own to (and its own text, for personal invitations): everyone sees only their own email. No attachments in a batch; send those one by one.
idempotency_key
string
Optional. The same key returns the same emails instead of sending them again (also as the Idempotency-Key header).
JSON
{
"emails": [
{
"from": "Acme Plumbing <hello@acmeplumbing.com>",
"to": "sam@example.org",
"subject": "Your invitation",
"text": "Hi Sam, you are invited to our open day on 1 November.",
"category": "transactional"
},
{
"from": "Acme Plumbing <hello@acmeplumbing.com>",
"to": "kim@example.org",
"subject": "Your invitation",
"text": "Hi Kim, you are invited to our open day on 1 November.",
"category": "transactional"
}
]
}
For an app's own server code only: await env.HYPAMOON.sendEmailBatch([{ to, subject, text }, ...]) does this call without an Authorization header; Hypamoon adds the app's key, and any other key gets 403. The rules of POST /emails/batch for the app's project; from is optional per email, as in sendEmail.
Scope: data:write
Parameter
In
Type
Idempotency-Key
header
string
Optional. The same key returns the same emails instead of sending them again.
One email with its status (queued, sent, delivered, bounced, complained: marked as spam, failed or simulated), a short reason, the status per recipient (one can bounce while the others are delivered) and the timeline with times.
For an app's own server code only: await env.HYPAMOON.getEmail(id), with the id that sendEmail or sendEmailBatch returned. The same fields as GET /emails/{id}. An email of another app or of the API is not found.
Makes one of this project's domains (connected or purchased) a sending domain. When its DNS is hosted here, we add the records ourselves: an existing SPF record is merged (never a second one), an MX record of the user's mailbox is never touched, and nothing is deleted, so the user's mail keeps working. When the user manages DNS elsewhere, records lists what they need to add there (state add_yourself). A DMARC record is only suggested when there is none. Then call verify_email_domain. Adding the same domain again returns the existing one.
The owner confirms an address that does not appear in the project, so send_email may use it. Only through a connection where the owner is signed in (a chat), or in the portal; an API key cannot. Ask the owner first and only pass an address they typed or confirmed. At most 20 a day.
Checks one address or up to 50 the way sending does before the first email to someone: the format, this project's suppression list (an earlier hard bounce or spam complaint, or an unsubscribe), likely typos of big mail domains (gmial.com), throwaway domains, and whether the domain exists and accepts email (MX, or an A record without MX). Sends nothing and stores nothing. Use it on addresses from a form or an import before sending. At most 30 calls per minute per project.
Per sending domain over the last 7 and 30 days: emails sent, delivered, bounced and marked as spam, with the rates (counted per email). Mailboxes start filtering above 2% bounces or 0.1% spam complaints; health and advice say what to do in plain words. Opens are not measured. Also SPF and DKIM (pass once the domain is verified) and the DMARC record that is in the DNS now, and the state of a dedicated sending IP with its price. With a test key the numbers come from simulated test emails, so there is no delivery data. Read-only.
true with a test key: test emails are simulated, so there is no delivery data.
thresholds (required)
object
opens_measured (required)
false
domains (required)
object[]
dedicated_ip (required)
object or null
JSON
{
"test": false,
"thresholds": {
"bounce_rate": 0.02,
"complaint_rate": 0.001
},
"opens_measured": false,
"domains": [
{
"email_domain_id": "8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"domain": "acmeplumbing.com",
"status": "verified",
"authentication": {
"spf": "pass",
"dkim": "pass",
"dmarc": {
"record": "v=DMARC1; p=none;",
"policy": "none",
"status": "monitoring"
}
},
"last_7_days": {
"sent": 120,
"delivered": 119,
"bounced": 1,
"complained": 0,
"opened": null,
"bounce_rate": 0.0083,
"complaint_rate": 0
},
"last_30_days": {
"sent": 480,
"delivered": 476,
"bounced": 4,
"complained": 0,
"opened": null,
"bounce_rate": 0.0083,
"complaint_rate": 0
},
"health": "good",
"advice": [
"DMARC on acmeplumbing.com only monitors (p=none). Once your emails pass for a few weeks, move to p=quarantine; that is also needed for your logo in the inbox."
]
}
],
"dedicated_ip": {
"status": "none",
"requested_at": null,
"emails_last_30_days": 480,
"price": {
"month_eur_per_ip": 49,
"free": false,
"text": "€49 a month per IP, excluding VAT, once we have set it up."
},
"advice": "A dedicated IP makes sense from about 100,000 emails a month with steady volume. Below that, an IP gets too little traffic to build its own reputation, and the shared pool usually delivers better.",
"available": true,
"reason": null
}
}
Whether the domain can show its logo next to emails in the inbox (BIMI): the DMARC policy (it must be quarantine or reject for all mail), the BIMI record at default._bimi, and the steps that are left. Gmail shows the logo only with a mark certificate (VMC or CMC) that the user gets from a certificate authority; Apple Mail only with a VMC; Yahoo can show it without a certificate for domains with a good sending reputation, but that is not guaranteed. Read-only.
The DNS is hosted here, so set_email_bimi sets the record itself.
steps (required)
string[]
note (required)
string
Which mailboxes show the logo, and what they need.
JSON
{
"email_domain_id": "8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"domain": "acmeplumbing.com",
"dmarc": {
"record": "v=DMARC1; p=quarantine; rua=mailto:dmarc@acmeplumbing.com",
"policy": "quarantine",
"percentage": 100,
"ok": true
},
"bimi": {
"name": "default._bimi.acmeplumbing.com",
"record": "v=BIMI1; l=https://acmeplumbing.com/logo.svg;",
"logo_url": "https://acmeplumbing.com/logo.svg",
"certificate_url": null
},
"ready": true,
"dns_managed": true,
"steps": [
"For Gmail and Apple Mail, get a mark certificate (VMC or CMC) for the logo and add its address as certificate_url."
],
"note": "Gmail shows the logo only with a mark certificate (VMC or CMC) that you get from a certificate authority yourself; Apple Mail only with a VMC. Yahoo can show it without a certificate for domains with a good sending reputation, but that is not guaranteed."
}
Sets the BIMI record default._bimi. with the logo (and the mark certificate, if the user has one). First checks that DMARC is quarantine or reject for all mail (otherwise bimi_not_ready; we never change DMARC ourselves) and that the logo is an SVG Tiny PS file mailboxes accept: version 1.2 with baseProfile tiny-ps, a , square, no scripts or external files, at most 32 KB, on an https address without a redirect. When the domain's DNS is hosted here we set or update the record; otherwise record_to_add is the record to add at the DNS provider.
The DNS is hosted here, so set_email_bimi sets the record itself.
steps (required)
string[]
note (required)
string
Which mailboxes show the logo, and what they need.
record_to_add (required)
object or null
When the DNS is hosted elsewhere: the record to add there. Null when we set it.
JSON
{
"email_domain_id": "8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"domain": "acmeplumbing.com",
"dmarc": {
"record": "v=DMARC1; p=quarantine; rua=mailto:dmarc@acmeplumbing.com",
"policy": "quarantine",
"percentage": 100,
"ok": true
},
"bimi": {
"name": "default._bimi.acmeplumbing.com",
"record": "v=BIMI1; l=https://acmeplumbing.com/logo.svg;",
"logo_url": "https://acmeplumbing.com/logo.svg",
"certificate_url": null
},
"ready": true,
"dns_managed": true,
"steps": [
"For Gmail and Apple Mail, get a mark certificate (VMC or CMC) for the logo and add its address as certificate_url."
],
"note": "Gmail shows the logo only with a mark certificate (VMC or CMC) that you get from a certificate authority yourself; Apple Mail only with a VMC. Yahoo can show it without a certificate for domains with a good sending reputation, but that is not guaranteed.",
"record_to_add": null
}
Asks Hypamoon to set up a dedicated IP address for this project's email. Available on the Business and Enterprise plans for €49 a month per IP, excluding VAT (shown in price before anyone requests; GET /email-deliverability has it too); otherwise dedicated_ip_not_available. We set it up ourselves and get back to the owner; nothing changes until then. It makes sense from about 100,000 emails a month with steady volume; below that the shared pool usually delivers better. A second request while one is open returns that one.
Per IP per month, excluding VAT; free for founder projects.
advice (required)
string
available (required)
boolean
Whether this project can request one (Business and Enterprise).
reason (required)
string or null
JSON
{
"status": "requested",
"requested_at": "2026-10-15T12:00:00.000Z",
"emails_last_30_days": 142300,
"price": {
"month_eur_per_ip": 49,
"free": false,
"text": "€49 a month per IP, excluding VAT, once we have set it up."
},
"advice": "A dedicated IP makes sense from about 100,000 emails a month with steady volume. Below that, an IP gets too little traffic to build its own reputation, and the shared pool usually delivers better.",
"available": true,
"reason": null
}
Per IP per month, excluding VAT; free for founder projects.
advice (required)
string
available (required)
boolean
Whether this project can request one (Business and Enterprise).
reason (required)
string or null
JSON
{
"status": "requested",
"requested_at": "2026-10-15T12:00:00.000Z",
"emails_last_30_days": 142300,
"price": {
"month_eur_per_ip": 49,
"free": false,
"text": "€49 a month per IP, excluding VAT, once we have set it up."
},
"advice": "A dedicated IP makes sense from about 100,000 emails a month with steady volume. Below that, an IP gets too little traffic to build its own reputation, and the shared pool usually delivers better.",
"available": true,
"reason": null
}
A template has a name, a subject and html or text with variables: {{name}}, or {{name|there}} with a fallback when the value is missing. Values in html are escaped. style site (default) puts the email in the style of the project's site: its name, logo and colors. Send it with POST /emails and template_id plus data, use it in an automation or a sequence, or preview it first. At most 200 templates per project.
When something happens, send a template (or start a sequence) to the person it happened to, or to a fixed address that appears in the project. Events: form.submitted (a form on one of the project's sites was sent; filter.collection for one form), booking.created (a form submission in the collection bookings, or the one in filter.collection), order.paid, order.payment_failed (the payment failed, expired or was canceled), app_user.created (someone became a user of an app; filter.app_id for one app). The variables are the fields of the form, or order_id, order_number, total and currency, plus email and name. delay_minutes waits, like 60 for an hour (at most 30 days). Every event sends at most once per automation, also when it is reported twice. The email follows all rules of POST /emails: category transactional (default) answers what the person did. A test project only simulates.
The template to send. Give template_id or sequence_id.
sequence_id
string (uuid)
Or: start this sequence for the person.
to
string
contact (default): the person of the event (who sent the form, the buyer, the new app user). Or one fixed address that appears in the project, like the owner's, for a notice.
from (required)
string
The sender: an address on a verified sending domain of this project, optionally with a name, like "Acme hello@acme.com".
reply_to
string
Optional. Where replies go, like the owner's own mailbox.
category
"transactional", "other"
transactional (default): it answers what the person did. other: gets an unsubscribe link.
delay_minutes
integer
Wait this long after the event, like 60 for one hour. 0 sends right away.
scheduled (waits for send_at), sending, sent (email_id is the email), canceled, skipped (the person unsubscribed or left the list or sequence), failed (reason says why).
send_at (required)
string (date-time)
to (required)
string or null
subject (required)
string or null
template_id (required)
string or null (uuid)
email_id (required)
string or null (uuid)
The email once it was sent; get_email shows whether it was delivered.
scheduled (waits for send_at), sending, sent (email_id is the email), canceled, skipped (the person unsubscribed or left the list or sequence), failed (reason says why).
send_at (required)
string (date-time)
to (required)
string or null
subject (required)
string or null
template_id (required)
string or null (uuid)
email_id (required)
string or null (uuid)
The email once it was sent; get_email shows whether it was delivered.
A series of 1 to 20 emails, each a template with a wait before it (in minutes, at most 30 days). Someone joins with POST /email-sequences/{id}/enrollments or through an automation. It stops for a person when they unsubscribe (always), when an event in stop_on happens for their address (like order.paid), or with POST /email-sequences/{id}/stop. category other (default) gives every email an unsubscribe link.
Only someone the project already knows, like any recipient (a form submission with in_reply_to, a document, an app user, a buyer, a confirmed list contact, or an address the owner confirmed). data fills the variables of every step. Someone who is already in the sequence stays where they are (200).
For agencies that build for clients: client projects, access requests, owner invitations, who pays, budgets, usage, invoice lines and activity per client, and payment links and subscriptions on your own payment account for a client. Work inside a client project with the Hypamoon-Project header on any other route. For a client: which agencies have access, and revoking it.
Creates a new project for a client in the building stage: your agency is its temporary owner through the relation, everything stays in test with Free limits, until the client accepts the owner invitation (POST /clients/{id}/owner-invite). The one-project-per-owner limit of signing up does not apply here.
Asks the owner of an existing project for access. The answer has confirm_url: a page in the portal where the owner sees what your agency can do, who pays and how to revoke, and accepts or declines. With payer agency your agency offers to pay the usage; the owner can still choose to pay himself. Nothing is emailed; give the link to the client. An open request is returned as it is (200).
Emails the client a link to become the owner of a project in the building stage. Inviting again replaces the previous link. The link works for 14 days and only for a signed-in user with this email address.
Ends your agency's access to a client project, or withdraws a pending request. What you built stays with the client. If your agency paid the usage, the client pays again from now on and the owner gets an email; payer_stopped says so. open_payments lists your payment links and subscriptions for this client that still run on your own payment account: cancel what should stop. A project in the building stage that nobody took over goes to the trash after 30 days and is deleted 30 days later, unless you restore it.
Hands over a project in the building stage. With to_email the client becomes the owner after accepting on their own confirmation page; agency_keeps_access says whether your agency keeps working on it (true) or stops (false, then the client pays from then on). With to_agency_project_id another agency takes it over after an owner or builder there accepts in their portal (Clients); your access ends at that moment. A project the client already owns cannot be handed over: the client chooses its agencies. The answer lists open_payments: your payment links and subscriptions for this client, to cancel what should stop.
Brings back a project in the building stage that your agency ended and nobody took over, also from the trash, as long as it was not deleted yet. Your agency is its temporary owner again.
Costs and usage per calendar month (UTC), oldest first: cost_eur with cost_by_source and cost_by_payer (client: the project's own credit, agency: paid by your agency), top_ups_eur and the app meters, plus the current payer and budget_eur. For invoicing your client with your own markup, or your own client portal.
Scope: clients:read
Parameter
In
Type
id (required)
path
string (uuid)
The id of the client project.
months
query
integer
How many months back, the current month included (at most 12).
Proposes who pays the usage of a client project (client or agency), or agrees to what the client proposed. It only changes when both sides agree: the client accepts in the portal under Agencies. Choosing the current payer withdraws an open proposal. In the building stage your agency decides alone; the client sees it when taking over. applied tells whether it changed now.
The most your agency pays for this client project per calendar month, in euros excluding VAT, or null for no budget of its own (your project's spending cap still applies). When it is reached, new paid usage in the project gets a 402 billing_blocked and your agency gets one email that month.
The usage of a client project as invoice lines per month, payer and source, from the same numbers as GET /clients/{id}/usage, so you invoice your client in your own accounting with your own markup. format=csv returns text/csv. Hypamoon does not send the invoice.
Scope: clients:read
Parameter
In
Type
id (required)
path
string (uuid)
The id of the client project.
months
query
integer
How many months back, the current month included.
payer
query
"agency", "client", "all"
Only the lines your agency paid, only those the client paid, or all.
A one-time payment on your agency's own payment account (connected on your agency project), with the client project in the metadata. The money goes to your agency; Hypamoon keeps only the transaction fee you accepted when connecting. Give checkout_url to your client yourself; nothing is emailed. Test until payments on your agency project are live. Events: client_payment.paid and client_payment.failed on your webhooks.
A subscription on your agency's own payment account, with the client project in the metadata: the payer pays the first period through checkout_url, which also sets up the mandate; after that the payment provider charges every month or year. Not every payment account supports this yet; a 409 says so. Events: client_subscription.active, client_subscription.payment_paid, client_subscription.payment_failed and client_subscription.canceled.
Marks an open payment link of your agency as expired, also after your relation with the client ended. The checkout at your payment provider expires on its own; if it is still paid, the money is on your own account.
For a client: the agencies that work on this project or asked to, newest first. Accepting a request is only possible in the portal, where the owner reads what the agency can do.
Sent when the phone of the recipient confirmed the message.
Field
Type
id (required)
string
The id of the event, evt_ plus 32 hex characters. It stays the same on retries.
type (required)
"message.delivered"
created_at (required)
string (date-time)
When the event happened.
data (required)
object
JSON
{
"id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3",
"created_at": "2026-10-01T09:00:04.000Z",
"type": "message.delivered",
"data": {
"message": {
"id": "8f7e6d5c-4b3a-4291-8f0e-1d2c3b4a5968",
"to": "+447700900321",
"from": "Acme Shop",
"body": "Hi Sam, thanks for your order 10432 at Acme Shop. We are packing it now; you get a track and trace link as soon as it ships.",
"segments": 1,
"encoding": "gsm7",
"status": "delivered",
"error": null,
"reference": "order-10432",
"created_at": "2026-10-01T09:00:00.000Z",
"status_updated_at": "2026-10-01T09:00:04.000Z",
"test": true
}
}
}
Sent when a message could not be delivered. data.message.error says why.
Field
Type
id (required)
string
The id of the event, evt_ plus 32 hex characters. It stays the same on retries.
type (required)
"message.failed"
created_at (required)
string (date-time)
When the event happened.
data (required)
object
JSON
{
"id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3",
"created_at": "2026-10-01T09:00:04.000Z",
"type": "message.failed",
"data": {
"message": {
"id": "8f7e6d5c-4b3a-4291-8f0e-1d2c3b4a5968",
"to": "+447700900321",
"from": "Acme Shop",
"body": "Hi Sam, thanks for your order 10432 at Acme Shop. We are packing it now; you get a track and trace link as soon as it ships.",
"segments": 1,
"encoding": "gsm7",
"status": "failed",
"error": "delivery_failed",
"reference": "order-10432",
"created_at": "2026-10-01T09:00:00.000Z",
"status_updated_at": "2026-10-01T09:00:04.000Z",
"test": true
}
}
}
Sent when the summary of a closed chat conversation is ready. Without the messages: fetch those with GET /conversations/{data.conversation.id}.
Field
Type
id (required)
string
The id of the event, evt_ plus 32 hex characters. It stays the same on retries.
type (required)
"conversation.summary.ready"
created_at (required)
string (date-time)
When the event happened.
data (required)
object
JSON
{
"id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3",
"created_at": "2026-10-01T09:31:10.000Z",
"type": "conversation.summary.ready",
"data": {
"conversation": {
"id": "2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f",
"channel": "chat",
"status": "closed",
"reference": "session-8841",
"metadata": {
"page": "/pricing",
"plan": "pro"
},
"customer": {
"name": "Sam Jones",
"phone": null,
"email": "sam@example.com"
},
"language": "en",
"turns": 2,
"max_turns": 30,
"created_at": "2026-10-01T09:00:00.000Z",
"last_message_at": "2026-10-01T09:01:05.000Z",
"closed_at": "2026-10-01T09:31:05.000Z",
"close_reason": "inactive",
"has_summary": true,
"summary_ready_at": "2026-10-01T09:31:09.000Z",
"test": false
},
"summary": {
"text": "Sam Jones has a leaking kitchen tap and wants someone to come today. Sam left the number +447700900321 for a call back.",
"topics": [
"leak",
"visit"
],
"sentiment": "neutral",
"action_items": [
"Call Sam Jones back on +447700900321 within the hour to plan a visit."
],
"next_step": "Call back within the hour."
}
}
}
The name of the project. The assistant uses it in its greeting.
edition (required)
string
Always api for a project of the Hypamoon API.
created_at (required)
string (date-time)
When the project was created.
spending_limit_eur
number or null
The most the project can spend per month, in euros. Null in test mode: a test project costs nothing. Left out for a key or connection limited to some sites and apps.
numbers_count
integer
How many numbers the project has. Left out for a limited key or connection.
mode (required)
"live", "test"
live or test: the mode of the key, which is always the mode of its project.
live_project_id
string or null (uuid)
Only in test mode: the live project this test project belongs to, or the project itself when it has no separate test project.
rails
object
Per function with real-world effects, for this request: live (in use) or test (simulated: not used yet, or a test key). A function on test becomes live the first time it is asked for, once the project is verified for it; until then the request answers verification_required with a link. A test key is test everywhere. Left out for a limited key or connection. Sites, data and apps are always real and have no mode.
verification
object
The verification (KYC) of the project. In test mode: the verification of the live project.
access (required)
object or null
Null: this key or connection can use the whole project. Otherwise only these sites, apps and collections: lists show only those, and anything for the whole project (billing, team, domains, numbers, a new site or app) is refused with 403 and a user_message.
eu (detailed), uk and us (basics), other (a general fallback).
audience (required)
"consumers", "businesses", "both"
how_to_use (required)
string
For the agent: inform yourself, ask only the blocking questions, use the defaults for the rest, and offer the checklist; never a questionnaire or a gate.
ask_first (required)
object[]
build_plan (required)
object[]
go_live_checklist (required)
object[]
pitfalls (required)
object[]
structured_data (required)
object
Structured data for search engines, ready to fill in and add with deploy_site.
note (required)
string
Guidance, not legal or tax advice.
other_types (required)
object
known_problems (required)
object[]
Known problems (an upload blocked by a sandbox, an image too large, version_conflict, phone verification, a site nobody else can see, is Hypamoon down), each with what to do and what to tell the user. GET /advice?topic= returns one.
Move sites from the test project to the live project.
Field
Type
sites
string[]
Optional. The sites to move, by id or slug. Leave it out to move every site of the test project.
collections
string[]
Optional. The collections to move, by name or id. Leave it out to move the collections the chosen sites use (a site uses a collection when its files name it, like a form from get_form_snippet).
shared_collections
"refuse", "copy"
What to do with a collection that a site staying in test also uses. refuse (default): nothing is moved and the answer lists them. copy: the live project gets its own copy with all documents; the test site keeps the original.
Who the project belongs to, and who its phone numbers are registered to: a business or a personal account (account_type). For a business: company_name, the address, the number in its country's business register (kvk_number in the Netherlands, registration_number elsewhere) and, in the EU, vat_number (checked with VIES). For a personal account: the user's address and the contact (the user), without company fields.
Field
Type
account_type
"personal", "business"
personal (someone without a company: no company_name, kvk_number, registration_number or vat_number) or business. Leave it out to keep the project's current type. A national number needs business. When a personal account is not available for the address, validation_failed says so.
company_name
string or null
The legal name. Required for a business, not allowed for a personal account.
kvk_number
string or null
For a company in the Netherlands (required there): the KvK number, 8 digits. Spaces and dots are removed.
registration_number
string or null
For a company outside the Netherlands: the number in its country's business register (like a UK Companies House number or a German HRB number). Needed for the company verification level outside the EU.
vat_number
string or null
The VAT number. In the EU outside the Netherlands it is needed for the company verification level and checked with VIES: send it with or without the country prefix. A number that VIES rejects returns vat_number_invalid.
address (required)
object
contact (required)
object
A person the regulator can reach today. For a personal account: the user.
registrar_contact_name
string or null
Optional, for a business: the name on the contacts of the domains registered or transferred through Hypamoon, when it should differ from contact.name. Use a role, like Administration or Webmaster, so the domain registration carries no personal name while contact.name stays the person that number registration needs. Leave it out to keep the saved value; send null to use contact.name again. Not allowed for a personal account.
For a company in the EU: the VIES check of vat_number (unverified when VIES did not answer; we retry automatically). missing: no VAT number yet. not_required: no VIES check needed for this country.
vat_checked_at (required)
string or null
When VIES last checked vat_number, ISO 8601.
address (required)
object
contact (required)
object
registrar_contact_name
string or null
Only for a business: the name on the domain contacts at the registry when it differs from contact.name, or null (then contact.name is used).
complete (required)
boolean
The details are complete enough for the company or personal verification level: a business needs its business register number and, in the EU, a valid VAT number; a personal account needs a complete address.
domain_contacts
object[]
Only when the project has domains registered or transferred through Hypamoon: per domain, whether the new details reached the domain's contact at the registry. A change of organization, registration number or (for a personal account) person is never sent: that is a change of holder.
The voice. It must speak the language: nl saar, fenna, merel, julia, roos, tim, bram, daan, ruben; en ava, nora, liam, owen; de lena, mara, jonas, felix; fr chloe, manon, hugo, theo; es lucia, elena, pablo, diego. Case does not matter. Default: the first voice of the language.
language
"nl", "en", "de", "fr", "es"
The language the assistant opens and speaks in: en (English), nl (Dutch), de (German), fr (French) or es (Spanish). The greeting follows it. Default: the language of the country of your first number (Dutch while the project has no number yet).
languages
"nl", "en", "de", "fr", "es"[]
The languages your callers speak; language always comes first. One language is the most reliable, because speech recognition never has to guess. Two or more switch it to multi, which follows the caller sentence by sentence, and the assistant switches with them. auto and multi are not accepted. Default: [language].
instructions
string
What the assistant does, as free text. Default empty.
website
string or null
Optional. We read the page and use it for opening hours, address and services. Must be a public web address.
Whether the assistant was ever set up with PUT /assistant.
voice (required)
string or null
The voice, like ava. It speaks the language.
language (required)
string
The language the assistant opens and speaks in, like en.
languages (required)
string[]
The languages your callers speak, with language first.
speech_recognition (required)
string
The language of speech recognition: the one language, or multi when there are two or more.
instructions (required)
string
What the assistant does: your instructions.
website (required)
string or null
The website the assistant reads for opening hours, address and services.
greeting (required)
string
The fixed greeting, in the language. It follows from the project name and always says the caller is talking to a digital assistant.
synced_at (required)
string or null (date-time)
When the assistant was last updated at the carrier.
sync (required)
"synced", "failed", "skipped_test_mode"
synced when the assistant was updated right away; failed when not. Your settings are saved either way and applied with the next PUT. skipped_test_mode with a test key: a test project has no live assistant.
call_plan (required)
"added", "in_plan", "not_in_plan"
added: the project only had the default plan (voicemail), so it now has a call plan where the assistant answers every call, with voicemail if the assistant is not available. in_plan: the call plan already sends calls to the assistant. not_in_plan: your own call plan does not use the assistant yet; add an ai_agent node with PUT /numbers/{id}/call-plan.
A call plan: a small graph. entry is the first node; every node points to the next by id. At most 50 nodes, and every node must be reachable from entry. Field names inside the plan are camelCase: the plan is stored exactly as you send it.
Field
Type
entry (required)
string
The id of the first node.
nodes (required)
object
The nodes by id. The key must be the same as the node's id.
The version of the number's own plan; 0 when the number has no own plan yet. Also in the ETag header.
source (required)
"number", "project", "none"
Where plan comes from. number: the number has its own plan. project: the number has no own plan and follows the plan of the project (set_assistant sets that one up); this is what callers get. none: no plan at all.
plan (required)
one of
The plan that calls to this number follow: its own plan, or else the plan of the project; null when there is none.
A call plan: a small graph. entry is the first node; every node points to the next by id. At most 50 nodes, and every node must be reachable from entry. Field names inside the plan are camelCase: the plan is stored exactly as you send it.
A call. The same shape in GET /calls, GET /calls/{id} and every webhook.
Field
Type
id (required)
string (uuid)
The id of the call.
direction (required)
"inbound", "outbound", null
inbound, or outbound for a call your assistant made with POST /calls.
from (required)
string or null
The caller, in E.164.
to (required)
string or null
The number that was called, in E.164.
status (required)
string or null
The status as we track it: ringing, active, completed, missed, failed, voicemail, ai_handled. New values may be added; treat an unknown value like completed.
created_at (required)
string or null (date-time)
When the call reached us.
started_at (required)
string or null (date-time)
When the call started.
answered_at (required)
string or null (date-time)
When the platform picked up. With a call plan the platform always picks up first, so this does not prove a person answered.
ended_at (required)
string or null (date-time)
When the call ended.
duration_sec (required)
integer or null
The length of the call in seconds.
hangup_cause (required)
string or null
Why the call ended, as reported by the carrier, like normal_clearing.
has_summary (required)
boolean
Whether the call has a summary. Fetch it with GET /calls/{id}.
summary_ready_at (required)
string or null (date-time)
When the summary of a recorded call was ready. Empty for a call handled by the voice AI; look at has_summary instead.
test (required)
boolean
true for a simulated call from POST /test/calls or POST /calls with a test key (test mode). Also in webhooks, so you can keep test traffic apart.
outbound (required)
object or null
The request behind an outbound call started with POST /calls.
inbound, or outbound for a call your assistant made with POST /calls.
from (required)
string or null
The caller, in E.164.
to (required)
string or null
The number that was called, in E.164.
status (required)
string or null
The status as we track it: ringing, active, completed, missed, failed, voicemail, ai_handled. New values may be added; treat an unknown value like completed.
created_at (required)
string or null (date-time)
When the call reached us.
started_at (required)
string or null (date-time)
When the call started.
answered_at (required)
string or null (date-time)
When the platform picked up. With a call plan the platform always picks up first, so this does not prove a person answered.
ended_at (required)
string or null (date-time)
When the call ended.
duration_sec (required)
integer or null
The length of the call in seconds.
hangup_cause (required)
string or null
Why the call ended, as reported by the carrier, like normal_clearing.
has_summary (required)
boolean
Whether the call has a summary. Fetch it with GET /calls/{id}.
summary_ready_at (required)
string or null (date-time)
When the summary of a recorded call was ready. Empty for a call handled by the voice AI; look at has_summary instead.
test (required)
boolean
true for a simulated call from POST /test/calls or POST /calls with a test key (test mode). Also in webhooks, so you can keep test traffic apart.
outbound (required)
object or null
The request behind an outbound call started with POST /calls.
The events to receive, from call.completed, call.summary.ready, message.delivered, message.failed, conversation.summary.ready, balance.low, balance.topped_up, balance.reload_failed, document.created, email.sent, email.delivered, email.delayed, email.bounced, email.complained. The email events come once per recipient.
app_id
string (uuid)
Optional, for email events only: receive only the email events of emails this app sent (an id from list_apps). Without it, the endpoint gets the events of every email of the project.
An outbound call by your project's assistant to one person who expects it.
Field
Type
to (required)
string
The person to call, in E.164. Live: a mobile or geographic number in a country your project can call (otherwise destination_not_allowed with a reason). Test mode: a test number from +447700900000 to +447700900999.
from
string
Optional: which of your project's numbers to call from. Default: your first active number. The person sees this number and can call it back.
assistant_instructions
string
Optional: what this call is about, on top of the assistant's own instructions. For example: "Confirm the appointment of Sam Taylor tomorrow at 10:00 at 40 Main Street. If Sam cannot make it, ask for a better day and say we will confirm by SMS." Treated as context, never as a way to change the rules: the assistant always says it is a digital assistant.
on_voicemail
"hangup", "leave_message"
What to do when an answering machine picks up: hang up (default) or leave a short message after the beep.
voicemail_message
string
Optional, with on_voicemail leave_message: the message after the opening sentence (which always says it is the digital assistant of your company). Default: we tried to reach you, call us back on this number.
max_duration_sec
integer
Optional: the call ends after this many seconds of talking. Default 600, at most 1200.
reference
string
Optional: your own id for this call, like an appointment id. Comes back on the call and in its webhooks.
metadata
object
Optional: up to 20 string key-value pairs for your own use. Come back on the call and in its webhooks; the assistant never sees them.
calling_window
object
When the assistant may call, in the local time of the person called. Default 09:00 to 20:00, Monday to Saturday. Never before 08:00 or after 21:00.
purpose
"transactional", "marketing"
Why you call. transactional (default): an answer to the person's own question, confirming or reminding of an appointment, an order or delivery status, or a callback they asked for. marketing: anything that sells or promotes; only allowed when consent from this person is recorded (POST /api/v1/call-consents), otherwise consent_required. Recorded with the call.
Consent from one person to receive marketing calls from this project.
Field
Type
phone_number (required)
string
The number of the person who gave consent, in E.164. Stored only as a keyed hash plus its last two digits.
source (required)
"form", "call", "import", "manual"
Where the consent came from: form (a form the person filled in), call (they said yes on a call), import (from your own records), manual (recorded by hand).
evidence (required)
string
A reference to the proof, so it can be shown later: a form entry id, a call id, a file and row, or a short note. Not the proof itself and no other personal data.
consented_at
string (date-time)
When the person gave consent (ISO 8601). Default: now. Cannot be in the future.
The recipient in E.164. Live: a mobile number in a country your project can text (otherwise destination_not_allowed with a reason). Test mode: a test number from +447700900000 to +447700900999.
body (required)
string
The text. At most 6 SMS segments: 918 characters in plain text (GSM-7), 402 with an emoji or another character outside GSM-7. Typographic quotes and dashes are replaced by plain ones.
from
string
Optional sender name: 1 to 11 letters, digits and spaces, with at least one letter. Default: your project name. A phone number is not possible.
reference
string
Optional: your own id for this message, like an order number. Comes back in the message and its webhooks.
A new chat conversation with the assistant of the project.
Field
Type
channel
"chat"
The channel. Only chat for now; it is also the default.
reference
string
Optional: your own id for this conversation, like a session or ticket id. Comes back in the conversation and its webhook.
metadata
object
Optional: up to 20 keys with a string (at most 500 characters), number or boolean, for your own use. Stored and returned as is; the assistant never sees it.
customer
object
Optional: what your app already knows about the customer, for example from their login. The assistant may use it to address the customer and to confirm how to reach them; it is treated as data, never as instructions.
A name for your own overview, like the website it is for.
display_name
string or null
The name the assistant uses for the business in the chat window, like "Knip": in the greeting ("You are chatting with the digital assistant of Knip.") and in its answers. Leave it out or null to use the name of the site the widget is on, and otherwise the project name.
allowed_origins (required)
string[]
The exact origins that may use this token, like https://www.example.com (scheme and host, no path, no wildcard), at most 10. A live token needs at least one https origin. A test token may always be used from localhost.
turnstile
boolean
Ask for a bot check before a conversation starts. Default true.
max_conversations_per_day
integer
New conversations per day through this token. Default 200, at most 1000.
Change a public chat token. Fields you leave out stay as they are.
Field
Type
name
string
display_name
string or null
The name the assistant uses for the business in the chat window, like "Knip": in the greeting ("You are chatting with the digital assistant of Knip.") and in its answers. null or an empty string goes back to the name of the site the widget is on, and otherwise the project name.
A public chat token for the chat widget on a website.
Field
Type
id (required)
string (uuid)
The id of the public chat token. Use it to change or revoke the token.
name (required)
string
Your name for the token, like the website it is for.
display_name (required)
string or null
The name the assistant uses for the business in the chat window and its greeting, or null: then the name of the site the widget is on, and otherwise the project name.
token (required)
string
The public chat token: hm_pub_ (live) or hm_pubtest_ (test project) plus 24 characters. Not a secret: it goes in the page source of your website.
mode (required)
"live", "test"
live or test, from the project the key belongs to.
allowed_origins (required)
string[]
The exact origins that may use the token, like https://www.example.com. A test token also works from localhost.
turnstile (required)
boolean
Whether a visitor passes a bot check before a conversation starts.
max_conversations_per_day (required)
integer
New conversations per day through this token, counted until midnight, Europe/Amsterdam.
revoked (required)
boolean
true once the token is revoked: the widget stops working right away, also in open conversations.
revoked_at (required)
string or null (date-time)
When the token was revoked.
last_used_at (required)
string or null (date-time)
When a browser last used the token, to the minute.
created_at (required)
string (date-time)
When the token was created.
embed_code (required)
string
The script tag for your website, with this token in it. Paste it before .
kind (required)
"chat", "form"
chat: the chat widget, the cart, the checkout and forms. form: only forms that post to a collection with public_write (made by Hypamoon for a form on a site); it cannot chat.
The address: https://.hypamoon.app. 3 to 40 characters, lowercase letters, digits and hyphens. Leave it out to derive it from name (with a short suffix if that one is taken).
name (required)
string
A name for your own overview, like the business or the page.
files (required)
object[]
All files of the site: complete, ready for a browser, with at least /index.html. At most 50 files, 1 MB per file (also PDF, video and audio) and 5 MB together.
password
string or null
Optional. One shared password for the whole site (4 to 72 characters): visitors enter it once per browser, like password protection on a preview. Not a login system: no accounts, no per-visitor access. null or an empty string removes it. Never returned; the site shows password_protected instead.
media
string[]
Optional. Files from the project's media library (list_media) to put on the site, without uploading them again: folders like "portfolio/" (with subfolders), file ids, or paths like /media/logo.webp. Each file lands at its library path with its 640 version next to it; private originals never go on a site. A file you also send in files wins.
message
string
Optional, but always send it: what changed and why, in one short sentence (up to 200 characters), like a commit message: "New opening hours for the summer" or "Spring menu, the user asked for it". It shows in the version history; without it Hypamoon writes a short summary like "Changed /index.html".
Change a site: a new name, new files, a password (or null to remove it), or a combination. The slug stays the same. The usual way: send only the changed files with keep_existing: true and if_match_version, images preferably as source_url.
Field
Type
name
string
A name for your own overview, like the business or the page.
files
object[]
Usually with keep_existing: true: only the files you add or change; every other file stays as it is, so never send images that are already on the site again. Without keep_existing, files is the complete new set of files: a file you leave out is removed, and the answer lists it in warnings.
keep_existing
boolean
true: a partial update. Only the files in files are added or replaced, delete_paths removes files, and every other file stays. Needs if_match_version. The limits, the checks and four-eyes approval apply to the whole site as it will be after the update.
delete_paths
string[]
Only with keep_existing: true. Paths of existing files to remove, like /old.html. A path that is not on the site is an error.
media
string[]
Optional. Files from the project's media library (list_media) to put on the site, without uploading them again: folders like "portfolio/" (with subfolders), file ids, or paths like /media/logo.webp. Each file lands at its library path with its 640 version next to it; private originals never go on a site. A file you also send in files wins.
if_match_version
string
The version of the site you are changing, from list_sites, get_site or the last deploy (version). Required with keep_existing. If the site changed since, you get 409 version_conflict with current_version and nothing changes: read the site again and redo your change.
password
string or null
Optional. One shared password for the whole site (4 to 72 characters): visitors enter it once per browser, like password protection on a preview. Not a login system: no accounts, no per-visitor access. null or an empty string removes it. Never returned; the site shows password_protected instead.
message
string
Optional, but always send it: what changed and why, in one short sentence (up to 200 characters), like a commit message: "New opening hours for the summer" or "Spring menu, the user asked for it". It shows in the version history; without it Hypamoon writes a short summary like "Changed /index.html".
preview
boolean
true: do not put this change live. It becomes a preview version at a secret link (preview.url in the answer, not indexed), and the live site stays as it is. Put it live later with promote_site_version. Only files (with keep_existing as usual), no name or password. Use it for large changes the user should see first.
confirm_replace
boolean
Only after the user agreed. A deploy without keep_existing that removes more than half of the files, makes the site more than half smaller, or rewrites most of /index.html while other files disappear, is held as a preview instead of going live (guardrail in the answer). Send confirm_replace: true only when the user has said yes to replacing the site like that; then it goes live right away.
The files of the site, sorted by path, without their content.
created_at (required)
string (date-time)
When the site was created.
updated_at (required)
string (date-time)
When the site or its files last changed.
version (required)
string
The version that is live now. Changes on every update; every file on the site carries it as its ETag, so you can check that a page shows this version.
password_protected (required)
boolean
true when visitors have to enter the site's password first (set with password on POST /sites or PUT /sites/{id}). The password itself is never returned.
test (required)
boolean
true for a site of a test project. It is public all the same.
taken_offline (required)
object or null
null while the site is live. Set when Hypamoon took the site offline because of abuse: visitors get a page that says it is unavailable, the files are kept, and PUT and DELETE return 403 site_offline. To object, the owner replies to the email or writes to hello@hypamoon.com.
warnings
one of[]
Only in the answer of a deploy, and only when there is something to tell: files a deploy without keep_existing removed, and build hints. Warnings never block a deploy.
Optional. A site of this project (from deploy_site or list_sites) to serve on the domain and on www.
method
"nameservers", "cname"
nameservers (default): the user points the domain's nameservers to the two we return; existing records, mail included, are copied first. cname: fallback when the nameservers cannot change: only www, through a CNAME and a TXT record at the user's DNS provider.
dkim_selectors
string[]
Optional. The DKIM selector(s) of the user's mail provider (like google or selector1), so their DKIM records are copied too.
replace_existing
boolean
Optional, default false. For a subdomain of a domain this project has on Hypamoon's nameservers: if the subdomain still has an A, AAAA or CNAME record pointing elsewhere (like an old host), the call is refused with what is live there now. true replaces those records (only after the user agreed); undo_dns_change puts them back for 30 days.
Registration period in years. Most extensions only allow 1.
accept_registry_terms (required)
boolean
Must be true: the user (the registrant) explicitly accepted the terms of the registry at registry_terms_url from check_domain.
max_price_eur (required)
number
The highest total the user agreed to pay for all years together, as shown by check_domain. Refused if the price is higher.
accept_price_eur
number
Only for a name with a higher registry price (premium true in check_domain): the price_eur for the first year exactly as check_domain showed it and the user confirmed. Without it, or if the price changed, nothing is bought.
site_id
string or null (uuid)
Optional. A site of this project to serve on the domain once it is active.
Tells browsers to always open the domain over https (HSTS). off (default): no such instruction. on: for six months. on_with_subdomains: the same, for every subdomain too. Once on, browsers that visited the site remember it for about six months and will only open the domain over https, even if you turn it off again. Only choose on_with_subdomains if every subdomain of the domain (like mail or shop) works over https too. Only possible when the site is live on the domain with a valid certificate.
auto_renew
boolean
Only for a domain bought or transferred through Hypamoon. true (default): Hypamoon renews it for a year before it expires, from the project's balance. false: it is not renewed and expires on its expiry date.
primary
"apex", "www"
Which address of the domain is the main one. apex (default): example.com, and www.example.com redirects to it. www: www.example.com, and example.com redirects to it. The redirect is permanent (301) and keeps the path and query; canonical links and the sitemap follow the main address. Only for method nameservers.
The name: @ for the domain itself, or a subdomain like www, mail or _dmarc.
content
string
The value: an IP address, a hostname, or the text of a TXT record. For SRV: 'priority weight port target'. For CAA: 'flags tag "value"'.
ttl
integer
Seconds; 1 means automatic.
priority
integer
For MX: the priority (lower is preferred).
proxied
boolean
For A, AAAA and CNAME: through the CDN (true) or DNS only (false, default).
delete
boolean
true deletes the record with record_id.
confirm_overwrite
boolean
Optional, default false. A change that replaces or deletes a record that is live now (on an active domain) is refused first with a preflight: what changes, what is live there now, and whether mail is affected. Show it to the user; only after they agree, call again with true. undo_dns_change reverses it for 30 days.
Four-eyes approval is on for this project: the deploy waits until another owner or builder approves it in the portal (or with approve_deploy). Nothing changed on the live site yet.
Field
Type
object (required)
"deploy_approval"
id (required)
string (uuid)
The id of the request; pass it as approval_id to approve_deploy or reject_deploy.
Optional. The id of a restore point from dns_snapshots in get_domain. Without it: the newest restore point of the previous DNS (a zone file, or the scan when the domain was connected).
The transfer code (auth code, EPP code; for .nl the token) the user got from their current registrar. Required for most extensions. Never stored.
accept_registry_terms (required)
boolean
Must be true: the user (the registrant) explicitly accepted the terms of the registry at registry_terms_url from check_domain.
max_price_eur (required)
number
The highest amount the user agreed to pay for the transfer. Refused if the price is higher.
dns
"hypamoon", "keep"
hypamoon (default): the nameservers switch to the DNS zone here when the transfer completes; run preflight_domain first. keep: the nameservers stay as they are; switch later with switch_domain_nameservers.
site_id
string or null (uuid)
Optional. A site of this project to serve on the domain once it is active.
dkim_selectors
string[]
Optional. The DKIM selector(s) of the user's mail provider, so their DKIM records are copied too.
ignore_preflight
boolean
Optional, default false. With dns hypamoon the nameservers switch when the transfer completes, so the transfer is refused (confirmation_required, nothing charged) while preflight_domain is not ready. true transfers anyway, only when the user accepts the listed differences.
hypamoon: the nameservers of the DNS zone here. previous: back to the nameservers the domain had before (to undo the move).
ignore_preflight
boolean
Optional, default false. With use hypamoon, the switch is refused while preflight_domain is not ready; true switches anyway (only when the user accepts the listed differences).
The https address of the WordPress site, on a domain this project has proven it owns (like https://www.example.com).
site_id
string (uuid)
Optional. An existing site of this project to put the import in. Without it, a new site is created.
keep_existing
boolean
Only with site_id. true: a partial update. The imported files are added or replaced and every other file of the site stays; images already on the site are not fetched again. Needs if_match_version.
if_match_version
string
With keep_existing: the version of the site from get_site or list_sites. If the site changed since, nothing changes (409).
name
string
Optional. A name for the new site; default the site's own name.
slug
string
Optional. The address of a new site, like deploy_site's slug.
max_posts
integer
Optional. Read at most this many posts, newest first (default all, at most 500).
Create a drop link: a page where the user uploads files and whole folders in a browser.
Field
Type
site_id
string (uuid)
Optional. The site the files are for (from deploy_site or list_sites). Leave it out for a new site.
strip
"location", "all", "none"
Optional. What the upload page removes, in the user's browser before upload, from photos (JPEG, PNG, WebP) it keeps as they are: originals the user chooses to keep, and photos that would not get smaller as WebP. location (default) removes only the GPS location and keeps camera, date, orientation and copyright; all removes all metadata (the orientation stays so photos stay upright); none keeps the files as they are. Web versions are re-encoded and carry no metadata at all. Use all or none only when the user asks for it.
folder
string
Optional. The folder under /media/ where the files land, like portfolio or menu/2026. Folders the user drags keep their structure inside it: /media/portfolio/project-12/03.webp. Leave it out to use the user's own folder names.
as_is
boolean
Optional. For moving an existing site (like WordPress uploads): true keeps every file exactly as it is, with no web versions, the same name and extension, and the exact folder path the user drags, under /media//.
A drop link and the files the user uploaded with it.
Field
Type
id (required)
string (uuid)
The id of the drop link; pass it to get_drop (GET /sites/drops/{id}).
status (required)
"open", "done", "expired"
open: waiting for the user. done: the user uploaded the files. expired: 24 hours passed; create a new one.
site_id (required)
string or null (uuid)
The site the link is for, or null.
created_at (required)
string
expires_at (required)
string
When the link stops working (24 hours after it was made).
completed_at (required)
string or null
When the user finished uploading, or null.
images_only (required)
boolean
True when the link only takes images (an editor made it).
as_is
boolean
True when files are kept exactly as they are (for moving a site): no web versions, same names, and paths that follow the user's folders exactly.
strip (required)
"location", "all", "none"
What the upload page removes from photos before upload: location (the default, only the GPS location), all (all metadata) or none. Web versions are re-encoded and carry no metadata at all.
folder (required)
string or null
The folder under /media/ the files land in, or null for the user's own folder names.
max_files (required)
integer
How many files the user can add with this link (a photo with its versions counts once).
files (required)
object[]
The files that arrived, in order. Private originals are not listed.
originals_kept (required)
integer
How many originals were kept. They are private: never on a site, only downloadable in the portal.
site_version (required)
object or null
With site_id, after the user pressed Done: the new version of the site with these files (waiting: it waits for four-eyes approval). The files are already on the site; send only HTML with keep_existing.
deploy_files (required)
object[]
The same files in the form deploy_site takes: add them to files, without content.
url
string
Only when the link is created: the page to give the user. It is not shown again.
Where the file goes on a site when you add it with deploy_site media, like /media/portfolio/project-12/03.webp.
folder (required)
string
The folder under /media/, empty for /media/ itself.
name (required)
string
The file name in the library.
original_name (required)
string or null
The name on the user's computer, when it differs.
content_type (required)
string
bytes (required)
integer
width (required)
integer or null
height (required)
integer or null
alt (required)
string or null
The alt text, set with update_media.
url (required)
string or null
Where the file is served, on the first site that has it at this path. null: it is on no site yet; it is private until you add it with deploy_site media.
variants (required)
object
For photos: 640, the small version for srcset and thumbnails.
srcset (required)
string or null
A ready srcset for an img tag, once both versions are on a site.
original_kept (required)
boolean
The full-size original is kept privately. It never goes on a site; the user downloads it in the portal.
sites (required)
object[]
The sites that use this file right now, with the path and url there.
source (required)
"upload", "portaal", "site", "import"
upload: an upload link; portaal: the portal; site: it was already on a site; import: an import.
Optional. The folder under /media/ where the files land, like portfolio or menu/2026. Folders the user drags keep their structure inside it: /media/portfolio/project-12/03.webp. Leave it out to use the user's own folder names.
site_id
string (uuid)
Optional. With a site, Done also puts the files on that site as a new version.
images_only
boolean
Optional. true: the page only takes images.
strip
"location", "all", "none"
Optional. What the upload page removes, in the user's browser before upload, from photos (JPEG, PNG, WebP) it keeps as they are: originals the user chooses to keep, and photos that would not get smaller as WebP. location (default) removes only the GPS location and keeps camera, date, orientation and copyright; all removes all metadata (the orientation stays so photos stay upright); none keeps the files as they are. Web versions are re-encoded and carry no metadata at all. Use all or none only when the user asks for it.
The files of the site, sorted by path, without their content.
created_at (required)
string (date-time)
When the site was created.
updated_at (required)
string (date-time)
When the site or its files last changed.
version (required)
string
The version that is live now. Changes on every update; every file on the site carries it as its ETag, so you can check that a page shows this version.
password_protected (required)
boolean
true when visitors have to enter the site's password first (set with password on POST /sites or PUT /sites/{id}). The password itself is never returned.
test (required)
boolean
true for a site of a test project. It is public all the same.
taken_offline (required)
object or null
null while the site is live. Set when Hypamoon took the site offline because of abuse: visitors get a page that says it is unavailable, the files are kept, and PUT and DELETE return 403 site_offline. To object, the owner replies to the email or writes to hello@hypamoon.com.
warnings
one of[]
Only in the answer of a deploy, and only when there is something to tell: files a deploy without keep_existing removed, and build hints. Warnings never block a deploy.
visibility
"public", "unlisted"
public or unlisted, as before the site was deleted.
restore_notes (required)
string[]
What did not come back with the site (an app on it, a custom domain), and how to set it up again.
The name of the collection, unique in the project: 2 to 40 characters, a lowercase letter first, then lowercase letters, digits and underscores (contact, signups, menu). A form posts to it by this name.
description
string
What the collection is for, for your own overview.
access
object
Who may use the collection besides your API key. Leave it out for private: all false.
triggers
object
What happens when a document is added, through the API or a form.
email_routing
object
Where the email of email_on_insert goes, instead of the billing email address: at most 5 different addresses in to and rules together. An address must be confirmed before it gets anything: the billing email, the email of a project member and an address on a domain connected to the project are confirmed right away; any other address gets one email with a link to confirm (live projects only). When none of the chosen addresses is confirmed, the email goes to the billing email address. Reply-To stays the email field of the entry.
schema
object
Optional JSON Schema (a subset of draft 2020-12, at most 16 kB) that every document must match, with type "object" at the root. Supported: type, properties, required, additionalProperties, items, minItems, maxItems, enum, const, minLength, maxLength, pattern, format (email, uri, date, date-time, time), minimum, maximum, exclusiveMinimum, exclusiveMaximum, title and description.
Change a collection. Fields you leave out stay as they are.
Field
Type
name
string
The name of the collection, unique in the project: 2 to 40 characters, a lowercase letter first, then lowercase letters, digits and underscores (contact, signups, menu). A form posts to it by this name.
description
string or null
What the collection is for, for your own overview.
access
object
Who may use the collection besides your API key. Leave it out for private: all false.
triggers
object
What happens when a document is added, through the API or a form.
email_routing
object or null
Where the email of email_on_insert goes, instead of the billing email address: at most 5 different addresses in to and rules together. An address must be confirmed before it gets anything: the billing email, the email of a project member and an address on a domain connected to the project are confirmed right away; any other address gets one email with a link to confirm (live projects only). When none of the chosen addresses is confirmed, the email goes to the billing email address. Reply-To stays the email field of the entry.
schema
object or null
Optional JSON Schema (a subset of draft 2020-12, at most 16 kB) that every document must match, with type "object" at the root. Supported: type, properties, required, additionalProperties, items, minItems, maxItems, enum, const, minLength, maxLength, pattern, format (email, uri, date, date-time, time), minimum, maximum, exclusiveMinimum, exclusiveMaximum, title and description.
A conversation as the widget sees it: only what concerns the visitor. No metadata, customer details or summary; your backend reads those with GET /conversations/{id}.
Field
Type
id (required)
string (uuid)
The id of the conversation.
channel (required)
"widget"
Always widget: started through a public chat token.
status (required)
"open", "closed"
open: takes messages. closed: takes no more messages.
language (required)
string
The language the assistant opened in.
turns (required)
integer
Visitor messages answered so far.
max_turns (required)
integer
After this many turns the conversation closes (close_reason turn_limit).
created_at (required)
string (date-time)
When the conversation started.
last_message_at (required)
string or null (date-time)
When the last message was sent.
closed_at (required)
string or null (date-time)
When the conversation closed.
close_reason (required)
"closed", "inactive", "turn_limit", null
Why it closed: inactive after 30 minutes without a message, or turn_limit.
test (required)
boolean
true for a conversation through a test token (hm_pubtest_).
A conversation as the widget sees it: only what concerns the visitor. No metadata, customer details or summary; your backend reads those with GET /conversations/{id}.
The secret of this conversation, for this visitor only. Send it as X-Conversation-Token on every next request about this conversation. Keep it in the page (or sessionStorage), never in a URL.
expires_at (required)
string (date-time)
When conversation_token stops working (6 hours after the start).
A conversation as the widget sees it: only what concerns the visitor. No metadata, customer details or summary; your backend reads those with GET /conversations/{id}.
One line in the ledger of the prepaid balance. The same shape in GET /balance and the balance webhooks.
Field
Type
id (required)
string
The id of the transaction.
type (required)
"top_up", "charge", "refund", "adjustment"
top_up: money added with a payment. charge: usage deducted. refund: a charge given back (an SMS the carrier refused, for example). adjustment: a correction by Hypamoon support.