Skip to content
Hypamoon
API docs

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.

All paths are relative to the base URL:

https://api.hypamoon.com/api/v1

Endpoints

Method Path Scope
GET /domains sites:read List the domains
POST /domains sites:write Connect a domain the user owns
GET /domains/health sites:read List watched domains and their health
POST /domains/watch sites:write Watch a domain, or park it
GET /domains/{id}/dns-cleanup sites:read Suggest a cleanup of left-over DNS records
POST /domains/{id}/dns-cleanup sites:write Remove left-over DNS records in one step
POST /domains/{id}/dns-cleanup/restore sites:write Put the records of a cleanup back
GET /domains/check sites:read Check whether a domain is available
GET /domains/suggestions sites:read Suggest available domains
POST /domains/register sites:write Register a domain
GET /domains/{id} sites:read Get a domain
PATCH /domains/{id} sites:write Update the settings of a domain
DELETE /domains/{id} sites:write Disconnect a domain
POST /domains/{id}/activate sites:write Check a domain and attach its site
PUT /domains/{id}/site sites:write Set the site of a domain
POST /domains/{id}/authcode sites:write Get the transfer code of a domain
GET /domains/{id}/dns-records sites:read List the DNS records
POST /domains/{id}/dns-records sites:write Add, change or delete a DNS record
POST /domains/{id}/dns-records/undo sites:write Undo the last DNS change
GET /domains/diagnose sites:read Check a domain and suggest fixes
POST /domains/{id}/zone-file sites:write Import a zone file
GET /domains/{id}/preflight sites:read Compare the old and the new DNS
POST /domains/{id}/restore sites:write Restore the DNS records
POST /domains/{id}/nameservers sites:write Switch the nameservers
POST /domains/transfer sites:write Transfer a domain to Hypamoon
GET /project project:read Get the project
PUT /project/company project:write Set the company or personal details
GET /advice project:read Get the blueprint for a kind of application
POST /project/move-to-live project:write Move sites from the test project to the live project
POST /project/move-to-live/undo project:write Undo a move to live
GET /errors project:read List recent errors
GET /balance project:read Get the prepaid balance
GET /numbers numbers:read List the numbers
POST /numbers numbers:write Order a number
GET /numbers/available numbers:read Search for numbers to order
DELETE /numbers/{id} numbers:write Release a test number
GET /assistant assistant:write Get the assistant
PUT /assistant assistant:write Set the assistant
GET /numbers/{id}/call-plan numbers:read Get the call plan of a number
PUT /numbers/{id}/call-plan numbers:write Set the call plan of a number
GET /calls calls:read List the calls
POST /calls calls:write Call someone with your assistant
GET /calls/{id} calls:read Get a call with transcript and summary
GET /call-consents calls:read List recorded consent for marketing calls
POST /call-consents calls:write Record consent for marketing calls
DELETE /call-consents/{id} calls:write Revoke consent for marketing calls
GET /webhooks webhooks:write List the webhook endpoints
POST /webhooks webhooks:write Create a webhook endpoint
DELETE /webhooks/{id} webhooks:write Delete a webhook endpoint
POST /webhooks/{id}/enable webhooks:write Switch a webhook endpoint back on
POST /webhooks/{id}/test webhooks:write Send a test event
GET /messages messages:read List the messages
POST /messages messages:write Send an SMS
GET /messages/{id} messages:read Get a message
GET /conversations chat:read List the conversations
POST /conversations chat:write Start a chat conversation
GET /conversations/{id} chat:read Get a conversation with its messages and summary
POST /conversations/{id}/messages chat:write Send a customer message and get the reply
POST /conversations/{id}/close chat:write Close a conversation
GET /chat-tokens chat:read List the public chat tokens
POST /chat-tokens chat:write Create a public chat token for the chat widget
GET /chat-tokens/{id} chat:read Get a public chat token
PATCH /chat-tokens/{id} chat:write Change a public chat token
DELETE /chat-tokens/{id} chat:write Revoke a public chat token
GET /public/config Get the widget settings (public)
POST /public/conversations Start a conversation from the widget (public)
GET /public/conversations/{id} Get the visitor's conversation (public)
POST /public/conversations/{id}/messages Send a visitor message and get the reply (public)
GET /public/challenge The bot check page for the widget (public)
GET /sites sites:read List the sites
POST /sites sites:write Publish a static site
POST /sites/uploads sites:write Get upload links for large files
POST /sites/drops sites:write Create a drop link where the user uploads files in a browser
GET /sites/drops/{id} sites:read Get a drop link and the files the user uploaded
GET /sites/{id} sites:read Get a site
PUT /sites/{id} sites:write Change the files, the name or the password of a site
DELETE /sites/{id} sites:write Delete a site
GET /sites/deleted sites:read List the deleted sites that can be restored
POST /sites/{id}/restore sites:write Restore a deleted site
GET /sites/address-suggestions sites:read Suggest an address for a site
PUT /sites/{id}/address sites:write Change the address or visibility of a site
POST /sites/{id}/rotate-secret-url sites:write Make a new secret link for an unlisted site
GET /sites/{id}/versions sites:read List the versions of a site
GET /sites/{id}/diff sites:read Show what changed between two versions
POST /sites/{id}/rollback sites:write Roll a site back to an earlier version
POST /sites/{id}/restore-files sites:write Restore a few files from an earlier version
POST /sites/{id}/versions/{versionId}/promote sites:write Publish a preview
PUT /sites/{id}/versions/{versionId}/label sites:write Label a version
GET /sites/{id}/store-readiness sites:read Check whether a site is ready for the App Store and Google Play
GET /sites/{id}/store-listing sites:read Draft the store listing for a site's app
GET /sites/{id}/privacy-answers sites:read Answers for Apple App Privacy and Google Play Data safety
POST /sites/{id}/app-export sites:write Export a Capacitor app project for a site
GET /sites/{id}/readiness sites:read Check what works on a site and what is missing before going live
POST /search-console/verify sites:write Verify a site or domain in Google Search Console
GET /sites/{id}/stats sites:read Get the visits of a site
GET /sites/{id}/fetch sites:read Fetch a page of a site or app, live
POST /sites/import sites:write Import an existing site
POST /sites/inspect-wordpress sites:read Inspect a WordPress site
POST /sites/import-wordpress sites:write Import a WordPress site
GET /media sites:read List the media library
POST /media/links sites:write Create an upload page for the media library
GET /media/{id} sites:read Get a file from the media library
PATCH /media/{id} sites:write Rename or move a file, or set its alt text
DELETE /media/{id} sites:write Delete a file from the media library
GET /collections data:read List the collections
POST /collections data:write Create a collection
GET /collections/{id} data:read Get a collection
PATCH /collections/{id} data:write Change a collection
DELETE /collections/{id} data:write Delete a collection
GET /collections/{id}/documents data:read List the documents
POST /collections/{id}/documents data:write Add a document
GET /collections/{id}/documents/{docId} data:read Get a document
PATCH /collections/{id}/documents/{docId} data:write Change a document
DELETE /collections/{id}/documents/{docId} data:write Delete a document
GET /public/collections/{name}/documents Read the newest documents (public)
POST /public/collections/{name}/documents Send a form entry (public)
POST /test/calls calls:read Simulate a call
POST /wordpress/connect-codes data:write Create a code to connect a WordPress site
GET /wordpress/connections data:read List connected WordPress sites
GET /wordpress/connections/{id} data:read Get the inventory of a connected WordPress site
DELETE /wordpress/connections/{id} data:write Revoke a WordPress connection
PUT /wordpress/connections/{id}/selection data:write Choose what moves from a WordPress site
GET /wordpress/connections/{id}/suggestion data:read Read the compact inventory and the open suggestion
PUT /wordpress/connections/{id}/suggestion data:write Suggest a selection for a WordPress site
GET /wordpress/connections/{id}/embeds data:read Read the chat and forms on a WordPress site
PUT /wordpress/connections/{id}/embeds data:write Put the chat and forms on a WordPress site
POST /wordpress/connect none Connect the plugin with a code (plugin)
GET /wordpress/connection Read the connection (plugin)
DELETE /wordpress/connection Disconnect (plugin)
PUT /wordpress/connection/inventory Send the inventory (plugin)
GET /wordpress/connection/selection Pull the selection (plugin)
PUT /wordpress/connection/selection Change the selection (plugin)
DELETE /wordpress/connection/suggestion Close the suggestion of the chat (plugin)
GET /wordpress/connection/embeds Pull the chat and forms for the site (plugin)
PUT /wordpress/connection/embeds Turn the chat on or off (plugin)
POST /wordpress/exports Start an export round (plugin)
POST /wordpress/exports/{id}/batches Send a batch (plugin)
POST /wordpress/exports/{id}/finish Finish an export round (plugin)
POST /mcp project:read Model Context Protocol endpoint
GET /openapi.json none This OpenAPI document
GET /status none Check whether Hypamoon is working
GET /agents project:read List the agents
POST /agents project:write Create an agent
GET /agents/{id} project:read Get an agent
PATCH /agents/{id} project:write Change an agent
DELETE /agents/{id} project:write Delete an agent
GET /agents/{id}/runs project:read List the runs of an agent
POST /agent-actions/{id}/decide project:write Approve or reject an agent action
GET /apps sites:read List the apps
POST /apps sites:write Deploy an app
GET /apps/{id} sites:read Get an app
DELETE /apps/{id} sites:write Delete an app
POST /apps/{id}/rollback sites:write Roll an app back
GET /apps/{id}/logs sites:read Read the log of an app
GET /apps/{id}/users sites:read List the users of an app
POST /apps/{id}/users sites:write Invite someone to an app
GET /apps/{id}/mails sites:read List the emails an app sent
POST /apps/{id}/users/import sites:write Import users from a collection
PATCH /apps/{id}/users/{userId} sites:write Change the role of a user
DELETE /apps/{id}/users/{userId} sites:write Remove a user from an app
POST /apps/{id}/users/{userId}/export sites:write Export everything of one user (GDPR)
DELETE /apps/{id}/users/{userId}/data sites:write Delete everything of one user (GDPR)
PUT /apps/{id}/tables/{table}/owner sites:write Make a table private per user
DELETE /apps/{id}/tables/{table}/owner sites:write Make a per-user table shared again
GET /apps/{id}/files sites:read List the files of an app
DELETE /apps/{id}/files/{fileId} sites:write Delete a file from an app
GET /apps/{id}/migrations sites:read List the migrations of an app
POST /apps/{id}/migrations sites:write Apply migrations to the database of an app
GET /apps/{id}/schema sites:read Read the schema of an app's database
GET /apps/{id}/code sites:read Read the server code of an app
POST /apps/{id}/query sites:read Run one read-only query on an app's database
GET /app-files data:read List the files (from an app)
POST /app-files data:write Get an upload link (from an app)
GET /app-files/{id} data:read Get a download link (from an app)
DELETE /app-files/{id} data:write Delete a file (from an app)
GET /app-users data:read List the users (from an app, as admin)
POST /app-users data:write Invite someone (from an app, as admin)
GET /app-users/{id} data:read Get one user (from an app, as admin)
PATCH /app-users/{id} data:write Change a user's role (from an app, as admin)
DELETE /app-users/{id} data:write Remove a user (from an app, as admin)
GET /emails messages:read List sent emails
POST /emails messages:write Send one email
GET /app-emails data:read List the emails of an app (from the app)
POST /app-emails data:write Send one email (from an app)
POST /emails/batch messages:write Send a batch of emails
POST /app-emails/batch data:write Send a batch of emails (from an app)
GET /emails/{id} messages:read Get one email
GET /app-emails/{id} data:read Get one email of an app (from the app)
GET /email-domains sites:read List sending domains
POST /email-domains sites:write Add a sending domain
GET /email-domains/{id} sites:read Get a sending domain
DELETE /email-domains/{id} sites:write Remove a sending domain
POST /email-domains/{id}/verify sites:write Check a sending domain now
POST /email-recipients messages:write Confirm a recipient (owner only)
POST /email-recipients/check messages:read Check email addresses
GET /email-deliverability messages:read Get delivery per sending domain
GET /email-domains/{id}/bimi sites:read Get the inbox logo (BIMI) of a sending domain
POST /email-domains/{id}/bimi sites:write Set the inbox logo (BIMI) of a sending domain
POST /email-dedicated-ip messages:write Request a dedicated sending IP
GET /email-templates messages:read List email templates
POST /email-templates messages:write Create an email template
GET /email-templates/{id} messages:read Get an email template
PATCH /email-templates/{id} messages:write Update an email template
DELETE /email-templates/{id} messages:write Delete an email template
POST /email-templates/{id}/preview messages:read Preview an email template
GET /email-automations messages:read List email automations
POST /email-automations messages:write Create an email automation
PATCH /email-automations/{id} messages:write Update an email automation
DELETE /email-automations/{id} messages:write Delete an email automation
GET /scheduled-emails messages:read List scheduled emails
GET /scheduled-emails/{id} messages:read Get a scheduled email
POST /scheduled-emails/{id}/cancel messages:write Cancel a scheduled email
GET /email-sequences messages:read List email sequences
POST /email-sequences messages:write Create an email sequence
GET /email-sequences/{id} messages:read Get an email sequence
DELETE /email-sequences/{id} messages:write Delete an email sequence
POST /email-sequences/{id}/enrollments messages:write Add someone to an email sequence
POST /email-sequences/{id}/stop messages:write Stop an email sequence for someone
GET /clients clients:read List your client projects
POST /clients clients:write Set up a project for a client
POST /clients/access-requests clients:write Ask a client for access to their project
POST /clients/{id}/owner-invite clients:write Invite the client to own the project
DELETE /clients/{id} clients:write Stop working on a client project
POST /clients/{id}/transfer clients:write Hand over a client project
DELETE /clients/{id}/transfer clients:write Cancel a handover
POST /clients/{id}/restore clients:write Restore an ended client project
GET /clients/{id}/usage clients:read Get the usage of a client project
PUT /clients/{id}/payer clients:write Change who pays for a client project
PUT /clients/{id}/budget clients:write Set the monthly budget for a client project
GET /clients/{id}/invoice-lines clients:read Export invoice lines for a client project
POST /clients/{id}/payment-links clients:write Create a payment link for a client
POST /clients/{id}/subscriptions clients:write Create a subscription for a client
GET /clients/{id}/payments clients:read List payment links and subscriptions for a client
DELETE /clients/{id}/subscriptions/{subscriptionId} clients:write Cancel a client subscription
DELETE /clients/{id}/payment-links/{linkId} clients:write Withdraw an open payment link
GET /clients/{id}/activity clients:read Read the activity log of a client project
GET /agencies project:read List the agencies on this project
DELETE /agencies/{id} project:write Revoke an agency's access

Errors on every endpoint

Any request with an API key can return these. Errors listed under an endpoint come in addition to these.

Domains

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.

GET /domains

List the domains.

The domains of the project, newest first, each with its status, nameservers, site and next_step.

Scope: sites:read

200 The domains.

POST /domains

Connect a domain the user owns.

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.

Scope: sites:write

Request body

Field Type
domain (required) string The domain, like example.com.
site_id string or null (uuid) 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.
JSON
{
  "domain": "example.com",
  "site_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f"
}

200 The domain was already connected; its site is updated.

201 The domain, with nameservers, mail_check and next_step.

Errors

Besides the errors on every endpoint:

GET /domains/health

List watched domains and their health.

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.

Scope: sites:read

200 The watched domains.

POST /domains/watch

Watch a domain, or park it.

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.

Scope: sites:write

Request body

Field Type
domain (required) string The domain, like example.com. It can be registered anywhere.
expected_hosts "vercel", "netlify", "github", "heroku", "aws", "azure", "shopify", "fly", "render"[] 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.
JSON
{
  "domain": "example.com",
  "expected_hosts": [
    "netlify"
  ],
  "park": "page"
}

200 The watched domain, with its health.

202 Watched, waiting for the ownership proof in ownership.challenge.

Errors

Besides the errors on every endpoint:

GET /domains/{id}/dns-cleanup

Suggest a cleanup of left-over DNS records.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the domain.

200 The cleanup suggestion.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
409 conflict

POST /domains/{id}/dns-cleanup

Remove left-over DNS records in one step.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the domain.

Request body

Field Type
include_record_ids string[] Optional. Records labeled ask (from get_dns_cleanup) that the user chose to remove too. Records labeled keep are never removed; naming one is refused.
exclude_record_ids string[] Optional. Records labeled safe_to_remove that the user wants to keep this time.
JSON
{
  "include_record_ids": [
    "372e67954025e0ba6aaa6d586b9e0b59"
  ]
}

200 What was removed, what failed, the zone file and until when it can be restored.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

POST /domains/{id}/dns-cleanup/restore

Put the records of a cleanup back.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the domain.

Request body

Field Type
cleanup_id (required) string (uuid) The id of the cleanup to undo (recent_cleanups in get_dns_cleanup).
JSON
{
  "cleanup_id": "0b6f0e0e-2a7c-4d39-9a8e-1d3c5b7a9e21"
}

200 What was added again, what was already there and what failed.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

GET /domains/check

Check whether a domain is available.

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.

Scope: sites:read

Parameter In Type
domain (required) query string The domain, like example.com.

200 The check.

Errors

Besides the errors on every endpoint:

Status Type
422 validation_failed
502 upstream_error

GET /domains/suggestions

Suggest available domains.

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.

200 The available domains.

Errors

Besides the errors on every endpoint:

Status Type
422 validation_failed
502 upstream_error

POST /domains/register

Register a domain.

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.

Scope: sites:write

Parameter In Type
Idempotency-Key (required) header string A unique key for this purchase, like a UUID.

Request body

Field Type
domain (required) string The domain, like example.com.
years integer 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.
JSON
{
  "domain": "example.nl",
  "years": 1,
  "accept_registry_terms": true,
  "max_price_eur": 7.32
}

201 The registered domain.

Header
Idempotent-Replayed Present when this is the stored response to an earlier request with the same Idempotency-Key.

Errors

Besides the errors on every endpoint:

GET /domains/{id}

Get a domain.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the domain.

200 The domain.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PATCH /domains/{id}

Update the settings of a domain.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the domain.

Request body

Field Type
hsts "off", "on", "on_with_subdomains" 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.
JSON
{
  "hsts": "on"
}

200 The domain, with hsts, primary and registration.auto_renew.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

DELETE /domains/{id}

Disconnect a domain.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the domain.

200 Disconnected, with the previous nameservers to restore.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
409 conflict

POST /domains/{id}/activate

Check a domain and attach its site.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the domain.

200 The domain.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PUT /domains/{id}/site

Set the site of a domain.

Puts one of the project's sites on the domain and on www, or removes it with null.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the domain.

Request body

Field Type
site_id (required) string or null (uuid) The site to serve on the domain, or null for none.
JSON
{
  "site_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f"
}

200 The domain.

Errors

Besides the errors on every endpoint:

POST /domains/{id}/authcode

Get the transfer code of a domain.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the domain.

200 The transfer code.

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden
404 not_found
409 conflict
502 upstream_error

GET /domains/{id}/dns-records

List the DNS records.

The DNS records of a domain connected through its nameservers. managed_by_hypamoon marks the records that serve the site.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the domain.

200 The records.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
409 conflict

POST /domains/{id}/dns-records

Add, change or delete a DNS record.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the domain.

Request body

Field Type
record_id string Optional. The id from list_dns_records of the record to change or delete. Without it, a new record is added.
type (required) "A", "AAAA", "CNAME", "MX", "TXT", "SRV", "CAA", "NS" The record type.
name (required) string 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.
JSON
{
  "type": "TXT",
  "name": "@",
  "content": "google-site-verification=abc123"
}

200 The record (or deleted: true), what it replaced, and how to undo it.

Errors

Besides the errors on every endpoint:

POST /domains/{id}/dns-records/undo

Undo the last DNS change.

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).

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the domain.

Request body

Field Type
confirm boolean 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.
JSON
{
  "confirm": true
}

200 What was undone: removed, restored and skipped records.

Errors

Besides the errors on every endpoint:

GET /domains/diagnose

Check a domain and suggest fixes.

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.

200 The diagnosis: nameservers, names, mail, records, problems with a fix each, and user_message.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
422 validation_failed

POST /domains/{id}/zone-file

Import a zone file.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the domain.

Request body

Field Type
zone_file (required) string The zone file in BIND format, as exported from the current DNS provider (often called 'export zone' or 'download zone file'). At most 256 KB.
replace_existing boolean Optional, default false. true: for every name and type in the file, records here that are not in the file are deleted. false: records are only added.
JSON
{
  "zone_file": "$ORIGIN example.com.\n@ 3600 IN MX 10 mail.example.com.\n@ 3600 IN TXT \"v=spf1 include:_spf.example.net ~all\"\n",
  "replace_existing": false
}

200 What was added, removed, skipped and failed, with the restore point to undo it.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

GET /domains/{id}/preflight

Compare the old and the new DNS.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the domain.

200 The checks, the blocking ones first, and ready.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
409 conflict

POST /domains/{id}/restore

Restore the DNS records.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the domain.

Request body

Field Type
snapshot_id string (uuid) 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).
JSON
{}

200 What changed, the zone file and the way back.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

POST /domains/{id}/nameservers

Switch the nameservers.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the domain.

Request body

Field Type
use (required) "hypamoon", "previous" 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).
JSON
{
  "use": "hypamoon"
}

200 The domain, with the nameservers now set at the registry.

Errors

Besides the errors on every endpoint:

POST /domains/transfer

Transfer a domain to Hypamoon.

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.

Scope: sites:write

Parameter In Type
Idempotency-Key (required) header string A unique key for this transfer, like a UUID.

Request body

Field Type
domain (required) string The domain to move to Hypamoon, like example.com.
authcode string 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.
JSON
{
  "domain": "example.nl",
  "authcode": "AbC123xyz!",
  "accept_registry_terms": true,
  "max_price_eur": 7.32,
  "dns": "hypamoon"
}

201 The transfer completed.

Header
Idempotent-Replayed Present when this is the stored response to an earlier request with the same Idempotency-Key.

202 The transfer was requested and is in progress; transfer.status shows where it stands.

Errors

Besides the errors on every endpoint:

Project

The project behind the key and its company details.

GET /project

Get the project.

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.

Scope: project:read

200 The project.

Field Type
id (required) string (uuid) The id of the project.
name (required) string 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.
JSON
{
  "id": "7d4e2b10-3c5a-4f6e-9b8d-1a2b3c4d5e6f",
  "name": "Acme Plumbing",
  "edition": "api",
  "created_at": "2026-10-01T09:00:00.000Z",
  "spending_limit_eur": 100,
  "numbers_count": 0,
  "mode": "live",
  "rails": {
    "phone": "live",
    "outbound": "live",
    "sms": "live",
    "email": "live",
    "payments": "live",
    "domains": "test"
  },
  "verification": {
    "account_type": "business",
    "levels": [
      {
        "level": "test",
        "status": "approved",
        "reason": null,
        "missing": [],
        "unlocks": [
          "test_keys",
          "test_numbers",
          "simulated_calls"
        ]
      },
      {
        "level": "company",
        "status": "not_started",
        "reason": null,
        "missing": [
          "payment_mandate",
          "payment_authentication",
          "phone_verification"
        ],
        "unlocks": [
          "live_keys",
          "national_numbers",
          "inbound_calls"
        ]
      },
      {
        "level": "number_documents",
        "status": "not_started",
        "reason": null,
        "missing": [
          "level.company",
          "document.proof_of_address",
          "document.identity",
          "document.company_registration"
        ],
        "unlocks": [
          "geographic_numbers"
        ]
      },
      {
        "level": "full",
        "status": "not_started",
        "reason": null,
        "missing": [
          "level.company",
          "director_identity",
          "ubo_confirmation",
          "review"
        ],
        "unlocks": [
          "outbound_calls",
          "spending_cap_above_500_eur",
          "more_concurrent_calls"
        ]
      }
    ]
  },
  "access": null
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PUT /project/company

Set the company or personal details.

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.

Scope: project:write

Request body

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.
JSON
{
  "company_name": "Acme Plumbing Ltd",
  "registration_number": "01234567",
  "address": {
    "street": "Main Street",
    "house_number": "40",
    "postal_code": "SW1A 1AA",
    "city": "London",
    "country": "GB"
  },
  "contact": {
    "name": "Jane Smith",
    "phone": "+447700900123"
  }
}

200 The company details as saved.

Field Type
account_type (required) "personal", "business" personal (your own details, no business register number) or business.
company_name (required) string or null The legal company name; null for a personal account.
kvk_number (required) string or null 8 digits, for a company in the Netherlands; null elsewhere.
registration_number (required) string or null The number in the business register of the company's country, for a company outside the Netherlands (there it is kvk_number).
vat_number (required) string or null The VAT number, with country prefix in the EU (like DE123456789).
vat_status (required) "valid", "invalid", "unverified", "missing", "not_required" 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.
JSON
{
  "account_type": "business",
  "company_name": "Acme Plumbing Ltd",
  "kvk_number": null,
  "registration_number": "01234567",
  "vat_number": null,
  "vat_status": "not_required",
  "vat_checked_at": null,
  "address": {
    "street": "Main Street",
    "house_number": "40",
    "postal_code": "SW1A 1AA",
    "city": "London",
    "country": "GB"
  },
  "contact": {
    "name": "Jane Smith",
    "phone": "+447700900123"
  },
  "registrar_contact_name": null,
  "complete": true
}

Errors

Besides the errors on every endpoint:

GET /advice

Get the blueprint for a kind of application.

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.

Scope: project:read

Parameter In Type
type query "webshop", "bookings", "restaurant", "course_membership", "wordpress_portal", "app" 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).
topic query "sandbox_blocks_upload", "image_too_large", "version_conflict", "phone_verification_required", "site_not_visible", "is_hypamoon_down" 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.

200 The blueprint, with a short markdown summary. With topic: that one known problem (id, symptom, what_to_do, tell_user, other_topics).

Field Type
type (required) "webshop", "bookings", "restaurant", "course_membership", "wordpress_portal"
title (required) string
summary (required) string
country (required) string or null
region (required) "eu", "uk", "us", "other" 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)._"
}

Errors

Besides the errors on every endpoint:

Status Type
422 validation_failed

POST /project/move-to-live

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.

Scope: project:write

Request body

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.
JSON
{
  "sites": [
    "bakkerij-jansen"
  ]
}

200 What moved, what stayed, and the next step.

Field Type
id (required) string The id of this move, for undo_move_to_live.
status (required) "done", "partly_done" 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)."
}

Errors

Besides the errors on every endpoint:

POST /project/move-to-live/undo

Undo a move to live.

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.

Scope: project:write

Request body

Undo a move to live within 24 hours.

Field Type
move_id string (uuid) Optional. The id from move_to_live. Leave it out to undo the latest move of this project.
JSON
{
  "move_id": "0f6c2a8e-7d3b-4c1e-9a55-2b8f6d4e1c90"
}

200 What went back.

Field Type
id (required) string
status (required) "undone", "partly_undone"
sites (required) string[]
collections (required) number
apps (required) number
agents (required) number
domains (required) number
errors (required) string[]
summary (required) string
next_step (required) string
JSON
{
  "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."
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
403 forbidden
404 not_found
409 conflict
422 validation_failed

GET /errors

List recent errors.

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.

200 The errors, newest first.

Field Type
data (required) RecentError[]
next_before (required) string or null
dropped_today (required) integer
JSON
{
  "data": [
    {
      "id": "48213",
      "at": "2026-10-12T14:03:11.402Z",
      "source": "mcp",
      "client": "chatgpt",
      "tool": "deploy_site",
      "route": null,
      "error": "validation_failed",
      "status": 422,
      "detail": "Some files could not be fetched. See 'errors'.",
      "duration_ms": 8123,
      "request_id": "req_7Hq2LmX9pRt4VwZ1aB3c",
      "mode": "live",
      "arguments": {
        "name": "string(12)",
        "files": {
          "type": "array",
          "n": 14,
          "fields": {
            "path": 14,
            "content": 11,
            "source_url": 3
          },
          "chars": 48211
        }
      },
      "field_errors": [
        {
          "path": "files.12.source_url",
          "message": "images.example.com answered HTTP 403: it blocks downloads by servers. Upload the file with create_upload_urls instead."
        }
      ]
    }
  ],
  "next_before": null,
  "dropped_today": 0
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_parameter

Billing

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.

GET /balance

Get the prepaid balance.

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.

Scope: project:read

200 The balance with the latest transactions.

Field Type
balance_eur (required) number The prepaid balance in euro, excluding VAT. Can be slightly below zero after a call that ran on.
currency (required) "EUR" Always EUR.
spent_this_month_eur (required) number What was deducted this calendar month (minus refunds). Counts toward the spending cap.
spend_cap_eur (required) number or null The spending cap per calendar month, or null without one.
auto_reload (required) AutoReload Automatic top-up with a saved card.
transactions (required) Transaction[] The latest 20 transactions, newest first.
test (required) boolean true with a test key: you see the balance of the live project, read-only. Test mode is free and never touches the balance.
JSON
{
  "balance_eur": 24.46,
  "currency": "EUR",
  "spent_this_month_eur": 0.3,
  "spend_cap_eur": 100,
  "auto_reload": {
    "enabled": true,
    "active": true,
    "threshold_eur": 5,
    "amount_eur": 20,
    "max_per_month": 10,
    "used_this_month": 1,
    "disabled_reason": null,
    "card": {
      "brand": "Visa",
      "last4": "4242",
      "expiry": "2028-12-31"
    }
  },
  "transactions": [
    {
      "id": "6e5d4c3b-2a19-4f8e-8d7c-6b5a4e3d2c1b",
      "type": "charge",
      "source": "sms",
      "amount_eur": -0.3,
      "balance_after_eur": 24.46,
      "description": "SMS, 2 segments",
      "quantity": 2,
      "unit": "segment",
      "reference_id": "8f7e6d5c-4b3a-4291-8f0e-1d2c3b4a5968",
      "created_at": "2026-10-01T10:02:00.000Z"
    },
    {
      "id": "4c3b2a19-0f8e-4d7c-9b6a-5e4d3c2b1a09",
      "type": "top_up",
      "source": "payment",
      "amount_eur": 20,
      "balance_after_eur": 24.76,
      "description": "Top-up",
      "quantity": null,
      "unit": null,
      "reference_id": "b7e5a1c4-2d93-4f60-8a1e-9c3d5b7f2e10",
      "created_at": "2026-10-01T09:30:00.000Z"
    }
  ],
  "test": false
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

Numbers

Search for, order and list phone numbers.

GET /numbers

List the numbers.

The phone numbers of the project, oldest first. Released numbers are left out.

Scope: numbers:read

200 The numbers.

Field Type
data (required) Number[]
JSON
{
  "data": [
    {
      "id": "5f0c9a2e-8b1d-4c3f-a6e7-2d9b0c1e4f53",
      "e164": "+•••••••••42",
      "type": "national",
      "status": "active",
      "created_at": "2026-10-01T09:00:00.000Z",
      "test": false
    }
  ]
}

POST /numbers

Order a number.

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.

Request body

Send either phone_number or area, not both.

Field Type
phone_number string A number from GET /numbers/available, in E.164. With a test key: a test number from that same search.
area string Instead of phone_number: we pick the first free number with this area code, as GET /numbers/available returns it.
JSON
{
  "area": "020"
}

201 The number was ordered. status is active (it works now) or pending (the registration is with the carrier).

Header
Idempotent-Replayed 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 number. Use it for the call plan.
e164 (required) string The number in E.164, like +447700900123.
type (required) string national (non-geographic), geographic (with an area code), mobile or tollfree.
status (required) string pending (the registration is with the carrier, usually active within one business day), active or porting.
created_at (required) string (date-time) When the number was added to the project.
test (required) boolean true for a test number (test mode), from the range +447700900000 to +447700900999. It can never be called for real.
from_stock (required) boolean Whether the number came from our stock. A number from stock works right away.
JSON
{
  "id": "5f0c9a2e-8b1d-4c3f-a6e7-2d9b0c1e4f53",
  "e164": "+•••••••••42",
  "type": "national",
  "status": "active",
  "created_at": "2026-10-01T09:00:00.000Z",
  "test": false,
  "from_stock": true
}

Errors

Besides the errors on every endpoint:

GET /numbers/available

Search for numbers to order.

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.

200 The available numbers.

Field Type
data (required) AvailableNumber[]
JSON
{
  "data": [
    {
      "e164": "+•••••••••42",
      "type": "national",
      "from_stock": true,
      "test": false
    }
  ]
}

Errors

Besides the errors on every endpoint:

DELETE /numbers/{id}

Release a test number.

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.

Scope: numbers:write

Parameter In Type
id (required) path string (uuid) The id of the number.

200 The test number was released.

Field Type
id (required) string (uuid) The id of the number that was released.
e164 (required) string The number in E.164, like +447700900123.
released (required) true Always true: the number is no longer in the project.
test (required) true Always true: only test numbers can be released through the API.
JSON
{
  "id": "0d1e2f3a-4b5c-4d6e-8f70-8192a3b4c5d6",
  "e164": "+447700900042",
  "released": true,
  "test": true
}

Errors

Besides the errors on every endpoint:

Assistant

The voice AI that answers your calls. One per project.

GET /assistant

Get the assistant.

The assistant of the project: its voice, languages, instructions, website and fixed greeting.

Scope: assistant:write

200 The assistant.

Field Type
configured (required) boolean 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"
}

PUT /assistant

Set the assistant.

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.

Scope: assistant:write

Request body

PUT replaces all fields: a field you leave out goes back to its default.

Field Type
voice "saar", "fenna", "merel", "julia", "roos", "tim", "bram", "daan", "ruben", "ava", "nora", "liam", "owen", "lena", "mara", "jonas", "felix", "chloe", "manon", "hugo", "theo", "lucia", "elena", "pablo", "diego" 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"
}

200 The assistant as saved.

Field Type
configured (required) boolean 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."
}

Errors

Besides the errors on every endpoint:

Call plans

What happens when your number is called: ring your team, read out a text, hand over to the assistant.

GET /numbers/{id}/call-plan

Get the call plan of a number.

The call plan of one number, with its version. The version is also in the ETag header; send it back as If-Match with PUT.

Scope: numbers:read

Parameter In Type
id (required) path string (uuid) The id of the number.

200 The call plan that calls to this number follow: its own plan (source number) or, without one, the plan of the project (source project, version 0).

Header
ETag 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.
JSON
{
  "number_id": "5f0c9a2e-8b1d-4c3f-a6e7-2d9b0c1e4f53",
  "version": 1,
  "source": "number",
  "plan": {
    "entry": "team",
    "nodes": {
      "team": {
        "id": "team",
        "type": "forward",
        "strategy": "sequential",
        "targets": [
          {
            "kind": "external",
            "e164": "+447700900123"
          }
        ],
        "ringSeconds": 20,
        "onNoAnswer": "ai"
      },
      "ai": {
        "id": "ai",
        "type": "ai_agent"
      }
    }
  }
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PUT /numbers/{id}/call-plan

Set the call plan of a number.

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.

Request body

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.
JSON
{
  "entry": "team",
  "nodes": {
    "team": {
      "id": "team",
      "type": "forward",
      "strategy": "sequential",
      "targets": [
        {
          "kind": "external",
          "e164": "+447700900123"
        }
      ],
      "ringSeconds": 20,
      "onNoAnswer": "ai"
    },
    "ai": {
      "id": "ai",
      "type": "ai_agent"
    }
  }
}

200 The call plan as saved, with its new version.

Header
ETag 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.
plan (required) CallPlan 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.
assistant_sync (required) "synced", "failed", "not_needed", "skipped_test_mode" Whether the assistant was updated with this plan. not_needed for a plan without ai_agent; skipped_test_mode with a test key.
JSON
{
  "number_id": "5f0c9a2e-8b1d-4c3f-a6e7-2d9b0c1e4f53",
  "version": 1,
  "plan": {
    "entry": "team",
    "nodes": {
      "team": {
        "id": "team",
        "type": "forward",
        "strategy": "sequential",
        "targets": [
          {
            "kind": "external",
            "e164": "+447700900123"
          }
        ],
        "ringSeconds": 20,
        "onNoAnswer": "ai"
      },
      "ai": {
        "id": "ai",
        "type": "ai_agent"
      }
    }
  },
  "assistant_sync": "synced"
}

Errors

Besides the errors on every endpoint:

Calls

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.

GET /calls

List the calls.

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.

200 A page of calls.

Field Type
data (required) Call[]
has_more (required) boolean Whether there is a next page.
next_cursor (required) string or null Pass this as cursor to get the next page. Null on the last page.
JSON
{
  "data": [
    {
      "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
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_parameter

POST /calls

Call someone with your assistant.

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.

Request body

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"
  }
}

201 The call was started (live: outbound.status queued or ringing) or, in test mode, simulated to its end result. The same shape as GET /calls/{id}.

Header
Idempotent-Replayed 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.
transcript (required) string or null The transcript. Only here, never in a webhook.
summary (required) one of The summary of a call.
JSON
{
  "id": "b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e",
  "direction": "outbound",
  "from": "+•••••••••42",
  "to": "+447700900321",
  "status": "ringing",
  "created_at": "2026-10-01T09:00:00.000Z",
  "started_at": "2026-10-01T09:00:00.000Z",
  "answered_at": null,
  "ended_at": null,
  "duration_sec": null,
  "hangup_cause": null,
  "has_summary": false,
  "summary_ready_at": null,
  "test": false,
  "outbound": {
    "status": "ringing",
    "answered_by": null,
    "voicemail_left": false,
    "on_voicemail": "leave_message",
    "max_duration_sec": 300,
    "reference": "appointment-8812",
    "metadata": {
      "customer_id": "cus_4821"
    },
    "error": null,
    "purpose": "transactional"
  },
  "transcript": null,
  "summary": null
}

Errors

Besides the errors on every endpoint:

GET /calls/{id}

Get a call with transcript and summary.

One call, with its transcript and summary. The transcript is only here, never in a webhook.

Scope: calls:read

Parameter In Type
id (required) path string (uuid) The id of the call.

200 The call.

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.
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."
  }
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

GET /call-consents

List recorded consent for marketing calls.

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.

200 The consents.

Field Type
data (required) CallConsent[] The consents, newest first (at most 100).
JSON
{
  "data": [
    {
      "id": "5b8e2c1d-7a4f-4e3b-9c6d-1f2a3b4c5d6e",
      "phone_number_ending": "21",
      "source": "form",
      "evidence": "Newsletter sign-up form, entry 7f3c2a, box 'call me about offers' ticked",
      "consented_at": "2026-10-01T09:30:00.000Z",
      "recorded_at": "2026-10-01T09:30:02.000Z",
      "revoked_at": null,
      "status": "active"
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_parameter

POST /call-consents

Record consent for marketing calls.

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.

Scope: calls:write

Request body

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"
}
Field Type
id (required) string (uuid) The id of the consent.
phone_number_ending (required) string The last two digits of the number. The number itself is never stored, only a keyed hash.
source (required) "form", "call", "import", "manual" Where the consent came from: form, call, import or manual.
evidence (required) string Your reference to the proof.
consented_at (required) string (date-time) When the person gave consent.
recorded_at (required) string (date-time) When it was recorded here.
revoked_at (required) string or null (date-time) When it was revoked; null while it is valid.
status (required) "active", "revoked" active: marketing calls to this number are allowed. revoked: no longer.
JSON
{
  "id": "5b8e2c1d-7a4f-4e3b-9c6d-1f2a3b4c5d6e",
  "phone_number_ending": "21",
  "source": "form",
  "evidence": "Newsletter sign-up form, entry 7f3c2a, box 'call me about offers' ticked",
  "consented_at": "2026-10-01T09:30:00.000Z",
  "recorded_at": "2026-10-01T09:30:02.000Z",
  "revoked_at": null,
  "status": "active"
}

201 Recorded.

Field Type
id (required) string (uuid) The id of the consent.
phone_number_ending (required) string The last two digits of the number. The number itself is never stored, only a keyed hash.
source (required) "form", "call", "import", "manual" Where the consent came from: form, call, import or manual.
evidence (required) string Your reference to the proof.
consented_at (required) string (date-time) When the person gave consent.
recorded_at (required) string (date-time) When it was recorded here.
revoked_at (required) string or null (date-time) When it was revoked; null while it is valid.
status (required) "active", "revoked" active: marketing calls to this number are allowed. revoked: no longer.
JSON
{
  "id": "5b8e2c1d-7a4f-4e3b-9c6d-1f2a3b4c5d6e",
  "phone_number_ending": "21",
  "source": "form",
  "evidence": "Newsletter sign-up form, entry 7f3c2a, box 'call me about offers' ticked",
  "consented_at": "2026-10-01T09:30:00.000Z",
  "recorded_at": "2026-10-01T09:30:02.000Z",
  "revoked_at": null,
  "status": "active"
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
403 forbidden
422 validation_failed

DELETE /call-consents/{id}

Revoke consent for marketing calls.

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.

Scope: calls:write

Parameter In Type
id (required) path string (uuid) The id of the consent.
Field Type
id (required) string (uuid) The id of the consent.
phone_number_ending (required) string The last two digits of the number. The number itself is never stored, only a keyed hash.
source (required) "form", "call", "import", "manual" Where the consent came from: form, call, import or manual.
evidence (required) string Your reference to the proof.
consented_at (required) string (date-time) When the person gave consent.
recorded_at (required) string (date-time) When it was recorded here.
revoked_at (required) string or null (date-time) When it was revoked; null while it is valid.
status (required) "active", "revoked" active: marketing calls to this number are allowed. revoked: no longer.
JSON
{
  "id": "5b8e2c1d-7a4f-4e3b-9c6d-1f2a3b4c5d6e",
  "phone_number_ending": "21",
  "source": "form",
  "evidence": "Newsletter sign-up form, entry 7f3c2a, box 'call me about offers' ticked",
  "consented_at": "2026-10-01T09:30:00.000Z",
  "recorded_at": "2026-10-01T09:30:02.000Z",
  "revoked_at": "2026-10-12T14:00:00.000Z",
  "status": "revoked"
}

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden
404 not_found

Webhooks

Receive an event for every call, signed with Hypamoon-Signature.

GET /webhooks

List the webhook endpoints.

The project's webhook endpoints, oldest first. The secret is never included.

Scope: webhooks:write

200 The endpoints.

Field Type
data (required) WebhookEndpoint[]
JSON
{
  "data": [
    {
      "id": "9e8d7c6b-5a49-4382-9716-a5b4c3d2e1f0",
      "url": "https://your-app.example/hooks/hypamoon",
      "events": [
        "call.completed",
        "call.summary.ready"
      ],
      "active": true,
      "secret_prefix": "whsec_Xk3p",
      "created_at": "2026-10-01T09:00:00.000Z",
      "last_success_at": null,
      "last_failure_at": null,
      "consecutive_failures": 0
    }
  ]
}

POST /webhooks

Create a webhook endpoint.

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.

Scope: webhooks:write

Request body

A new webhook endpoint.

Field Type
url (required) string An https URL with a domain name on the default port, without credentials, that resolves to a public address.
events (required) "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 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.
JSON
{
  "url": "https://your-app.example/hooks/hypamoon",
  "events": [
    "call.completed",
    "call.summary.ready"
  ]
}

201 The endpoint, with its secret.

Field Type
id (required) string (uuid)
url (required) string
events (required) string[] The subscribed events, from call.completed, call.summary.ready, message.delivered, message.failed, conversation.summary.ready, balance.low, balance.topped_up, balance.reload_failed, document.created.
active (required) boolean false after 50 failed deliveries in a row; switch it back on with POST /webhooks/{id}/enable.
secret_prefix (required) string The first characters of the secret, to recognize it.
created_at (required) string (date-time) When the endpoint was created.
last_success_at (required) string or null (date-time) The last delivery that got a 2xx.
last_failure_at (required) string or null (date-time) The last delivery that failed.
consecutive_failures (required) integer Failed deliveries in a row.
app_id string (uuid) Only with a filter on one app: the endpoint gets the email events of that app's emails only.
secret (required) string The signing secret, whsec_ plus 43 characters. Shown only in this response: store it now.
JSON
{
  "id": "9e8d7c6b-5a49-4382-9716-a5b4c3d2e1f0",
  "url": "https://your-app.example/hooks/hypamoon",
  "events": [
    "call.completed",
    "call.summary.ready"
  ],
  "active": true,
  "secret_prefix": "whsec_Xk3p",
  "created_at": "2026-10-01T09:00:00.000Z",
  "last_success_at": null,
  "last_failure_at": null,
  "consecutive_failures": 0,
  "secret": "whsec_Xk3pQ9vT2mLr8sYw4nBc6dFh1jKz5aGe7uIo0pRtVxE"
}

Errors

Besides the errors on every endpoint:

DELETE /webhooks/{id}

Delete a webhook endpoint.

Deletes the endpoint, its pending deliveries and its delivery log.

Scope: webhooks:write

Parameter In Type
id (required) path string (uuid) The id of the webhook endpoint.

204 Deleted.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

POST /webhooks/{id}/enable

Switch a webhook endpoint back on.

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.

Scope: webhooks:write

Parameter In Type
id (required) path string (uuid) The id of the webhook endpoint.

200 The endpoint.

Field Type
id (required) string (uuid)
url (required) string
events (required) string[] The subscribed events, from call.completed, call.summary.ready, message.delivered, message.failed, conversation.summary.ready, balance.low, balance.topped_up, balance.reload_failed, document.created.
active (required) boolean false after 50 failed deliveries in a row; switch it back on with POST /webhooks/{id}/enable.
secret_prefix (required) string The first characters of the secret, to recognize it.
created_at (required) string (date-time) When the endpoint was created.
last_success_at (required) string or null (date-time) The last delivery that got a 2xx.
last_failure_at (required) string or null (date-time) The last delivery that failed.
consecutive_failures (required) integer Failed deliveries in a row.
app_id string (uuid) Only with a filter on one app: the endpoint gets the email events of that app's emails only.
JSON
{
  "id": "9e8d7c6b-5a49-4382-9716-a5b4c3d2e1f0",
  "url": "https://your-app.example/hooks/hypamoon",
  "events": [
    "call.completed",
    "call.summary.ready"
  ],
  "active": true,
  "secret_prefix": "whsec_Xk3p",
  "created_at": "2026-10-01T09:00:00.000Z",
  "last_success_at": null,
  "last_failure_at": null,
  "consecutive_failures": 0
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

POST /webhooks/{id}/test

Send a test event.

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.

Scope: webhooks:write

Parameter In Type
id (required) path string (uuid) The id of the webhook endpoint.

200 The outcome of the first delivery.

Field Type
delivery_id (required) string (uuid)
event_id (required) string The id of the webhook.test event, evt_ plus 32 hex characters.
status (required) "delivered", "pending", "failed" delivered: your endpoint answered 2xx. pending: it did not, and we retry with backoff. failed: we gave up.
response_status (required) integer or null The HTTP status your endpoint answered with, if any.
error (required) "timeout", "connection_failed", "non_2xx_status", "not_public_host", "invalid_url", "response_too_large", "secret_unreadable", "endpoint_disabled", "call_deleted", "summary_deleted", "message_deleted", "conversation_deleted", "document_deleted", "invalid_reference", "delivery_failed", null Why the delivery did not succeed, as a short code.
JSON
{
  "delivery_id": "3b2a1908-f7e6-4d5c-8b4a-392817160514",
  "event_id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3",
  "status": "delivered",
  "response_status": 204,
  "error": null
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
409 webhook_endpoint_disabled

Messages

Transactional SMS to mobile numbers: order confirmations, shipping updates, reminders. Not for marketing.

GET /messages

List the messages.

The SMS messages the project sent through the API, newest first, with their status. Follow next_cursor while has_more is true.

Scope: messages:read

Parameter In Type
limit query integer Messages per page, default 25. A value above 100 is capped at 100.
cursor query string The next_cursor of the previous page.

200 A page of messages.

Field Type
data (required) Message[]
has_more (required) boolean Whether there is another page.
next_cursor (required) string or null Pass this as cursor to get the next page.
JSON
{
  "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
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_parameter

POST /messages

Send an SMS.

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.

Request body

A transactional SMS to one recipient.

Field Type
to (required) string 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"
}

201 The message was accepted (or, in test mode, simulated).

Header
Idempotent-Replayed 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
}

Errors

Besides the errors on every endpoint:

GET /messages/{id}

Get a message.

One SMS message with its current status: queued, sent, delivered or failed.

Scope: messages:read

Parameter In Type
id (required) path string (uuid) The id of the message.

200 The message.

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
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

Chat

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.

GET /conversations

List the conversations.

The project's chat conversations, newest first, without messages. Follow next_cursor while has_more is true.

Scope: chat:read

Parameter In Type
limit query integer Conversations per page, default 25. A value above 100 is capped at 100.
cursor query string The next_cursor of the previous page.
status query "open", "closed" Only open or only closed conversations.

200 A page of conversations.

Field Type
data (required) Conversation[]
has_more (required) boolean Whether there is another page.
next_cursor (required) string or null Pass this as cursor to get the next page.
JSON
{
  "data": [
    {
      "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
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_parameter

POST /conversations

Start a chat conversation.

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.

Request body

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.
JSON
{
  "reference": "session-8841",
  "metadata": {
    "page": "/pricing",
    "plan": "pro"
  },
  "customer": {
    "name": "Sam Jones",
    "email": "sam@example.com"
  }
}

201 The conversation, with the greeting as its first message.

Header
Idempotent-Replayed 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 conversation.
channel (required) "chat", "widget" The channel: chat when your app started it with an API key, widget when a visitor started it in the chat widget on a website (public chat token).
status (required) "open", "closed" open: takes messages. closed: takes no more messages; the summary follows.
reference (required) string or null Your own id for this conversation, as you sent it.
metadata (required) object Your metadata, as you sent it. The assistant never sees it.
customer (required) object What your app told us about the customer when the conversation started.
language (required) string The language the assistant opened in (nl, en, de, fr or es). It replies in the customer's language when that is one of its languages.
turns (required) integer Customer 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 closed: POST /conversations/{id}/close. inactive: no message for 30 minutes. turn_limit: max_turns reached.
has_summary (required) boolean Whether the summary is ready. Fetch it with GET /conversations/{id}.
summary_ready_at (required) string or null (date-time) When the summary was ready.
test (required) boolean true for a conversation in test mode (hm_test_): real replies, free, with a low daily limit.
messages (required) ChatMessage[] 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": "open",
  "reference": "session-8841",
  "metadata": {
    "page": "/pricing",
    "plan": "pro"
  },
  "customer": {
    "name": "Sam Jones",
    "phone": null,
    "email": "sam@example.com"
  },
  "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,
  "has_summary": false,
  "summary_ready_at": 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"
    }
  ],
  "summary": null
}

Errors

Besides the errors on every endpoint:

GET /conversations/{id}

Get a conversation with its messages and summary.

One conversation with all its messages, oldest first, and the summary once it is closed. The messages are only here, never in a webhook.

Scope: chat:read

Parameter In Type
id (required) path string (uuid) The id of the conversation.

200 The conversation.

Field Type
id (required) string (uuid) The id of the conversation.
channel (required) "chat", "widget" The channel: chat when your app started it with an API key, widget when a visitor started it in the chat widget on a website (public chat token).
status (required) "open", "closed" open: takes messages. closed: takes no more messages; the summary follows.
reference (required) string or null Your own id for this conversation, as you sent it.
metadata (required) object Your metadata, as you sent it. The assistant never sees it.
customer (required) object What your app told us about the customer when the conversation started.
language (required) string The language the assistant opened in (nl, en, de, fr or es). It replies in the customer's language when that is one of its languages.
turns (required) integer Customer 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 closed: POST /conversations/{id}/close. inactive: no message for 30 minutes. turn_limit: max_turns reached.
has_summary (required) boolean Whether the summary is ready. Fetch it with GET /conversations/{id}.
summary_ready_at (required) string or null (date-time) When the summary was ready.
test (required) boolean true for a conversation in test mode (hm_test_): real replies, free, with a low daily limit.
messages (required) ChatMessage[] 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."
  }
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

POST /conversations/{id}/messages

Send a customer message and get the reply.

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.

Request body

One message from the customer.

Field Type
text (required) string What the customer typed, at most 2000 characters. Send it as is; it is never treated as an instruction to the assistant.
JSON
{
  "text": "My kitchen tap is leaking. Can someone come today?"
}

200 The customer's message and the assistant's reply.

Header
Idempotent-Replayed 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
  }
}

Errors

Besides the errors on every endpoint:

POST /conversations/{id}/close

Close a conversation.

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.

Scope: chat:write

Parameter In Type
id (required) path string (uuid) The id of the conversation.

200 The conversation, closed.

Field Type
id (required) string (uuid) The id of the conversation.
channel (required) "chat", "widget" The channel: chat when your app started it with an API key, widget when a visitor started it in the chat widget on a website (public chat token).
status (required) "open", "closed" open: takes messages. closed: takes no more messages; the summary follows.
reference (required) string or null Your own id for this conversation, as you sent it.
metadata (required) object Your metadata, as you sent it. The assistant never sees it.
customer (required) object What your app told us about the customer when the conversation started.
language (required) string The language the assistant opened in (nl, en, de, fr or es). It replies in the customer's language when that is one of its languages.
turns (required) integer Customer 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 closed: POST /conversations/{id}/close. inactive: no message for 30 minutes. turn_limit: max_turns reached.
has_summary (required) boolean Whether the summary is ready. Fetch it with GET /conversations/{id}.
summary_ready_at (required) string or null (date-time) When the summary was ready.
test (required) boolean true for a conversation in test mode (hm_test_): real replies, free, with a low daily limit.
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": "closed",
  "has_summary": false,
  "summary_ready_at": null,
  "test": false
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

Chat widget

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.

GET /chat-tokens

List the public chat tokens.

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.

Scope: chat:read

200 The tokens and the daily spending limit.

Field Type
data (required) ChatToken[] The public chat tokens of the project, newest first, also the revoked ones.
daily_spend_limit_eur (required) number What the public chat of this project may spend per day in replies, in euro. Default 5; change it in the portal. 0 turns the live widget off.
JSON
{
  "data": [
    {
      "id": "1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b",
      "name": "acme-plumbing.example",
      "display_name": "Acme Plumbing",
      "token": "hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE",
      "mode": "live",
      "allowed_origins": [
        "https://www.acme-plumbing.example",
        "https://acme-plumbing.example"
      ],
      "turnstile": true,
      "max_conversations_per_day": 200,
      "revoked": false,
      "revoked_at": null,
      "last_used_at": null,
      "created_at": "2026-10-01T09:00:00.000Z",
      "embed_code": "<script src=\"https://api.hypamoon.com/widget.js\" data-token=\"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\" async></script>",
      "kind": "chat"
    }
  ],
  "daily_spend_limit_eur": 5
}

POST /chat-tokens

Create a public chat token for the chat widget.

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).

Scope: chat:write

Request body

A new public chat token for the website widget.

Field Type
name (required) string 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.
JSON
{
  "name": "acme-plumbing.example",
  "allowed_origins": [
    "https://www.acme-plumbing.example",
    "https://acme-plumbing.example"
  ]
}

201 The token, with embed_code.

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.
JSON
{
  "id": "1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b",
  "name": "acme-plumbing.example",
  "display_name": "Acme Plumbing",
  "token": "hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE",
  "mode": "live",
  "allowed_origins": [
    "https://www.acme-plumbing.example",
    "https://acme-plumbing.example"
  ],
  "turnstile": true,
  "max_conversations_per_day": 200,
  "revoked": false,
  "revoked_at": null,
  "last_used_at": null,
  "created_at": "2026-10-01T09:00:00.000Z",
  "embed_code": "<script src=\"https://api.hypamoon.com/widget.js\" data-token=\"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\" async></script>",
  "kind": "chat"
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
422 validation_failed

GET /chat-tokens/{id}

Get a public chat token.

One public chat token with its embed_code.

Scope: chat:read

Parameter In Type
id (required) path string (uuid) The id of the public chat token.

200 The token.

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.
JSON
{
  "id": "1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b",
  "name": "acme-plumbing.example",
  "display_name": "Acme Plumbing",
  "token": "hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE",
  "mode": "live",
  "allowed_origins": [
    "https://www.acme-plumbing.example",
    "https://acme-plumbing.example"
  ],
  "turnstile": true,
  "max_conversations_per_day": 200,
  "revoked": false,
  "revoked_at": null,
  "last_used_at": null,
  "created_at": "2026-10-01T09:00:00.000Z",
  "embed_code": "<script src=\"https://api.hypamoon.com/widget.js\" data-token=\"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\" async></script>",
  "kind": "chat"
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PATCH /chat-tokens/{id}

Change a public chat token.

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.

Scope: chat:write

Parameter In Type
id (required) path string (uuid) The id of the public chat token.

Request body

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.
allowed_origins string[]
turnstile boolean
max_conversations_per_day integer
JSON
{
  "allowed_origins": [
    "https://www.acme-plumbing.example",
    "https://acme-plumbing.example",
    "https://shop.acme-plumbing.example"
  ]
}

200 The token as changed.

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.
JSON
{
  "id": "1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b",
  "name": "acme-plumbing.example",
  "display_name": "Acme Plumbing",
  "token": "hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE",
  "mode": "live",
  "allowed_origins": [
    "https://www.acme-plumbing.example",
    "https://acme-plumbing.example",
    "https://shop.acme-plumbing.example"
  ],
  "turnstile": true,
  "max_conversations_per_day": 200,
  "revoked": false,
  "revoked_at": null,
  "last_used_at": null,
  "created_at": "2026-10-01T09:00:00.000Z",
  "embed_code": "<script src=\"https://api.hypamoon.com/widget.js\" data-token=\"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\" async></script>",
  "kind": "chat"
}

Errors

Besides the errors on every endpoint:

DELETE /chat-tokens/{id}

Revoke a public chat token.

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.

Scope: chat:write

Parameter In Type
id (required) path string (uuid) The id of the public chat token.

200 The token, revoked.

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.
JSON
{
  "id": "1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b",
  "name": "acme-plumbing.example",
  "display_name": "Acme Plumbing",
  "token": "hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE",
  "mode": "live",
  "allowed_origins": [
    "https://www.acme-plumbing.example",
    "https://acme-plumbing.example"
  ],
  "turnstile": true,
  "max_conversations_per_day": 200,
  "revoked": true,
  "revoked_at": "2026-10-02T08:00:00.000Z",
  "last_used_at": null,
  "created_at": "2026-10-01T09:00:00.000Z",
  "embed_code": "<script src=\"https://api.hypamoon.com/widget.js\" data-token=\"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\" async></script>",
  "kind": "chat"
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

GET /public/config

Get the widget settings (public).

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.

200 The settings of the widget.

Field Type
name (required) string The name of the project, as the widget shows it.
greeting (required) string 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
}

Errors

Besides the errors on every endpoint:

POST /public/conversations

Start a conversation from the widget (public).

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.

Request body

An empty body is fine when turnstile is off for the token.

Field Type
turnstile_token string The bot-check token from the challenge frame. Required when the public token has turnstile on (the default); the widget does this for you.
JSON
{
  "turnstile_token": "0.zXnA8v1rT3...from-the-bot-check"
}

201 The conversation with the greeting and its secret.

Field Type
conversation (required) PublicConversation 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}.
messages (required) ChatMessage[] The fixed greeting, as the first message.
conversation_token (required) string 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"
}

Errors

Besides the errors on every endpoint:

GET /public/conversations/{id}

Get the visitor's conversation (public).

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.

200 The conversation with its messages.

Field Type
conversation (required) PublicConversation 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}.
messages (required) ChatMessage[] 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"
    }
  ]
}

Errors

Besides the errors on every endpoint:

POST /public/conversations/{id}/messages

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.

Request body

One message from the customer.

Field Type
text (required) string What the customer typed, at most 2000 characters. Send it as is; it is never treated as an instruction to the assistant.
JSON
{
  "text": "My kitchen tap is leaking. Can someone come today?"
}

200 The visitor's message and the assistant's reply.

Field Type
message (required) object The visitor's message as stored.
reply (required) object The assistant's reply. Show this text.
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": "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
  }
}

Errors

Besides the errors on every endpoint:

GET /public/challenge

The bot check page for the widget (public).

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.

Parameter In Type
origin (required) query string The origin of the page with the widget, like https://www.example.com.
lang query "en", "nl", "de", "fr", "es" The language of the check. Without it, the browser language.

200 The page with the check.

Sites

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.

GET /sites

List the sites.

The project's static sites, newest first, each with its url and its files (without their content). Deleted sites are not listed.

Scope: sites:read

200 The sites.

Field Type
data (required) Site[] The sites of the project, newest first. Deleted sites are not listed.
JSON
{
  "data": [
    {
      "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
    }
  ]
}

POST /sites

Publish a static 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.

Scope: sites:write

Request body

A new static site.

Field Type
slug string 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".
JSON
{
  "slug": "acme-plumbing",
  "name": "Acme Plumbing",
  "files": [
    {
      "path": "/index.html",
      "content": "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Acme Plumbing</title><link rel=\"stylesheet\" href=\"style.css\"></head><body><h1>Acme Plumbing</h1><p>Leaking tap? Call us.</p></body></html>"
    },
    {
      "path": "/style.css",
      "content": "body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }"
    }
  ]
}

201 The site, live at url.

Field Type
id (required) string (uuid) The id of the site. Use it to update or delete the site.
slug (required) string The first part of the address: https://.hypamoon.app.
name (required) string Your name for the site.
url (required) string The public address of the site, live right away.
files (required) SiteFile[] 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.
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
}

202 Four-eyes approval is on: the deploy waits for another owner or builder. Nothing has 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.
status (required) "pending", "processing", "approved", "rejected", "failed", "superseded", "expired" pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days.
kind (required) "new_site", "site_update", "site_delete", "app_deploy", "app_rollback" app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules.
site_id (required) string or null (uuid)
name (required) string or null
slug (required) string or null
url (required) string or null The address of the site. For an update or deletion it still shows the live version until approval.
files (required) integer
bytes (required) integer
password_change (required) "none", "remove", "set"
requested_by (required) object
requested_at (required) string
expires_at (required) string
reviewed_by (required) string or null (uuid)
reviewed_at (required) string or null
reason (required) string or null
result (required) object or null
JSON
{
  "object": "deploy_approval",
  "id": "8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90",
  "status": "pending",
  "kind": "new_site",
  "site_id": null,
  "name": "Acme Plumbing",
  "slug": "acme-plumbing",
  "url": "https://acme-plumbing.hypamoon.app",
  "files": 2,
  "bytes": 1840,
  "password_change": "none",
  "requested_by": {
    "user_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "via": "api",
    "app": "Claude"
  },
  "requested_at": "2026-10-05T10:15:00Z",
  "expires_at": "2026-10-12T10:15:00Z",
  "reviewed_by": null,
  "reviewed_at": null,
  "reason": null,
  "result": null
}

Errors

Besides the errors on every endpoint:

POST /sites/uploads

Get upload links for large files.

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.

Scope: sites:write

Request body

Ask for upload links for files that are too large to send inline.

Field Type
site_id string (uuid) Optional. The site the files are for, when you change an existing one (from deploy_site or list_sites). Leave it out for a new site.
files (required) object[] The files to upload, at most 200 per request.
JSON
{
  "files": [
    {
      "path": "/img/hero.jpg",
      "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
      "bytes": 2483112
    }
  ]
}
Field Type
data (required) object[] 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
  }
}

Errors

Besides the errors on every endpoint:

POST /sites/drops

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.

Scope: sites:write

Request body

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//.
JSON
{}
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.
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."
}

Errors

Besides the errors on every endpoint:

GET /sites/drops/{id}

Get a drop link and the files the user uploaded.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the drop link.
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.
user_message string A sentence to give the user.
next_step string What to do next.
JSON
{
  "id": "3f1c2a9e-5b7d-4e8f-9a0b-1c2d3e4f5a6b",
  "status": "done",
  "site_id": null,
  "created_at": "2026-10-01T09:00:00.000Z",
  "expires_at": "2026-10-02T09:00:00.000Z",
  "completed_at": "2026-10-01T09:04:00.000Z",
  "images_only": false,
  "strip": "location",
  "folder": "portfolio",
  "max_files": 100,
  "files": [
    {
      "path": "/media/portfolio/project-12/03.webp",
      "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
      "bytes": 384112,
      "content_type": "image/webp",
      "name": "03.jpg",
      "variant": "2048",
      "width": 2048,
      "height": 1365
    },
    {
      "path": "/media/portfolio/project-12/03-640.webp",
      "sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7",
      "bytes": 41240,
      "content_type": "image/webp",
      "name": "03.jpg",
      "variant": "640",
      "width": 640,
      "height": 427
    }
  ],
  "originals_kept": 0,
  "site_version": null,
  "deploy_files": [
    {
      "path": "/media/portfolio/project-12/03.webp",
      "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
      "bytes": 384112
    },
    {
      "path": "/media/portfolio/project-12/03-640.webp",
      "sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7",
      "bytes": 41240
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

GET /sites/{id}

Get a site.

One site with its url and its files, without their content.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the site.

200 The site.

Field Type
id (required) string (uuid) The id of the site. Use it to update or delete the site.
slug (required) string The first part of the address: https://.hypamoon.app.
name (required) string Your name for the site.
url (required) string The public address of the site, live right away.
files (required) SiteFile[] 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.
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
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PUT /sites/{id}

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the site.

Request body

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.
JSON
{
  "files": [
    {
      "path": "/index.html",
      "content": "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Acme Plumbing</title><link rel=\"stylesheet\" href=\"style.css\"></head><body><h1>Acme Plumbing</h1><p>Leaking tap? Call us.</p></body></html>"
    },
    {
      "path": "/style.css",
      "content": "body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }"
    }
  ],
  "password": "spring-tour-2027"
}

200 The site as it is now.

Field Type
id (required) string (uuid) The id of the site. Use it to update or delete the site.
slug (required) string The first part of the address: https://.hypamoon.app.
name (required) string Your name for the site.
url (required) string The public address of the site, live right away.
files (required) SiteFile[] 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.
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": true,
  "test": false,
  "taken_offline": null
}

202 Four-eyes approval is on: the update waits for another owner or builder. Nothing has 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.
status (required) "pending", "processing", "approved", "rejected", "failed", "superseded", "expired" pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days.
kind (required) "new_site", "site_update", "site_delete", "app_deploy", "app_rollback" app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules.
site_id (required) string or null (uuid)
name (required) string or null
slug (required) string or null
url (required) string or null The address of the site. For an update or deletion it still shows the live version until approval.
files (required) integer
bytes (required) integer
password_change (required) "none", "remove", "set"
requested_by (required) object
requested_at (required) string
expires_at (required) string
reviewed_by (required) string or null (uuid)
reviewed_at (required) string or null
reason (required) string or null
result (required) object or null
JSON
{
  "object": "deploy_approval",
  "id": "8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90",
  "status": "pending",
  "kind": "site_update",
  "site_id": "3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c",
  "name": "Acme Plumbing",
  "slug": "acme-plumbing",
  "url": "https://acme-plumbing.hypamoon.app",
  "files": 2,
  "bytes": 1840,
  "password_change": "none",
  "requested_by": {
    "user_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "via": "api",
    "app": "Claude"
  },
  "requested_at": "2026-10-05T10:15:00Z",
  "expires_at": "2026-10-12T10:15:00Z",
  "reviewed_by": null,
  "reviewed_at": null,
  "reason": null,
  "result": null
}

Errors

Besides the errors on every endpoint:

DELETE /sites/{id}

Delete a site.

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.

202 Four-eyes approval is on: the deletion waits for another owner or builder. Nothing has 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.
status (required) "pending", "processing", "approved", "rejected", "failed", "superseded", "expired" pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days.
kind (required) "new_site", "site_update", "site_delete", "app_deploy", "app_rollback" app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules.
site_id (required) string or null (uuid)
name (required) string or null
slug (required) string or null
url (required) string or null The address of the site. For an update or deletion it still shows the live version until approval.
files (required) integer
bytes (required) integer
password_change (required) "none", "remove", "set"
requested_by (required) object
requested_at (required) string
expires_at (required) string
reviewed_by (required) string or null (uuid)
reviewed_at (required) string or null
reason (required) string or null
result (required) object or null
JSON
{
  "object": "deploy_approval",
  "id": "8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90",
  "status": "pending",
  "kind": "site_delete",
  "site_id": "3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c",
  "name": "Acme Plumbing",
  "slug": "acme-plumbing",
  "url": "https://acme-plumbing.hypamoon.app",
  "files": 0,
  "bytes": 0,
  "password_change": "none",
  "requested_by": {
    "user_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "via": "api",
    "app": "Claude"
  },
  "requested_at": "2026-10-05T10:15:00Z",
  "expires_at": "2026-10-12T10:15:00Z",
  "reviewed_by": null,
  "reviewed_at": null,
  "reason": null,
  "result": null
}

204 Deleted. Without permanent the site can be restored for 30 days.

Errors

Besides the errors on every endpoint:

Status Type
403 site_offline
404 not_found

GET /sites/deleted

List the deleted sites that can be restored.

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.

Scope: sites:read

200 The deleted sites.

Field Type
data (required) DeletedSite[] The deleted sites that can still be restored, most recently deleted first.
JSON
{
  "data": [
    {
      "id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
      "slug": "acme-plumbing",
      "name": "Acme Plumbing",
      "deleted_at": "2026-10-14T09:00:00.000Z",
      "restore_until": "2026-11-13T09:00:00.000Z",
      "test": true
    }
  ]
}

POST /sites/{id}/restore

Restore a deleted site.

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).

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the site.

200 The site, live again.

Field Type
id (required) string (uuid) The id of the site. Use it to update or delete the site.
slug (required) string The first part of the address: https://.hypamoon.app.
name (required) string Your name for the site.
url (required) string The public address of the site, live right away.
files (required) SiteFile[] 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."
  ]
}

Errors

Besides the errors on every endpoint:

GET /sites/address-suggestions

Suggest an address for a site.

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.

200 The requested address, checked, and available suggestions.

Field Type
requested (required) object or null The slug you sent, checked. null without slug.
suggestions (required) object[] Up to three free addresses derived from name. Nothing is reserved: whoever uses one first gets it.
JSON
{
  "requested": {
    "slug": "acme",
    "available": false,
    "reason": "Taken by another site.",
    "url": "https://acme.hypamoon.app"
  },
  "suggestions": [
    {
      "slug": "acme-plumbing",
      "url": "https://acme-plumbing.hypamoon.app"
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
422 validation_failed

PUT /sites/{id}/address

Change the address or visibility of a site.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the site.

Request body

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.
JSON
{
  "slug": "acme-plumbing-rotterdam"
}

200 The site with its new address.

Field Type
id (required) string (uuid) The id of the site. Use it to update or delete the site.
slug (required) string The first part of the address: https://.hypamoon.app.
name (required) string Your name for the site.
url (required) string Where the site lives: https://.hypamoon.app, or the secret link when visibility is unlisted.
files (required) SiteFile[] 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.
JSON
{
  "id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
  "slug": "acme-plumbing-rotterdam",
  "name": "Acme Plumbing",
  "url": "https://acme-plumbing-rotterdam.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,
  "visibility": "public"
}

202 Four-eyes approval is on: the change waits for another owner or builder. Nothing has changed 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.
status (required) "pending", "processing", "approved", "rejected", "failed", "superseded", "expired" pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days.
kind (required) "new_site", "site_update", "site_delete", "app_deploy", "app_rollback" app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules.
site_id (required) string or null (uuid)
name (required) string or null
slug (required) string or null
url (required) string or null The address of the site. For an update or deletion it still shows the live version until approval.
files (required) integer
bytes (required) integer
password_change (required) "none", "remove", "set"
requested_by (required) object
requested_at (required) string
expires_at (required) string
reviewed_by (required) string or null (uuid)
reviewed_at (required) string or null
reason (required) string or null
result (required) object or null
JSON
{
  "object": "deploy_approval",
  "id": "8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90",
  "status": "pending",
  "kind": "site_update",
  "site_id": "3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c",
  "name": "Acme Plumbing",
  "slug": "acme-plumbing",
  "url": "https://acme-plumbing.hypamoon.app",
  "files": 2,
  "bytes": 1840,
  "password_change": "none",
  "requested_by": {
    "user_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "via": "api",
    "app": "Claude"
  },
  "requested_at": "2026-10-05T10:15:00Z",
  "expires_at": "2026-10-12T10:15:00Z",
  "reviewed_by": null,
  "reviewed_at": null,
  "reason": null,
  "result": null
}

Errors

Besides the errors on every endpoint:

POST /sites/{id}/rotate-secret-url

Make a new secret link for an unlisted site.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the site.
Field Type
id (required) string (uuid) The id of the site. Use it to update or delete the site.
slug (required) string The first part of the address: https://.hypamoon.app.
name (required) string Your name for the site.
url (required) string Where the site lives: https://.hypamoon.app, or the secret link when visibility is unlisted.
files (required) SiteFile[] 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.
JSON
{
  "id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
  "slug": "acme-plumbing",
  "name": "Acme Plumbing",
  "url": "https://k7q2m4x9r3t6w8y2b5c7d9f3gh.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,
  "visibility": "unlisted"
}

Errors

Besides the errors on every endpoint:

Status Type
403 site_offline
404 not_found
409 conflict
429 deploy_limit_reached

GET /sites/{id}/versions

List the versions of a site.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the site.
limit query integer How many versions per page (default 20).
cursor query integer The next_cursor of the previous page.

200 The versions, newest first.

Field Type
object (required) "list"
data (required) object[]
has_more (required) boolean
next_cursor (required) integer or null Send as cursor for the next page.
retention_days (required) integer How long the plan keeps versions.
labels_max (required) integer How many labeled versions a site can have on this plan.
JSON
{
  "object": "list",
  "data": [
    {
      "object": "site_version",
      "id": "8d0f5c1e-2b7a-4c55-9a51-3f1f2f6c9e10",
      "number": 4,
      "site_id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
      "created_at": "2026-10-06T09:12:00Z",
      "cause": "partial_deploy",
      "message": "New opening hours",
      "author": {
        "user_id": "5b1d6f3e-9a0c-4f2e-8d7b-2c4a1e9f0b33",
        "via": "connected_app",
        "client": "claude",
        "client_name": "Claude"
      },
      "changes": {
        "added": [],
        "modified": [
          "/index.html"
        ],
        "removed": [],
        "counts": {
          "added": 0,
          "modified": 1,
          "removed": 0
        },
        "truncated": false
      },
      "files": 3,
      "bytes": 18240,
      "current": true,
      "preview": false,
      "preview_url": null,
      "label": null,
      "labeled_at": null,
      "restored_from": null,
      "expires_at": null,
      "auto_label": null
    }
  ],
  "has_more": false,
  "next_cursor": null,
  "retention_days": 30,
  "labels_max": 5
}

Errors

Besides the errors on every endpoint:

GET /sites/{id}/diff

Show what changed between two versions.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the site.
from query string The id or number of the older version.
to query string The id or number of the newer version.

200 What changed.

Field Type
object (required) "site_diff"
site_id (required) string (uuid)
from (required) object or null
to (required) object
summary (required) object
files (required) object[]
truncated (required) boolean true: some diffs were left out to keep the answer small (diff_omitted per file).
JSON
{
  "object": "site_diff",
  "site_id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
  "from": {
    "id": "0b6c7e2a-1f3d-4e5a-9c8b-7d6e5f4a3b21",
    "number": 3
  },
  "to": {
    "id": "8d0f5c1e-2b7a-4c55-9a51-3f1f2f6c9e10",
    "number": 4
  },
  "summary": {
    "added": 0,
    "modified": 1,
    "removed": 0,
    "replaced": 0
  },
  "files": [
    {
      "path": "/index.html",
      "status": "modified",
      "kind": "text",
      "old_bytes": 2310,
      "new_bytes": 2342,
      "diff": "--- a/index.html\n+++ b/index.html\n@@ -12,1 +12,1 @@\n-<p>Open 9 to 5</p>\n+<p>Open 8 to 6</p>\n"
    }
  ],
  "truncated": false
}

Errors

Besides the errors on every endpoint:

POST /sites/{id}/rollback

Roll a site back to an earlier version.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the site.

Request body

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"
}

200 The site as it is now live, with the new version.

Field Type
site (required) Site A static site on hypamoon.app.
version (required) object or null One version of a site: a snapshot of its files.
unchanged (required) boolean true: the site already had exactly these files, so nothing changed (safe to retry).
JSON
{
  "site": {
    "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
  },
  "version": {
    "object": "site_version",
    "id": "8d0f5c1e-2b7a-4c55-9a51-3f1f2f6c9e10",
    "number": 5,
    "site_id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
    "created_at": "2026-10-06T09:12:00Z",
    "cause": "rollback",
    "message": "New opening hours",
    "author": {
      "user_id": "5b1d6f3e-9a0c-4f2e-8d7b-2c4a1e9f0b33",
      "via": "connected_app",
      "client": "claude",
      "client_name": "Claude"
    },
    "changes": {
      "added": [],
      "modified": [
        "/index.html"
      ],
      "removed": [],
      "counts": {
        "added": 0,
        "modified": 1,
        "removed": 0
      },
      "truncated": false
    },
    "files": 3,
    "bytes": 18240,
    "current": true,
    "preview": false,
    "preview_url": null,
    "label": null,
    "labeled_at": null,
    "restored_from": "0b6c7e2a-1f3d-4e5a-9c8b-7d6e5f4a3b21",
    "expires_at": null,
    "auto_label": null
  },
  "unchanged": false
}

202 Four-eyes approval is on: the rollback waits for another owner or builder. Nothing has changed 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.
status (required) "pending", "processing", "approved", "rejected", "failed", "superseded", "expired" pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days.
kind (required) "new_site", "site_update", "site_delete", "app_deploy", "app_rollback" app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules.
site_id (required) string or null (uuid)
name (required) string or null
slug (required) string or null
url (required) string or null The address of the site. For an update or deletion it still shows the live version until approval.
files (required) integer
bytes (required) integer
password_change (required) "none", "remove", "set"
requested_by (required) object
requested_at (required) string
expires_at (required) string
reviewed_by (required) string or null (uuid)
reviewed_at (required) string or null
reason (required) string or null
result (required) object or null
JSON
{
  "object": "deploy_approval",
  "id": "8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90",
  "status": "pending",
  "kind": "site_update",
  "site_id": "3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c",
  "name": "Acme Plumbing",
  "slug": "acme-plumbing",
  "url": "https://acme-plumbing.hypamoon.app",
  "files": 2,
  "bytes": 1840,
  "password_change": "none",
  "requested_by": {
    "user_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "via": "api",
    "app": "Claude"
  },
  "requested_at": "2026-10-05T10:15:00Z",
  "expires_at": "2026-10-12T10:15:00Z",
  "reviewed_by": null,
  "reviewed_at": null,
  "reason": null,
  "result": null
}

Errors

Besides the errors on every endpoint:

POST /sites/{id}/restore-files

Restore a few files from an earlier version.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the site.

Request body

Which files to restore, and from which version.

Field Type
version_id string (uuid) 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"
}

200 The site as it is now live, with the new version.

Field Type
site (required) Site A static site on hypamoon.app.
version (required) object or null One version of a site: a snapshot of its files.
unchanged (required) boolean true: the site already had exactly these files, so nothing changed (safe to retry).
JSON
{
  "site": {
    "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
  },
  "version": {
    "object": "site_version",
    "id": "8d0f5c1e-2b7a-4c55-9a51-3f1f2f6c9e10",
    "number": 7,
    "site_id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
    "created_at": "2026-10-06T09:12:00Z",
    "cause": "file_restore",
    "message": "Back to the working app.js and style.css",
    "author": {
      "user_id": "5b1d6f3e-9a0c-4f2e-8d7b-2c4a1e9f0b33",
      "via": "connected_app",
      "client": "claude",
      "client_name": "Claude"
    },
    "changes": {
      "added": [],
      "modified": [
        "/index.html"
      ],
      "removed": [],
      "counts": {
        "added": 0,
        "modified": 1,
        "removed": 0
      },
      "truncated": false
    },
    "files": 3,
    "bytes": 18240,
    "current": true,
    "preview": false,
    "preview_url": null,
    "label": null,
    "labeled_at": null,
    "restored_from": "0b6c7e2a-1f3d-4e5a-9c8b-7d6e5f4a3b21",
    "expires_at": null,
    "auto_label": null
  },
  "unchanged": false
}

202 Four-eyes approval is on: the restore waits for another owner or builder. Nothing has changed 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.
status (required) "pending", "processing", "approved", "rejected", "failed", "superseded", "expired" pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days.
kind (required) "new_site", "site_update", "site_delete", "app_deploy", "app_rollback" app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules.
site_id (required) string or null (uuid)
name (required) string or null
slug (required) string or null
url (required) string or null The address of the site. For an update or deletion it still shows the live version until approval.
files (required) integer
bytes (required) integer
password_change (required) "none", "remove", "set"
requested_by (required) object
requested_at (required) string
expires_at (required) string
reviewed_by (required) string or null (uuid)
reviewed_at (required) string or null
reason (required) string or null
result (required) object or null
JSON
{
  "object": "deploy_approval",
  "id": "8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90",
  "status": "pending",
  "kind": "site_update",
  "site_id": "3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c",
  "name": "Acme Plumbing",
  "slug": "acme-plumbing",
  "url": "https://acme-plumbing.hypamoon.app",
  "files": 2,
  "bytes": 1840,
  "password_change": "none",
  "requested_by": {
    "user_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "via": "api",
    "app": "Claude"
  },
  "requested_at": "2026-10-05T10:15:00Z",
  "expires_at": "2026-10-12T10:15:00Z",
  "reviewed_by": null,
  "reviewed_at": null,
  "reason": null,
  "result": null
}

Errors

Besides the errors on every endpoint:

POST /sites/{id}/versions/{versionId}/promote

Publish a preview.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the site.
versionId (required) path string (uuid) The id of the version, from list_site_versions.

Request body

Optional settings for putting the preview live.

Field Type
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
{
  "message": "Spring menu"
}

200 The site as it is now live, with the new version.

Field Type
site (required) Site A static site on hypamoon.app.
version (required) object or null One version of a site: a snapshot of its files.
unchanged (required) boolean true: the site already had exactly these files, so nothing changed (safe to retry).
JSON
{
  "site": {
    "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
  },
  "version": {
    "object": "site_version",
    "id": "8d0f5c1e-2b7a-4c55-9a51-3f1f2f6c9e10",
    "number": 6,
    "site_id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
    "created_at": "2026-10-06T09:12:00Z",
    "cause": "promote",
    "message": "New opening hours",
    "author": {
      "user_id": "5b1d6f3e-9a0c-4f2e-8d7b-2c4a1e9f0b33",
      "via": "connected_app",
      "client": "claude",
      "client_name": "Claude"
    },
    "changes": {
      "added": [],
      "modified": [
        "/index.html"
      ],
      "removed": [],
      "counts": {
        "added": 0,
        "modified": 1,
        "removed": 0
      },
      "truncated": false
    },
    "files": 3,
    "bytes": 18240,
    "current": true,
    "preview": false,
    "preview_url": null,
    "label": null,
    "labeled_at": null,
    "restored_from": null,
    "expires_at": null,
    "auto_label": null
  },
  "unchanged": false
}

202 Four-eyes approval is on: publishing the preview waits for another owner or builder.

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.
status (required) "pending", "processing", "approved", "rejected", "failed", "superseded", "expired" pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days.
kind (required) "new_site", "site_update", "site_delete", "app_deploy", "app_rollback" app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules.
site_id (required) string or null (uuid)
name (required) string or null
slug (required) string or null
url (required) string or null The address of the site. For an update or deletion it still shows the live version until approval.
files (required) integer
bytes (required) integer
password_change (required) "none", "remove", "set"
requested_by (required) object
requested_at (required) string
expires_at (required) string
reviewed_by (required) string or null (uuid)
reviewed_at (required) string or null
reason (required) string or null
result (required) object or null
JSON
{
  "object": "deploy_approval",
  "id": "8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90",
  "status": "pending",
  "kind": "site_update",
  "site_id": "3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c",
  "name": "Acme Plumbing",
  "slug": "acme-plumbing",
  "url": "https://acme-plumbing.hypamoon.app",
  "files": 2,
  "bytes": 1840,
  "password_change": "none",
  "requested_by": {
    "user_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "via": "api",
    "app": "Claude"
  },
  "requested_at": "2026-10-05T10:15:00Z",
  "expires_at": "2026-10-12T10:15:00Z",
  "reviewed_by": null,
  "reviewed_at": null,
  "reason": null,
  "result": null
}

Errors

Besides the errors on every endpoint:

PUT /sites/{id}/versions/{versionId}/label

Label a version.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the site.
versionId (required) path string (uuid) The id of the version, from list_site_versions.

Request body

The label of a version.

Field Type
label (required) string or null A name for this version, like "before launch" (1 to 60 characters, unique per site). null or an empty string removes it.
JSON
{
  "label": "before launch"
}

200 The version with its label.

Field Type
object (required) "site_version"
id (required) string (uuid) The id of the version: use it for get_site_diff, rollback_site, promote_site_version and label_site_version.
number (required) integer The number of the version within the site: 1, 2, 3 and so on.
site_id (required) string (uuid)
created_at (required) string When this version was made (ISO 8601).
cause (required) "baseline", "deploy", "partial_deploy", "import", "approved_deploy", "rollback", "promote", "preview", "file_restore" 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.
JSON
{
  "object": "site_version",
  "id": "8d0f5c1e-2b7a-4c55-9a51-3f1f2f6c9e10",
  "number": 4,
  "site_id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
  "created_at": "2026-10-06T09:12:00Z",
  "cause": "partial_deploy",
  "message": "New opening hours",
  "author": {
    "user_id": "5b1d6f3e-9a0c-4f2e-8d7b-2c4a1e9f0b33",
    "via": "connected_app",
    "client": "claude",
    "client_name": "Claude"
  },
  "changes": {
    "added": [],
    "modified": [
      "/index.html"
    ],
    "removed": [],
    "counts": {
      "added": 0,
      "modified": 1,
      "removed": 0
    },
    "truncated": false
  },
  "files": 3,
  "bytes": 18240,
  "current": true,
  "preview": false,
  "preview_url": null,
  "label": "before launch",
  "labeled_at": "2026-10-06T10:00:00Z",
  "restored_from": null,
  "expires_at": null,
  "auto_label": null
}

Errors

Besides the errors on every endpoint:

GET /sites/{id}/store-readiness

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the site.

200 The verdict per store, every check and the next steps.

Field Type
site (required) object
stores (required) object
checks (required) object[]
native_value (required) object
accounts (required) object
unknown (required) string[] 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."
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

GET /sites/{id}/store-listing

Draft the store listing for a site's app.

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.

200 The draft listing.

Field Type
site (required) object
source_language (required) string
note (required) string
apple (required) object
google_play (required) object
category_reason (required) string
review_notes (required) string
screenshots (required) object
limits (required) object
JSON
{
  "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
    }
  }
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
422 validation_failed

GET /sites/{id}/privacy-answers

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the site.

200 The answers per store, with reasons and console steps.

Field Type
site (required) object
sources (required) string[] 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."
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

POST /sites/{id}/app-export

Export a Capacitor app project for a site.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the site.

Request body

Field Type
app_id string 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.
JSON
{
  "app_id": "com.acmeplumbing.app",
  "icon_path": "/icon-1024.png"
}

201 The project is ready to download.

Field Type
download_url (required) string A signed link to the zip, valid for 15 minutes. Give it to the user; never store it.
expires_at (required) string
app_id (required) string
app_id_reason (required) string
app_name (required) string
app_url (required) string The address the app opens: the connected domain, or the site's hypamoon.app address.
icon (required) string or null Where the icon is in the zip, or null when the user must add one.
background_color (required) string
files (required) string[]
bytes (required) integer
warnings (required) string[]
test (required) boolean
JSON
{
  "download_url": "https://api.hypamoon.com/api/v1/public/app-exports/k7q2m4x9r3t6w8y2b5c7d9f3gh?o=5f0c9a2e-1b3d-4e5f-8a9b-0c1d2e3f4a5b&n=acme-plumbing-app&e=1791282000&s=3q2-7wEXAMPLEsignature",
  "expires_at": "2026-10-06T12:20:00.000Z",
  "app_id": "com.acmeplumbing.app",
  "app_id_reason": "Chosen by the user.",
  "app_name": "Acme Plumbing",
  "app_url": "https://acme-plumbing.hypamoon.app",
  "icon": "assets/icon-only.png",
  "background_color": "#0b5cff",
  "files": [
    "assets/icon-only.png",
    "package.json",
    "capacitor.config.json",
    "www/index.html",
    "www/offline.html",
    "www/app.js",
    ".gitignore",
    "hypamoon.json",
    "README.md"
  ],
  "bytes": 9214,
  "warnings": [],
  "test": false
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
422 validation_failed

GET /sites/{id}/readiness

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the site.
type query "webshop", "bookings", "restaurant", "course_membership", "wordpress_portal" The kind of application; leave it out to detect it from the site.

200 What works, what is missing, and what to check with the owner.

Field Type
site (required) object
type (required) "webshop", "bookings", "restaurant", "course_membership", "wordpress_portal", null
type_source (required) "given", "detected", "none"
region (required) "eu", "uk", "us", "other"
country (required) string or null
works (required) object[]
missing (required) object[]
check_with_owner (required) object[] 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)._"
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
422 validation_failed

POST /search-console/verify

Verify a site or domain in Google Search Console.

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.

Scope: sites:write

Request body

Field Type
site_id string (uuid) The site to verify. For method meta this is the site that gets the tag; for dns the domain connected to this site is used.
domain string A domain connected in this project (connect_domain), like example.com. For method dns; for meta, the site on this domain.
method (required) "dns", "meta" dns: a TXT record on the connected domain (a domain property in Search Console). meta: a meta tag on the home page (property type URL prefix).
token (required) string The verification token from Search Console: the value alone, google-site-verification=..., or the whole meta tag.
JSON
{
  "domain": "bakkerijjansen.nl",
  "method": "dns",
  "token": "Abc123dEf456gHi789jKl012mNo345pQr678sTu901v"
}

200 What was added and what the user does next.

Field Type
method (required) "dns", "meta"
status (required) "added", "already_present", "waiting_for_approval" 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."
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

GET /sites/{id}/stats

Get the visits of a site.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the site.
days query integer How many days back, today included (default 30).

200 Page views per day, top pages and origins.

Field Type
site_id (required) string
slug (required) string
days (required) integer
from (required) string The first day (UTC) in the range.
to (required) string The last day (UTC), today.
totals (required) object
per_day (required) object[]
top_pages (required) object[] Up to 10 paths with the most page views. Paths are recorded since 9 October 2026.
origins (required) object[] Where page views came from: direct (no referrer), internal (from the site itself), search, social, email, shortener or external (another site).
note (required) string
JSON
{
  "site_id": "5b1f3c2a-8d4e-4f6a-9b7c-1d2e3f4a5b6c",
  "slug": "bakkerij-jansen",
  "days": 7,
  "from": "2026-10-03",
  "to": "2026-10-09",
  "totals": {
    "page_views": 60,
    "bot_page_views_excluded": 41
  },
  "per_day": [
    {
      "date": "2026-10-03",
      "page_views": 0
    },
    {
      "date": "2026-10-04",
      "page_views": 0
    },
    {
      "date": "2026-10-05",
      "page_views": 12
    },
    {
      "date": "2026-10-06",
      "page_views": 0
    },
    {
      "date": "2026-10-07",
      "page_views": 30
    },
    {
      "date": "2026-10-08",
      "page_views": 0
    },
    {
      "date": "2026-10-09",
      "page_views": 18
    }
  ],
  "top_pages": [
    {
      "path": "/",
      "page_views": 34
    },
    {
      "path": "/menu",
      "page_views": 19
    },
    {
      "path": "/contact",
      "page_views": 7
    }
  ],
  "origins": [
    {
      "origin": "search",
      "page_views": 28
    },
    {
      "origin": "direct",
      "page_views": 20
    },
    {
      "origin": "social",
      "page_views": 12
    }
  ],
  "note": "Page views by people, counted at the edge without cookies; not unique visitors. Numbers can be estimates on busy days (sampling). Paths and the bot filter exist since 9 October 2026; older data is kept for 90 days."
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
422 validation_failed

GET /sites/{id}/fetch

Fetch a page of a site or app, live.

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.
method query "GET", "HEAD" GET (default) or HEAD.

200 What the site answered.

Field Type
url (required) string The address that was asked for.
final_url (required) string The address that answered, after the redirects that were followed.
method (required) "GET", "HEAD"
status (required) integer The HTTP status of the final answer.
ok (required) boolean true for a 2xx status.
headers (required) object content-type, cache-control, location, etag, x-* and a few more. Never set-cookie.
redirects (required) object[] The redirects that were followed, in order.
redirect_blocked string Set when a redirect was not followed (another host, not https, or too many); status and headers are then of that redirect.
content_type (required) string or null
body (required) string or null The body as text, at most 20 KB; null for HEAD, an empty body or a body that is not text.
body_bytes (required) integer Bytes received (reading stops after 256 KB).
truncated (required) boolean true when the body is longer than what is returned.
body_note string Why the body is not shown, when it is not.
elapsed_ms (required) integer
JSON
{
  "url": "https://bakkerij-jansen.hypamoon.app/contact",
  "final_url": "https://bakkerij-jansen.hypamoon.app/contact",
  "method": "GET",
  "status": 200,
  "ok": true,
  "headers": {
    "content-type": "text/html; charset=utf-8",
    "cache-control": "public, max-age=60"
  },
  "redirects": [],
  "content_type": "text/html",
  "body": "<!doctype html><html lang=\"nl\"><head><title>Contact | Bakkerij Jansen</title>",
  "body_bytes": 4210,
  "truncated": false,
  "elapsed_ms": 182
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
422 validation_failed
502 upstream_error

POST /sites/import

Import an existing site.

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.

Scope: sites:write

Request body

Field Type
url (required) string The https address of the existing site, on a domain this project connected (like https://www.example.com).
site_id string (uuid) Optional. An existing site of this project to replace with the imported files. Without it, a new site is created.
name string Optional. A name for the new site; default the host name.
slug string Optional. The address of a new site, like deploy_site's slug.
max_pages integer Optional. At most this many pages (default 25, at most 50).
JSON
{
  "url": "https://www.example.com"
}

200 The replaced site, with import.

201 The new site, with import.

202 Four-eyes approval is on: the imported site waits for another owner or builder, with import. Nothing has 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.
status (required) "pending", "processing", "approved", "rejected", "failed", "superseded", "expired" pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days.
kind (required) "new_site", "site_update", "site_delete", "app_deploy", "app_rollback" app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules.
site_id (required) string or null (uuid)
name (required) string or null
slug (required) string or null
url (required) string or null The address of the site. For an update or deletion it still shows the live version until approval.
files (required) integer
bytes (required) integer
password_change (required) "none", "remove", "set"
requested_by (required) object
requested_at (required) string
expires_at (required) string
reviewed_by (required) string or null (uuid)
reviewed_at (required) string or null
reason (required) string or null
result (required) object or null
JSON
{
  "object": "deploy_approval",
  "id": "8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90",
  "status": "pending",
  "kind": "new_site",
  "site_id": null,
  "name": "Acme Plumbing",
  "slug": "acme-plumbing",
  "url": "https://acme-plumbing.hypamoon.app",
  "files": 2,
  "bytes": 1840,
  "password_change": "none",
  "requested_by": {
    "user_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "via": "api",
    "app": "Claude"
  },
  "requested_at": "2026-10-05T10:15:00Z",
  "expires_at": "2026-10-12T10:15:00Z",
  "reviewed_by": null,
  "reviewed_at": null,
  "reason": null,
  "result": null
}

Errors

Besides the errors on every endpoint:

POST /sites/inspect-wordpress

Inspect a WordPress site.

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.

Scope: sites:read

Request body

Field Type
url (required) string The https address of the WordPress site, like https://www.example.com. Any public site: nothing is changed and no login is used.
JSON
{
  "url": "https://www.example.com"
}

200 What was found, what can move (can_move) and what cannot (cannot_move), with next_step.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
422 validation_failed

POST /sites/import-wordpress

Import a WordPress 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).

Scope: sites:write

Request body

Field Type
url (required) string 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).
JSON
{
  "url": "https://www.example.com"
}

200 The updated site, with import and data.

201 The new site, with import and data.

202 Four-eyes approval is on: the imported site waits for another owner or builder. Nothing has 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.
status (required) "pending", "processing", "approved", "rejected", "failed", "superseded", "expired" pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days.
kind (required) "new_site", "site_update", "site_delete", "app_deploy", "app_rollback" app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules.
site_id (required) string or null (uuid)
name (required) string or null
slug (required) string or null
url (required) string or null The address of the site. For an update or deletion it still shows the live version until approval.
files (required) integer
bytes (required) integer
password_change (required) "none", "remove", "set"
requested_by (required) object
requested_at (required) string
expires_at (required) string
reviewed_by (required) string or null (uuid)
reviewed_at (required) string or null
reason (required) string or null
result (required) object or null
JSON
{
  "object": "deploy_approval",
  "id": "8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90",
  "status": "pending",
  "kind": "new_site",
  "site_id": null,
  "name": "Acme Plumbing",
  "slug": "acme-plumbing",
  "url": "https://acme-plumbing.hypamoon.app",
  "files": 2,
  "bytes": 1840,
  "password_change": "none",
  "requested_by": {
    "user_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "via": "api",
    "app": "Claude"
  },
  "requested_at": "2026-10-05T10:15:00Z",
  "expires_at": "2026-10-12T10:15:00Z",
  "reviewed_by": null,
  "reviewed_at": null,
  "reason": null,
  "result": null
}

Errors

Besides the errors on every endpoint:

Media

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.

GET /media

List the media library.

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.
site_id query string (uuid) Only the files this site uses.
limit query integer At most this many files (default 100).
cursor query string next_cursor from the previous page.

200 The files and the folders.

Field Type
data (required) MediaFile[]
folders (required) object[] Every folder in the library with its number of files (empty is /media/ itself).
next_cursor (required) string or null Pass it as cursor for the next page; null when there is no more.
JSON
{
  "data": [
    {
      "id": "6c0e3a5b-2f1d-4a8e-9b7c-1d2e3f4a5b6c",
      "path": "/media/portfolio/project-12/03.webp",
      "folder": "portfolio/project-12",
      "name": "03.webp",
      "original_name": "03.jpg",
      "content_type": "image/webp",
      "bytes": 384112,
      "width": 2048,
      "height": 1365,
      "alt": null,
      "url": null,
      "variants": {
        "640": {
          "path": "/media/portfolio/project-12/03-640.webp",
          "bytes": 41240,
          "width": 640,
          "height": 427,
          "url": null
        }
      },
      "srcset": null,
      "original_kept": true,
      "sites": [],
      "source": "portaal",
      "created_at": "2026-10-01T09:00:00.000Z",
      "updated_at": "2026-10-01T09:00:00.000Z"
    }
  ],
  "folders": [
    {
      "folder": "portfolio/project-12",
      "files": 1
    }
  ],
  "next_cursor": null
}

Errors

Besides the errors on every endpoint:

Status Type
422 validation_failed

POST /media/links

Create an upload page for the media library.

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.

Scope: sites:write

Request body

Create an upload page for the media library.

Field Type
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.
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.
JSON
{
  "folder": "portfolio"
}

201 The upload page.

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.
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."
}

Errors

Besides the errors on every endpoint:

GET /media/{id}

Get a file from the media library.

One file with its versions, alt text and the sites that use it.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the file.

200 The file.

Field Type
id (required) string (uuid) The id of the file in the library.
path (required) string 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.
created_at (required) string
updated_at (required) string
JSON
{
  "id": "6c0e3a5b-2f1d-4a8e-9b7c-1d2e3f4a5b6c",
  "path": "/media/portfolio/project-12/03.webp",
  "folder": "portfolio/project-12",
  "name": "03.webp",
  "original_name": "03.jpg",
  "content_type": "image/webp",
  "bytes": 384112,
  "width": 2048,
  "height": 1365,
  "alt": null,
  "url": null,
  "variants": {
    "640": {
      "path": "/media/portfolio/project-12/03-640.webp",
      "bytes": 41240,
      "width": 640,
      "height": 427,
      "url": null
    }
  },
  "srcset": null,
  "original_kept": true,
  "sites": [],
  "source": "portaal",
  "created_at": "2026-10-01T09:00:00.000Z",
  "updated_at": "2026-10-01T09:00:00.000Z"
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PATCH /media/{id}

Rename or move a file, or set its alt text.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the file.

Request body

Rename or move a file in the library, or set its alt text. Sites keep the file under the path they have until you deploy again.

Field Type
name string A new file name. The extension always stays that of the file.
folder string or null Move the file to this folder under /media/; null or an empty string for /media/ itself.
alt string or null The alt text for the image; null removes it.
JSON
{
  "alt": "Apple pie on a wooden board"
}

200 The file after the change.

Field Type
id (required) string (uuid) The id of the file in the library.
path (required) string 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.
created_at (required) string
updated_at (required) string
JSON
{
  "id": "6c0e3a5b-2f1d-4a8e-9b7c-1d2e3f4a5b6c",
  "path": "/media/portfolio/project-12/03.webp",
  "folder": "portfolio/project-12",
  "name": "03.webp",
  "original_name": "03.jpg",
  "content_type": "image/webp",
  "bytes": 384112,
  "width": 2048,
  "height": 1365,
  "alt": "Apple pie on a wooden board",
  "url": null,
  "variants": {
    "640": {
      "path": "/media/portfolio/project-12/03-640.webp",
      "bytes": 41240,
      "width": 640,
      "height": 427,
      "url": null
    }
  },
  "srcset": null,
  "original_kept": true,
  "sites": [],
  "source": "portaal",
  "created_at": "2026-10-01T09:00:00.000Z",
  "updated_at": "2026-10-01T09:00:00.000Z"
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

DELETE /media/{id}

Delete a file from the media library.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the file.

204 Deleted.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
409 conflict

Data

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.

GET /collections

List the collections.

The project's collections, newest first, each with its access, triggers, schema and document_count. Deleted collections are not listed.

Scope: data:read

200 The collections.

Field Type
data (required) Collection[] The collections of the project, newest first. Deleted collections are not listed.
JSON
{
  "data": [
    {
      "id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
      "name": "contact",
      "description": "The contact form on the website.",
      "access": {
        "public_write": true,
        "public_read": false,
        "assistant_read": false
      },
      "triggers": {
        "email_on_insert": true,
        "webhook_on_insert": false
      },
      "email_routing": null,
      "schema": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "message": {
            "type": "string",
            "minLength": 1,
            "maxLength": 5000
          }
        },
        "required": [
          "name",
          "email",
          "message"
        ]
      },
      "personal_data": false,
      "document_count": 1,
      "created_at": "2026-10-01T09:00:00.000Z",
      "updated_at": "2026-10-01T09:00:00.000Z",
      "test": false
    }
  ]
}

POST /collections

Create a collection.

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.

Scope: data:write

Request body

A new collection.

Field Type
name (required) 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 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.
JSON
{
  "name": "contact",
  "description": "The contact form on the website.",
  "access": {
    "public_write": true
  },
  "triggers": {
    "email_on_insert": true
  },
  "schema": {
    "type": "object",
    "properties": {
      "name": {
        "type": "string",
        "minLength": 1,
        "maxLength": 100
      },
      "email": {
        "type": "string",
        "format": "email"
      },
      "message": {
        "type": "string",
        "minLength": 1,
        "maxLength": 5000
      }
    },
    "required": [
      "name",
      "email",
      "message"
    ]
  }
}

201 The new collection.

Field Type
id (required) string (uuid) The id of the collection. Its name works in the path as well.
name (required) string The name, unique in the project. A form posts to it by this name.
description (required) string or null What the collection is for, or null.
access (required) object Who may use the collection besides your API key. All false is private.
triggers (required) object
email_routing (required) object or null Where the email of email_on_insert goes. null: the billing email address.
schema (required) object or null The JSON Schema every document must match, or null.
personal_data boolean true for a collection with personal data imported from WordPress (form entries, orders, customers, users, reviews). It always stays private.
document_count (required) integer How many documents the collection has now.
created_at (required) string (date-time) When the collection was created.
updated_at (required) string (date-time) When the collection last changed.
test (required) boolean true for a collection of a test project.
JSON
{
  "id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
  "name": "contact",
  "description": "The contact form on the website.",
  "access": {
    "public_write": true,
    "public_read": false,
    "assistant_read": false
  },
  "triggers": {
    "email_on_insert": true,
    "webhook_on_insert": false
  },
  "email_routing": null,
  "schema": {
    "type": "object",
    "properties": {
      "name": {
        "type": "string",
        "minLength": 1,
        "maxLength": 100
      },
      "email": {
        "type": "string",
        "format": "email"
      },
      "message": {
        "type": "string",
        "minLength": 1,
        "maxLength": 5000
      }
    },
    "required": [
      "name",
      "email",
      "message"
    ]
  },
  "personal_data": false,
  "document_count": 0,
  "created_at": "2026-10-01T09:00:00.000Z",
  "updated_at": "2026-10-01T09:00:00.000Z",
  "test": false
}

Errors

Besides the errors on every endpoint:

GET /collections/{id}

Get a collection.

One collection with its access, triggers, schema and document_count. The path takes the id or the name.

Scope: data:read

Parameter In Type
id (required) path string The id of the collection, or its name.

200 The collection.

Field Type
id (required) string (uuid) The id of the collection. Its name works in the path as well.
name (required) string The name, unique in the project. A form posts to it by this name.
description (required) string or null What the collection is for, or null.
access (required) object Who may use the collection besides your API key. All false is private.
triggers (required) object
email_routing (required) object or null Where the email of email_on_insert goes. null: the billing email address.
schema (required) object or null The JSON Schema every document must match, or null.
personal_data boolean true for a collection with personal data imported from WordPress (form entries, orders, customers, users, reviews). It always stays private.
document_count (required) integer How many documents the collection has now.
created_at (required) string (date-time) When the collection was created.
updated_at (required) string (date-time) When the collection last changed.
test (required) boolean true for a collection of a test project.
JSON
{
  "id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
  "name": "contact",
  "description": "The contact form on the website.",
  "access": {
    "public_write": true,
    "public_read": false,
    "assistant_read": false
  },
  "triggers": {
    "email_on_insert": true,
    "webhook_on_insert": false
  },
  "email_routing": null,
  "schema": {
    "type": "object",
    "properties": {
      "name": {
        "type": "string",
        "minLength": 1,
        "maxLength": 100
      },
      "email": {
        "type": "string",
        "format": "email"
      },
      "message": {
        "type": "string",
        "minLength": 1,
        "maxLength": 5000
      }
    },
    "required": [
      "name",
      "email",
      "message"
    ]
  },
  "personal_data": false,
  "document_count": 1,
  "created_at": "2026-10-01T09:00:00.000Z",
  "updated_at": "2026-10-01T09:00:00.000Z",
  "test": false
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PATCH /collections/{id}

Change a collection.

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.

Scope: data:write

Parameter In Type
id (required) path string The id of the collection, or its name.

Request body

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.
JSON
{
  "access": {
    "public_write": true
  },
  "triggers": {
    "webhook_on_insert": true
  }
}

200 The collection as it is now.

Field Type
id (required) string (uuid) The id of the collection. Its name works in the path as well.
name (required) string The name, unique in the project. A form posts to it by this name.
description (required) string or null What the collection is for, or null.
access (required) object Who may use the collection besides your API key. All false is private.
triggers (required) object
email_routing (required) object or null Where the email of email_on_insert goes. null: the billing email address.
schema (required) object or null The JSON Schema every document must match, or null.
personal_data boolean true for a collection with personal data imported from WordPress (form entries, orders, customers, users, reviews). It always stays private.
document_count (required) integer How many documents the collection has now.
created_at (required) string (date-time) When the collection was created.
updated_at (required) string (date-time) When the collection last changed.
test (required) boolean true for a collection of a test project.
JSON
{
  "id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
  "name": "contact",
  "description": "The contact form on the website.",
  "access": {
    "public_write": true,
    "public_read": false,
    "assistant_read": false
  },
  "triggers": {
    "email_on_insert": true,
    "webhook_on_insert": false
  },
  "email_routing": null,
  "schema": {
    "type": "object",
    "properties": {
      "name": {
        "type": "string",
        "minLength": 1,
        "maxLength": 100
      },
      "email": {
        "type": "string",
        "format": "email"
      },
      "message": {
        "type": "string",
        "minLength": 1,
        "maxLength": 5000
      }
    },
    "required": [
      "name",
      "email",
      "message"
    ]
  },
  "personal_data": false,
  "document_count": 1,
  "created_at": "2026-10-01T09:00:00.000Z",
  "updated_at": "2026-10-01T09:00:00.000Z",
  "test": false
}

Errors

Besides the errors on every endpoint:

DELETE /collections/{id}

Delete a collection.

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.

Scope: data:write

Parameter In Type
id (required) path string The id of the collection, or its name.

204 Deleted.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

GET /collections/{id}/documents

List the documents.

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.

200 A page of documents.

Field Type
data (required) Document[] The documents, newest first.
has_more (required) boolean Whether there is another page.
next_cursor (required) string or null Pass this as cursor to get the next page.
JSON
{
  "data": [
    {
      "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
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_parameter
404 not_found

POST /collections/{id}/documents

Add a document.

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).

Scope: data:write

Parameter In Type
id (required) path string The id of the collection, or its name.

Request body

A new document.

Field Type
data (required) object The document: any JSON object, at most 64 kB.
JSON
{
  "data": {
    "name": "Anna de Vries",
    "email": "anna@example.com",
    "message": "Can you come and look at a leaking tap on Friday?"
  }
}

201 The new document.

Field Type
id (required) string (uuid) The id of the document.
collection_id (required) string (uuid) The id of its collection.
data (required) object The document itself: a JSON object.
source (required) "api", "public" 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
}

Errors

Besides the errors on every endpoint:

GET /collections/{id}/documents/{docId}

Get a document.

One document from the collection.

Scope: data:read

Parameter In Type
id (required) path string The id of the collection, or its name.
docId (required) path string (uuid) The id of the document.

200 The document.

Field Type
id (required) string (uuid) The id of the document.
collection_id (required) string (uuid) The id of its collection.
data (required) object The document itself: a JSON object.
source (required) "api", "public" 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
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PATCH /collections/{id}/documents/{docId}

Change a document.

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.

Scope: data:write

Parameter In Type
id (required) path string The id of the collection, or its name.
docId (required) path string (uuid) The id of the document.

Request body

Change a document: send data to replace it, or merge to change some fields. Not both.

Field Type
data object The document: any JSON object, at most 64 kB.
merge object Merged into the document as a JSON Merge Patch (RFC 7396): a field you send replaces that field, null removes it, an object is merged field by field.
JSON
{
  "merge": {
    "status": "answered"
  }
}

200 The document as it is now.

Field Type
id (required) string (uuid) The id of the document.
collection_id (required) string (uuid) The id of its collection.
data (required) object The document itself: a JSON object.
source (required) "api", "public" 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
}

Errors

Besides the errors on every endpoint:

DELETE /collections/{id}/documents/{docId}

Delete a document.

Deletes the document immediately and permanently. A second delete returns 404.

Scope: data:write

Parameter In Type
id (required) path string The id of the collection, or its name.
docId (required) path string (uuid) The id of the document.

204 Deleted.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

GET /public/collections/{name}/documents

Read the newest documents (public).

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.

Parameter In Type
name (required) path string The name of the collection.
limit query integer How many, default 25, at most 100.

200 The newest documents.

Field Type
data (required) object[] The newest documents, newest first: only data and created_at.
JSON
{
  "data": [
    {
      "data": {
        "dish": "Soup of the day",
        "price_eur": 6.5
      },
      "created_at": "2026-10-01T09:00:00.000Z"
    }
  ]
}

Errors

Besides the errors on every endpoint:

POST /public/collections/{name}/documents

Send a form entry (public).

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.

Parameter In Type
name (required) path string The name of the collection, like contact.

Request body

A document from a form on a website.

Field Type
data (required) object 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"
}

201 Stored.

Field Type
id (required) string (uuid) The id of the new document.
created_at (required) string (date-time) When it was stored.
test (required) boolean true when the public token is a test token: stored, but no email is sent.
JSON
{
  "id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
  "created_at": "2026-10-01T09:30:00.000Z",
  "test": false
}

Errors

Besides the errors on every endpoint:

Test mode

Build and test without a real number or real costs: a test key (hm_test_) works on a test project with test numbers and simulated calls.

POST /test/calls

Simulate a call.

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.

Scope: calls:read

Request body

Field Type
number_id (required) string (uuid) The id of one of your test numbers, from GET /numbers.
scenario (required) "reservation", "callback_request", "question", "missed" What happens in the call: reservation, callback_request, question, missed. missed has no transcript and no summary, so only call.completed is sent.
from string Optional: the caller, a fictional number from +447700900000 to +447700900999.
caller_goal string
JSON
{
  "number_id": "0d1e2f3a-4b5c-4d6e-8f70-8192a3b4c5d6",
  "scenario": "question",
  "caller_goal": "Ask whether you are open on Sunday."
}

201 The simulated call, as GET /calls/{id} returns it.

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.
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
  }
}

Errors

Besides the errors on every endpoint:

WordPress

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.

POST /wordpress/connect-codes

Create a code to connect a WordPress site.

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.

Scope: data:write

Request body

An empty object.

JSON
{}

201 The code, when it expires, and next_step for the agent.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json

GET /wordpress/connections

List connected WordPress sites.

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.

Scope: data:read

200 data: the connections.

GET /wordpress/connections/{id}

Get the inventory of a connected WordPress site.

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.

Scope: data:read

Parameter In Type
id (required) path string (uuid) The id of the WordPress connection.

200 The connection with inventory, selection and next_step.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

DELETE /wordpress/connections/{id}

Revoke a WordPress connection.

The plugin's token stops working right away. The collections and their documents stay.

Scope: data:write

Parameter In Type
id (required) path string (uuid) The id of the WordPress connection.

204 Revoked.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PUT /wordpress/connections/{id}/selection

Choose what moves from a WordPress site.

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.

Scope: data:write

Parameter In Type
id (required) path string (uuid) The id of the WordPress connection.

Request body

Field Type
items object 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).
JSON
{
  "items": {
    "orders": {
      "enabled": true,
      "since": "2024-10-12",
      "omit_fields": [
        "phone"
      ]
    }
  },
  "version": 2
}

200 The new selection, with its version.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

GET /wordpress/connections/{id}/suggestion

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.

Scope: data:read

Parameter In Type
id (required) path string (uuid) The id of the WordPress connection.

200 inventory (compact), suggestion and selection_version.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PUT /wordpress/connections/{id}/suggestion

Suggest a selection for a WordPress site.

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.

Scope: data:write

Parameter In Type
id (required) path string (uuid) The id of the WordPress connection.

Request body

Field Type
goal (required) string 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."
    }
  }
}

201 The suggestion with its differences from the current selection, a summary and next_step.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

GET /wordpress/connections/{id}/embeds

Read the chat and forms on a 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.

Scope: data:read

Parameter In Type
id (required) path string (uuid) The id of the WordPress connection.

200 The chat, the forms, the version and problems.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PUT /wordpress/connections/{id}/embeds

Put the chat and forms on a WordPress site.

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).

Scope: data:write

Parameter In Type
id (required) path string (uuid) The id of the WordPress connection.

Request body

Field Type
chat object or null The chat window on the site. Leave it out to keep it as it is.
forms object[] Forms to offer or change. Forms you leave out keep their settings.
version integer or null The version you showed the user. If it changed meanwhile, nothing changes (409). Leave it out to change it anyway.
JSON
{
  "chat": {
    "enabled": true,
    "color": "#0f766e"
  },
  "forms": [
    {
      "collection": "contact",
      "title": "Contact us"
    }
  ],
  "version": 0
}

200 The new settings, as GET returns them.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

POST /wordpress/connect

Connect the plugin with a code (plugin).

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.

No API key needed.

Request body

Field Type
code (required) string The one-time code of 8 characters, like ABCD-EF23 (dashes, spaces and case do not matter).
site_url (required) string The address of the WordPress site (home_url), like https://www.example.com.
site_name string or null The name of the WordPress site.
wordpress_version string or null The WordPress version, like 6.6.2.
plugin_version string or null The version of Hypamoon Connect, like 1.0.0.
JSON
{
  "code": "ABCD-EF23",
  "site_url": "https://www.example.com",
  "site_name": "Example",
  "wordpress_version": "6.6.2",
  "plugin_version": "1.0.0"
}

200 The token, the connection, the project and the limits.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
422 validation_failed

GET /wordpress/connection

Read the connection (plugin).

Called by the plugin: the project, the site, when the token expires, the selection and the last export round.

200 The connection.

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden

DELETE /wordpress/connection

Disconnect (plugin).

Called by the plugin when the admin disconnects: the token stops working. The collections stay.

204 Disconnected.

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden

PUT /wordpress/connection/inventory

Send the inventory (plugin).

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).

Request body

Field Type
items (required) object[] One item per kind of data: counts, period and field names, never content.
JSON
{
  "items": [
    {
      "key": "content:post",
      "type": "content",
      "label": "Posts",
      "count": 120,
      "oldest": "2019-03-01T10:00:00Z",
      "newest": "2026-10-01T09:00:00.000Z",
      "statuses": {
        "publish": 100,
        "draft": 20
      },
      "post_type": "post"
    },
    {
      "key": "orders",
      "type": "orders",
      "label": "Orders",
      "count": 830,
      "oldest": "2021-01-04T09:00:00Z",
      "newest": "2026-10-01T09:00:00.000Z"
    }
  ]
}

200 inventory_at and the selection.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
403 forbidden
409 conflict
422 validation_failed

GET /wordpress/connection/selection

Pull the selection (plugin).

Called by the plugin: what the user chose (in a chat with their AI assistant, in the portal or in the plugin), with its version.

200 The selection.

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden

PUT /wordpress/connection/selection

Change the selection (plugin).

Called by the plugin when the admin changes the checkboxes in WordPress. Send the version it showed; a newer one gives 409.

Request body

Field Type
items object 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).
JSON
{
  "items": {
    "users": {
      "enabled": true,
      "omit_fields": null
    }
  },
  "version": 2
}

200 The new selection.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
403 forbidden
409 conflict
422 validation_failed

DELETE /wordpress/connection/suggestion

Close the suggestion of the chat (plugin).

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.

Parameter In Type
outcome query "applied", "dismissed" applied or dismissed.

200 The status of the suggestion.

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden

GET /wordpress/connection/embeds

Pull the chat and forms for the site (plugin).

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.

200 The chat, the forms, the version and problems.

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden

PUT /wordpress/connection/embeds

Turn the chat on or off (plugin).

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.

Request body

Field Type
chat object or null
version integer or null
JSON
{
  "chat": {
    "enabled": true
  },
  "version": 1
}

200 The new settings.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
403 forbidden
409 conflict
422 validation_failed

POST /wordpress/exports

Start an export round (plugin).

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.

Request body

Field Type
selection_version (required) integer The selection version the plugin pulled and shows. A newer one gives 409: pull again.
JSON
{
  "selection_version": 2
}

201 The round: export_id, status and the selection.

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
403 forbidden
409 conflict
422 validation_failed

POST /wordpress/exports/{id}/batches

Send a batch (plugin).

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.

Parameter In Type
id (required) path string (uuid) The id of the export round.

Request body

Field Type
batch (required) string A key for this batch, unique and deterministic within the round, like content:post:after:0. The same key again changes nothing.
key (required) string The inventory key, like content:post, orders or form_entries:wpforms:12.
type (required) "content", "media", "menus", "terms", "acf_field_groups", "acf_options", "form_entries", "products", "orders", "customers", "users", "coupons", "shipping", "tax", "reviews", "subscriptions", "table", "options" The kind of data, the first part of key.
form object or null Only for form_entries: the form.
records (required) any[] At most 200 records, each with wp_id.
JSON
{
  "batch": "content:post:after:0",
  "key": "content:post",
  "type": "content",
  "records": [
    {
      "wp_id": 12,
      "post_type": "post",
      "status": "draft",
      "title": "Spring menu",
      "content_html": "<p>...</p>"
    }
  ]
}

200 What was stored: received, created, updated, skipped (with reasons), the collections and the totals of the round.

Errors

Besides the errors on every endpoint:

POST /wordpress/exports/{id}/finish

Finish an export round (plugin).

Called by the plugin at the end of a round: the collections with their document counts and next_step for the admin.

Parameter In Type
id (required) path string (uuid) The id of the export round.

200 The round's result.

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden
404 not_found
409 conflict

MCP

The Model Context Protocol server, for agents and AI tools.

POST /mcp

Model Context Protocol endpoint.

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.

Scope: project:read

Request body

Field Type
jsonrpc (required) "2.0"
id one of
method (required) string
params object
JSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list"
}

200 The JSON-RPC response.

Field Type
jsonrpc (required) "2.0"
id (required) one of
result object
JSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": []
  }
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json

Meta

About the API itself.

GET /openapi.json

This OpenAPI document.

The OpenAPI 3.1 document of the API. Public: no key needed.

No API key needed.

200 The OpenAPI document.

GET /status

Check whether Hypamoon is working.

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.

No API key needed.

200 The status.

Field Type
status (required) "operational", "degraded", "down", "unknown" The overall status: the worst of the components (unknown does not count).
source (required) "status_page", "self_check" status_page: from status.hypamoon.com. self_check: that page did not answer, so only a minimal check of our own.
components (required) object[] Per component: sites, api, portal, phone, ai, mail, payments.
incidents (required) object[] Reported problems that are not resolved yet.
status_page (required) string The public status page.
checked_at (required) string When the status was measured.
user_message (required) string One calm sentence for the user, like "Hypamoon is working normally. The problem is likely in your chat app."
next_step (required) string What the chat or agent should do next.
JSON
{
  "status": "operational",
  "source": "status_page",
  "components": [
    {
      "id": "sites",
      "name": "Sites and apps",
      "status": "operational"
    },
    {
      "id": "api",
      "name": "API and chat connection",
      "status": "operational"
    },
    {
      "id": "portal",
      "name": "Portal",
      "status": "operational"
    },
    {
      "id": "phone",
      "name": "Phone",
      "status": "operational"
    },
    {
      "id": "ai",
      "name": "AI receptionist",
      "status": "operational"
    },
    {
      "id": "mail",
      "name": "Email",
      "status": "operational"
    },
    {
      "id": "payments",
      "name": "Payments",
      "status": "operational"
    }
  ],
  "incidents": [],
  "status_page": "https://status.hypamoon.com",
  "checked_at": "2026-10-01T09:00:00.000Z",
  "user_message": "Hypamoon is working normally. The problem is likely in your chat app.",
  "next_step": "If a step failed, read its error and user_message."
}

Agents

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.

GET /agents

List the agents.

The agents of the project, newest first, each with pending_approvals: the actions waiting for the owner.

Scope: project:read

200 The agents.

Field Type
data (required) object[]
JSON
{
  "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"
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

POST /agents

Create an agent.

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.

Scope: project:write

Request body

A new agent.

Field Type
name (required) string A short name, like New contact request follow-up.
instructions (required) 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 (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
  }
}

201 The new agent.

Field Type
id (required) string (uuid)
name (required) string
instructions (required) string
trigger (required) object
actions (required) object
limits (required) object
status (required) "active", "paused"
mode (required) "live", "test"
next_run_at (required) string or null
pending_approvals (required) integer
created_by (required) string or null 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"
}

Errors

Besides the errors on every endpoint:

GET /agents/{id}

Get an agent.

One agent with its five newest runs, each with its actions (including those waiting for approval).

Scope: project:read

Parameter In Type
id (required) path string (uuid) The id of the agent.

200 The agent.

Field Type
id (required) string (uuid)
name (required) string
instructions (required) string
trigger (required) object
actions (required) object
limits (required) object
status (required) "active", "paused"
mode (required) "live", "test"
next_run_at (required) string or null
pending_approvals (required) integer
created_by (required) string or null 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
        }
      ]
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PATCH /agents/{id}

Change an agent.

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.

Scope: project:write

Parameter In Type
id (required) path string (uuid) The id of the agent.

Request body

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.
limits object The limits of the agent.
status "active", "paused" paused stops new runs; active starts them again.
JSON
{
  "actions": {
    "email_owner": {
      "mode": "auto"
    },
    "start_call": {
      "mode": "approve"
    },
    "send_sms": {
      "mode": "approve"
    }
  }
}

200 The agent as it is now.

Field Type
id (required) string (uuid)
name (required) string
instructions (required) string
trigger (required) object
actions (required) object
limits (required) object
status (required) "active", "paused"
mode (required) "live", "test"
next_run_at (required) string or null
pending_approvals (required) integer
created_by (required) string or null 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"
}

Errors

Besides the errors on every endpoint:

DELETE /agents/{id}

Delete an agent.

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.

Scope: project:write

Parameter In Type
id (required) path string (uuid) The id of the agent.

204 Deleted.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

GET /agents/{id}/runs

List the runs of an agent.

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).

Scope: project:read

Parameter In Type
id (required) path string (uuid) The id of the agent.
limit query integer Runs per page, 1 to 50.
before query string (date-time) Only runs created before this moment.

200 The runs.

Field Type
data (required) object[]
has_more (required) boolean
JSON
{
  "data": [
    {
      "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
        }
      ]
    }
  ],
  "has_more": false
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_parameter
404 not_found

POST /agent-actions/{id}/decide

Approve or reject an agent action.

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.

Scope: project:write

Parameter In Type
id (required) path string (uuid) The id of the agent action.

Request body

The owner's decision on an action that waits for approval.

Field Type
decision (required) "approve", "reject" approve runs the action now; reject drops it.
JSON
{
  "decision": "approve"
}

200 The action after the decision.

Field Type
id (required) string (uuid)
run_id (required) string (uuid)
agent_id (required) string (uuid)
type (required) "read_document", "update_document", "email_owner", "email_contact", "send_sms", "start_call", "send_webhook"
input (required) object What the agent asked for, like {"to": "+447700900123", "assistant_instructions": "..."}.
status (required) "running", "done", "simulated", "pending_approval", "rejected", "refused", "failed", "expired" 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"
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
403 forbidden
404 not_found
409 conflict
422 validation_failed

Apps

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.

GET /apps

List the apps.

The project's apps, newest first.

Scope: sites:read

200 The apps.

Field Type
data (required) object[]
JSON
{
  "data": [
    {
      "id": "9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6",
      "name": "Contact form",
      "site_id": "5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d",
      "url": "https://contact-demo.hypamoon.app",
      "api_url": "https://contact-demo.hypamoon.app/api",
      "mode": "test",
      "status": "active",
      "version": 2,
      "allowed_hosts": [
        "api.weatherapi.com"
      ],
      "secrets": [
        "weather_key"
      ],
      "schedules": [
        {
          "cron": "0 7 * * *"
        }
      ],
      "limits": {
        "cpu_ms": 10,
        "subrequests": 10,
        "outbound_per_minute": 60,
        "outbound_per_day": 2000,
        "bundle_bytes": 1048576
      },
      "created_at": "2026-10-06T09:00:00.000Z",
      "updated_at": "2026-10-06T09:30:00.000Z"
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

POST /apps

Deploy an app.

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).

Scope: sites:write

Request body

Field Type
site_id (required) string (uuid) 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"
  }
}

200 A new version is live.

Field Type
id (required) string (uuid)
name (required) string
site_id (required) string (uuid)
url (required) string The site; the app answers on url + /api.
api_url (required) string Where the app's /api starts.
mode (required) "live", "test"
status (required) "active", "offline" 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
JSON
{
  "id": "9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6",
  "name": "Contact form",
  "site_id": "5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d",
  "url": "https://contact-demo.hypamoon.app",
  "api_url": "https://contact-demo.hypamoon.app/api",
  "mode": "test",
  "status": "active",
  "version": 2,
  "allowed_hosts": [
    "api.weatherapi.com"
  ],
  "secrets": [
    "weather_key"
  ],
  "schedules": [
    {
      "cron": "0 7 * * *"
    }
  ],
  "limits": {
    "cpu_ms": 10,
    "subrequests": 10,
    "outbound_per_minute": 60,
    "outbound_per_day": 2000,
    "bundle_bytes": 1048576
  },
  "created_at": "2026-10-06T09:00:00.000Z",
  "updated_at": "2026-10-06T09:30:00.000Z"
}

201 The app is live.

Field Type
id (required) string (uuid)
name (required) string
site_id (required) string (uuid)
url (required) string The site; the app answers on url + /api.
api_url (required) string Where the app's /api starts.
mode (required) "live", "test"
status (required) "active", "offline" 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
JSON
{
  "id": "9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6",
  "name": "Contact form",
  "site_id": "5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d",
  "url": "https://contact-demo.hypamoon.app",
  "api_url": "https://contact-demo.hypamoon.app/api",
  "mode": "test",
  "status": "active",
  "version": 1,
  "allowed_hosts": [
    "api.weatherapi.com"
  ],
  "secrets": [
    "weather_key"
  ],
  "schedules": [
    {
      "cron": "0 7 * * *"
    }
  ],
  "limits": {
    "cpu_ms": 10,
    "subrequests": 10,
    "outbound_per_minute": 60,
    "outbound_per_day": 2000,
    "bundle_bytes": 1048576
  },
  "created_at": "2026-10-06T09:00:00.000Z",
  "updated_at": "2026-10-06T09:30:00.000Z"
}

202 Four-eyes approval is on: the deploy waits for another owner or builder. The running code has not changed 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.
status (required) "pending", "processing", "approved", "rejected", "failed", "superseded", "expired" pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days.
kind (required) "new_site", "site_update", "site_delete", "app_deploy", "app_rollback" app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules.
site_id (required) string or null (uuid)
name (required) string or null
slug (required) string or null
url (required) string or null The address of the site. For an update or deletion it still shows the live version until approval.
files (required) integer
bytes (required) integer
password_change (required) "none", "remove", "set"
requested_by (required) object
requested_at (required) string
expires_at (required) string
reviewed_by (required) string or null (uuid)
reviewed_at (required) string or null
reason (required) string or null
result (required) object or null
JSON
{
  "object": "deploy_approval",
  "id": "8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90",
  "status": "pending",
  "kind": "app_deploy",
  "site_id": "3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c",
  "name": "Acme Plumbing",
  "slug": "acme-plumbing",
  "url": "https://acme-plumbing.hypamoon.app",
  "files": 1,
  "bytes": 1834,
  "password_change": "none",
  "requested_by": {
    "user_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "via": "api",
    "app": "Claude"
  },
  "requested_at": "2026-10-05T10:15:00Z",
  "expires_at": "2026-10-12T10:15:00Z",
  "reviewed_by": null,
  "reviewed_at": null,
  "reason": null,
  "result": null
}

Errors

Besides the errors on every endpoint:

GET /apps/{id}

Get an app.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the app.

200 The app.

Field Type
id (required) string (uuid)
name (required) string
site_id (required) string (uuid)
url (required) string The site; the app answers on url + /api.
api_url (required) string Where the app's /api starts.
mode (required) "live", "test"
status (required) "active", "offline" 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.
JSON
{
  "id": "9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6",
  "name": "Contact form",
  "site_id": "5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d",
  "url": "https://contact-demo.hypamoon.app",
  "api_url": "https://contact-demo.hypamoon.app/api",
  "mode": "test",
  "status": "active",
  "version": 2,
  "allowed_hosts": [
    "api.weatherapi.com"
  ],
  "secrets": [
    "weather_key"
  ],
  "schedules": [
    {
      "cron": "0 7 * * *"
    }
  ],
  "limits": {
    "cpu_ms": 10,
    "subrequests": 10,
    "outbound_per_minute": 60,
    "outbound_per_day": 2000,
    "bundle_bytes": 1048576
  },
  "created_at": "2026-10-06T09:00:00.000Z",
  "updated_at": "2026-10-06T09:30:00.000Z",
  "main_module": "index.js",
  "vars": {
    "CITY": "Delft"
  },
  "fetch_pages": false,
  "versions": [
    {
      "version": 2,
      "bytes": 1834,
      "created_at": "2026-10-06T09:30:00.000Z"
    },
    {
      "version": 1,
      "bytes": 1620,
      "created_at": "2026-10-06T09:00:00.000Z"
    }
  ],
  "recent_errors": [
    {
      "at": "2026-10-06T09:41:12.000Z",
      "status": 500,
      "path": "/api/contact",
      "message": "TypeError: Cannot read properties of undefined (reading 'temp_c')"
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

DELETE /apps/{id}

Delete an app.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the app.

204 Deleted.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

POST /apps/{id}/rollback

Roll an app back.

Makes a stored version live again, with the settings it had (allowed hosts, secrets, schedules, vars). Without version: the version before the current one.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the app.

Request body

Field Type
version integer The version to go back to. Leave it out for the version before the current one.
JSON
{
  "version": 1
}

200 The app as it runs now.

Field Type
id (required) string (uuid)
name (required) string
site_id (required) string (uuid)
url (required) string The site; the app answers on url + /api.
api_url (required) string Where the app's /api starts.
mode (required) "live", "test"
status (required) "active", "offline" 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
JSON
{
  "id": "9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6",
  "name": "Contact form",
  "site_id": "5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d",
  "url": "https://contact-demo.hypamoon.app",
  "api_url": "https://contact-demo.hypamoon.app/api",
  "mode": "test",
  "status": "active",
  "version": 1,
  "allowed_hosts": [
    "api.weatherapi.com"
  ],
  "secrets": [
    "weather_key"
  ],
  "schedules": [
    {
      "cron": "0 7 * * *"
    }
  ],
  "limits": {
    "cpu_ms": 10,
    "subrequests": 10,
    "outbound_per_minute": 60,
    "outbound_per_day": 2000,
    "bundle_bytes": 1048576
  },
  "created_at": "2026-10-06T09:00:00.000Z",
  "updated_at": "2026-10-06T09:30:00.000Z"
}

202 Four-eyes approval is on: the rollback waits for another owner or builder. Nothing has changed 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.
status (required) "pending", "processing", "approved", "rejected", "failed", "superseded", "expired" pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days.
kind (required) "new_site", "site_update", "site_delete", "app_deploy", "app_rollback" app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules.
site_id (required) string or null (uuid)
name (required) string or null
slug (required) string or null
url (required) string or null The address of the site. For an update or deletion it still shows the live version until approval.
files (required) integer
bytes (required) integer
password_change (required) "none", "remove", "set"
requested_by (required) object
requested_at (required) string
expires_at (required) string
reviewed_by (required) string or null (uuid)
reviewed_at (required) string or null
reason (required) string or null
result (required) object or null
JSON
{
  "object": "deploy_approval",
  "id": "8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90",
  "status": "pending",
  "kind": "app_rollback",
  "site_id": "3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c",
  "name": "Acme Plumbing",
  "slug": "acme-plumbing",
  "url": "https://acme-plumbing.hypamoon.app",
  "files": 1,
  "bytes": 1620,
  "password_change": "none",
  "requested_by": {
    "user_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "via": "api",
    "app": "Claude"
  },
  "requested_at": "2026-10-05T10:15:00Z",
  "expires_at": "2026-10-12T10:15:00Z",
  "reviewed_by": null,
  "reviewed_at": null,
  "reason": null,
  "result": null
}

Errors

Besides the errors on every endpoint:

GET /apps/{id}/logs

Read the log of an app.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the app.
limit query integer Lines, 1 to 100.
errors_only query boolean Only errors.

200 The log.

Field Type
data (required) object[]
JSON
{
  "data": [
    {
      "at": "2026-10-06T09:41:12.000Z",
      "kind": "request",
      "method": "POST",
      "path": "/api/contact",
      "status": 200,
      "duration_ms": 182,
      "message": ""
    },
    {
      "at": "2026-10-06T09:41:12.000Z",
      "kind": "outbound",
      "method": "GET",
      "path": "api.weatherapi.com",
      "status": 200,
      "duration_ms": 95,
      "message": ""
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_parameter
404 not_found

GET /apps/{id}/users

List the users of an app.

The people who may sign in to the app, with their role and when they last signed in. Hypamoon keeps only their email address and name.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the app.

200 The users.

Field Type
data (required) object[]
JSON
{
  "data": [
    {
      "id": "7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5",
      "email": "sam@example.com",
      "name": "Sam",
      "role": "member",
      "created_at": "2026-10-06T10:00:00.000Z",
      "last_sign_in_at": "2026-10-06T10:05:00.000Z"
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
409 conflict

POST /apps/{id}/users

Invite someone to an app.

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).

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the app.

Request body

Field Type
email (required) string (email) Their email address.
name string Their name, shown to the app.
role string One of the app's roles. Defaults to default_role.
send_email boolean Send them a sign-in link now (default true).
JSON
{
  "email": "sam@example.com",
  "name": "Sam",
  "role": "member"
}

201 Invited.

Field Type
id (required) string (uuid) The person's id: sub in x-hypamoon-user.
email (required) string
name (required) string or null
role (required) string
created_at (required) string
last_sign_in_at (required) string or null
JSON
{
  "id": "7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5",
  "email": "sam@example.com",
  "name": "Sam",
  "role": "member",
  "created_at": "2026-10-06T10:00:00.000Z",
  "last_sign_in_at": null
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

GET /apps/{id}/mails

List the emails an app sent.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the app.
limit query integer Emails, 1 to 50.

200 The emails.

Field Type
data (required) object[]
JSON
{
  "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"
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_parameter
404 not_found

POST /apps/{id}/users/import

Import users from a collection.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the app.

Request body

Field Type
collection string 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.
JSON
{
  "collection": "wp_users",
  "role_map": {
    "administrator": "admin",
    "opdrachtgever": "opdrachtgever",
    "kandidaat": "kandidaat"
  },
  "send_email": false
}

200 What happened per person.

Field Type
app_id (required) string (uuid)
collection (required) string
dry_run (required) boolean
send_email (required) boolean
summary (required) object 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.
next_step (required) string
JSON
{
  "app_id": "8d0f4a1e-2b3c-4d5e-8f60-718293a4b5c6",
  "collection": "wp_users",
  "dry_run": false,
  "send_email": false,
  "summary": {
    "found": 2,
    "created": 1,
    "invited": 0,
    "existing": 1,
    "skipped": 0,
    "remaining": 0
  },
  "users": [
    {
      "wp_id": 12,
      "user_id": "3f1e2d3c-4b5a-4c6d-9e8f-0a1b2c3d4e5f",
      "email": "s***@example.com",
      "role": "opdrachtgever",
      "status": "created"
    },
    {
      "wp_id": 1,
      "user_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "email": "a***@example.com",
      "role": "admin",
      "status": "existing"
    }
  ],
  "next_step": "1 added silently, without email, 1 could already sign in, 0 skipped."
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

PATCH /apps/{id}/users/{userId}

Change the role of a user.

Sets one of the app's roles. The app sees the new role in x-hypamoon-user within seconds.

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).

Request body

Field Type
role (required) string One of the app's roles.
JSON
{
  "role": "admin"
}

200 The user with the new role.

Field Type
id (required) string (uuid) The person's id: sub in x-hypamoon-user.
email (required) string
name (required) string or null
role (required) string
created_at (required) string
last_sign_in_at (required) string or null
JSON
{
  "id": "7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5",
  "email": "sam@example.com",
  "name": "Sam",
  "role": "admin",
  "created_at": "2026-10-06T10:00:00.000Z",
  "last_sign_in_at": "2026-10-06T10:05:00.000Z"
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
404 not_found
409 conflict
422 validation_failed

DELETE /apps/{id}/users/{userId}

Remove a user from an app.

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.

200 Removed, with delete_data=true: what was deleted.

Field Type
app_id (required) string (uuid)
user_id (required) string (uuid)
deleted (required) object
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",
  "deleted": {
    "rows": {
      "items": 12
    },
    "files": 12,
    "sessions": 1,
    "passkeys": 1,
    "membership": true,
    "account": true
  },
  "app_tables": "included",
  "unmarked_tables": [],
  "note": null
}

204 Removed; their data stays.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
409 conflict
422 validation_failed

POST /apps/{id}/users/{userId}/export

Export everything of one user (GDPR).

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.

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).

200 The export.

Field Type
app_id (required) string (uuid)
user_id (required) string (uuid)
exported_at (required) string
account (required) object or null 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)."
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

DELETE /apps/{id}/users/{userId}/data

Delete everything of one user (GDPR).

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.

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).

200 What was deleted.

Field Type
app_id (required) string (uuid)
user_id (required) string (uuid)
deleted (required) object
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",
  "deleted": {
    "rows": {
      "items": 12
    },
    "files": 12,
    "sessions": 1,
    "passkeys": 1,
    "membership": true,
    "account": true
  },
  "app_tables": "included",
  "unmarked_tables": [],
  "note": null
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

PUT /apps/{id}/tables/{table}/owner

Make a table private per user.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the app.
table (required) path string The name of a table in the app's database.

Request body

Field Type
column string 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.
JSON
{
  "column": "user_id",
  "admin_role": "admin"
}

200 The app, with database.per_user_tables.

Field Type
id (required) string (uuid)
name (required) string
site_id (required) string (uuid)
url (required) string The site; the app answers on url + /api.
api_url (required) string Where the app's /api starts.
mode (required) "live", "test"
status (required) "active", "offline" 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
JSON
{
  "id": "9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6",
  "name": "Wardrobe",
  "site_id": "5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d",
  "url": "https://wardrobe-demo.hypamoon.app",
  "api_url": "https://wardrobe-demo.hypamoon.app/api",
  "mode": "test",
  "status": "active",
  "version": 2,
  "allowed_hosts": [],
  "secrets": [],
  "schedules": [],
  "limits": {
    "cpu_ms": 10,
    "subrequests": 10,
    "outbound_per_minute": 60,
    "outbound_per_day": 2000,
    "bundle_bytes": 1048576
  },
  "created_at": "2026-10-06T09:00:00.000Z",
  "updated_at": "2026-10-06T09:30:00.000Z",
  "auth": {
    "signup": "invite",
    "roles": [
      "admin",
      "member"
    ],
    "default_role": "member",
    "google": false,
    "login_url": "https://wardrobe-demo.hypamoon.app/_hypamoon/auth/login"
  },
  "database": {
    "bytes": 24576,
    "full": false,
    "per_user_tables": [
      {
        "table": "items",
        "column": "user_id",
        "admin_role": "admin"
      }
    ]
  }
}

Errors

Besides the errors on every endpoint:

DELETE /apps/{id}/tables/{table}/owner

Make a per-user table shared again.

Removes the per-user marking: the app can use the table with plain SQL again, and nothing keeps users' rows apart any more. The rows stay.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the app.
table (required) path string The name of a table in the app's database.

200 The app.

Field Type
id (required) string (uuid)
name (required) string
site_id (required) string (uuid)
url (required) string The site; the app answers on url + /api.
api_url (required) string Where the app's /api starts.
mode (required) "live", "test"
status (required) "active", "offline" 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
JSON
{
  "id": "9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6",
  "name": "Wardrobe",
  "site_id": "5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d",
  "url": "https://wardrobe-demo.hypamoon.app",
  "api_url": "https://wardrobe-demo.hypamoon.app/api",
  "mode": "test",
  "status": "active",
  "version": 2,
  "allowed_hosts": [],
  "secrets": [],
  "schedules": [],
  "limits": {
    "cpu_ms": 10,
    "subrequests": 10,
    "outbound_per_minute": 60,
    "outbound_per_day": 2000,
    "bundle_bytes": 1048576
  },
  "created_at": "2026-10-06T09:00:00.000Z",
  "updated_at": "2026-10-06T09:30:00.000Z",
  "auth": {
    "signup": "invite",
    "roles": [
      "admin",
      "member"
    ],
    "default_role": "member",
    "google": false,
    "login_url": "https://wardrobe-demo.hypamoon.app/_hypamoon/auth/login"
  },
  "database": {
    "bytes": 24576,
    "full": false,
    "per_user_tables": []
  }
}

Errors

Besides the errors on every endpoint:

Status Type
403 site_offline
404 not_found
409 conflict
422 validation_failed

GET /apps/{id}/files

List the files of an app.

Files the app's users uploaded through the app, newest first, with the total size. The app itself uploads and downloads through /app-files.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the app.
limit query integer Files, 1 to 500.

200 The files.

Field Type
data (required) object[]
bytes (required) integer All files of this app together.
JSON
{
  "data": [
    {
      "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
      "name": "receipt.pdf",
      "content_type": "application/pdf",
      "bytes": 48213,
      "status": "ready",
      "user_id": "7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5",
      "created_at": "2026-10-06T10:10:00.000Z"
    }
  ],
  "bytes": 48213
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

DELETE /apps/{id}/files/{fileId}

Delete a file from an app.

Deletes the file permanently, including from storage.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the app.
fileId (required) path string (uuid) The id of the file.

204 Deleted.

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

GET /apps/{id}/migrations

List the migrations of an app.

The migrations applied to the app's database, oldest first, and its size.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the app.

200 The migrations.

Field Type
applied (required) string[] Applied now.
skipped (required) string[] Already applied before.
data (required) object[] Every migration of this app, oldest first.
database_bytes (required) integer
JSON
{
  "applied": [],
  "skipped": [],
  "data": [
    {
      "name": "0001_init.sql",
      "sha256": "9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f",
      "statements": 2,
      "applied_at": "2026-10-06T10:00:00.000Z"
    }
  ],
  "database_bytes": 24576
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

POST /apps/{id}/migrations

Apply migrations to the database of an app.

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.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the app.

Request body

Field Type
migrations (required) object[] 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);"
    }
  ]
}

200 Applied (or already applied).

Field Type
applied (required) string[] Applied now.
skipped (required) string[] Already applied before.
data (required) object[] Every migration of this app, oldest first.
database_bytes (required) integer
JSON
{
  "applied": [
    "0001_init.sql"
  ],
  "skipped": [],
  "data": [
    {
      "name": "0001_init.sql",
      "sha256": "9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f",
      "statements": 2,
      "applied_at": "2026-10-06T10:00:00.000Z"
    }
  ],
  "database_bytes": 24576
}

Errors

Besides the errors on every endpoint:

GET /apps/{id}/schema

Read the schema of an app's database.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the app.

200 The schema.

Field Type
app_id (required) string
database (required) true
tables (required) object[]
views (required) object[]
migrations (required) object[] Applied migrations, oldest first.
next_migration_name (required) string A name that sorts after the last applied migration, like 0003_next.sql; replace next with what it does.
database_bytes (required) integer
JSON
{
  "app_id": "4a7ff9fa-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
  "database": true,
  "tables": [
    {
      "name": "recipes",
      "columns": [
        {
          "name": "id",
          "type": "TEXT",
          "not_null": false,
          "primary_key": true,
          "default": null
        },
        {
          "name": "user_id",
          "type": "TEXT",
          "not_null": true,
          "primary_key": false,
          "default": null
        },
        {
          "name": "title",
          "type": "TEXT",
          "not_null": true,
          "primary_key": false,
          "default": null
        },
        {
          "name": "ingredients",
          "type": "TEXT",
          "not_null": false,
          "primary_key": false,
          "default": null
        }
      ],
      "indexes": [
        {
          "name": "recipes_user",
          "unique": false,
          "sql": "CREATE INDEX recipes_user ON recipes (user_id, created_at)"
        }
      ],
      "rows": 12,
      "per_user": {
        "column": "user_id",
        "admin_role": "admin"
      },
      "sql": "CREATE TABLE recipes (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, ingredients TEXT)"
    }
  ],
  "views": [],
  "migrations": [
    {
      "name": "0001_init.sql",
      "applied_at": "2026-10-14T10:00:00.000Z",
      "statements": 4
    }
  ],
  "next_migration_name": "0002_next.sql",
  "database_bytes": 24576
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
409 conflict

GET /apps/{id}/code

Read the server code of an app.

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.
module query string Only this module, like index.js.

200 The code.

Field Type
object (required) "app_code"
app_id (required) string (uuid)
version (required) integer The version these modules belong to.
running_version (required) integer The version that runs now.
main_module (required) string The module with export default { fetch }.
modules (required) object[] The main module first, then the others by name.
JSON
{
  "object": "app_code",
  "app_id": "9f2b6c1e-3d4a-4b5c-8d7e-1f2a3b4c5d6e",
  "version": 3,
  "running_version": 3,
  "main_module": "index.js",
  "modules": [
    {
      "name": "index.js",
      "type": "esm",
      "bytes": 74,
      "content": "export default { async fetch(request, env) { return new Response('hi'); } };",
      "truncated": false
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
422 validation_failed

POST /apps/{id}/query

Run one read-only query on an app's database.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the app.

Request body

Field Type
sql (required) string One SELECT (or WITH ... SELECT). Nothing that writes or changes the schema.
limit integer Rows to return, 1 to 50 (default 20).
JSON
{
  "sql": "SELECT id, title, servings FROM recipes ORDER BY created_at DESC",
  "limit": 20
}

200 The rows.

Field Type
columns (required) string[]
rows (required) object[]
row_count (required) integer
limit (required) integer
truncated (required) boolean true when there were more rows than limit.
per_user_tables (required) string[] Per-user tables this query read.
masked (required) string
JSON
{
  "columns": [
    "id",
    "title",
    "servings"
  ],
  "rows": [
    {
      "id": "7f1c",
      "title": "Pasta pesto",
      "servings": 4
    }
  ],
  "row_count": 1,
  "limit": 20,
  "truncated": false,
  "per_user_tables": [
    "recipes"
  ],
  "masked": "Email addresses and phone numbers are masked, text longer than 300 characters is cut, and binary values show only their size."
}

Errors

Besides the errors on every endpoint:

GET /app-files

List the files (from an app).

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.

200 The files.

Field Type
data (required) object[]
JSON
{
  "data": [
    {
      "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
      "name": "receipt.pdf",
      "content_type": "application/pdf",
      "bytes": 48213,
      "status": "ready",
      "user_id": "7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5",
      "created_at": "2026-10-06T10:10:00.000Z"
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden
422 validation_failed

POST /app-files

Get an upload link (from an app).

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.

Scope: data:write

Request body

Field Type
name (required) string The file name, like receipt.pdf.
content_type (required) "image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "video/mp4", "video/webm", "audio/mpeg", "audio/mp4", "audio/webm", "image/heic", "image/heif", "video/quicktime", "text/plain", "text/csv", "application/json" 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.
JSON
{
  "name": "receipt.pdf",
  "content_type": "application/pdf",
  "size": 48213
}
Field Type
id (required) string (uuid)
upload_url (required) string PUT the bytes here from the browser, on the site's own address. Works once.
method (required) "PUT"
expires_at (required) string
JSON
{
  "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
  "upload_url": "/_hypamoon/files/upload?t=eyJvIjoi...",
  "method": "PUT",
  "expires_at": "2026-10-06T10:25:00.000Z"
}

Errors

Besides the errors on every endpoint:

GET /app-files/{id}

Get a download link (from an app).

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.
Field Type
id (required) string (uuid)
name (required) string
content_type (required) string
bytes (required) integer
download_url (required) string Valid for 15 minutes, on the site's own address.
expires_at (required) string
JSON
{
  "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
  "name": "receipt.pdf",
  "content_type": "application/pdf",
  "bytes": 48213,
  "download_url": "/_hypamoon/files/5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9?t=eyJvIjoi...",
  "expires_at": "2026-10-06T10:25:00.000Z"
}

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden
404 not_found
409 conflict

DELETE /app-files/{id}

Delete a file (from an app).

For an app's own server code only: deletes the file permanently.

Scope: data:write

Parameter In Type
id (required) path string (uuid) The id of the file.

204 Deleted.

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden
404 not_found

GET /app-users

List the users (from an app, as admin).

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.

Scope: data:read

200 The users.

Field Type
data (required) object[]
JSON
{
  "data": [
    {
      "id": "7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5",
      "email": "sam@example.com",
      "name": "Sam",
      "role": "member",
      "created_at": "2026-10-06T10:00:00.000Z",
      "last_sign_in_at": "2026-10-06T10:05:00.000Z"
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden
409 conflict

POST /app-users

Invite someone (from an app, as admin).

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.

Scope: data:write

Request body

Field Type
email (required) string (email) Their email address. Ask the admin for it; never guess one.
name string Their name, shown in the app and in the list.
role string One of the app's roles. Defaults to default_role.
JSON
{
  "email": "sam@example.com",
  "name": "Sam",
  "role": "member"
}

201 Invited, and the email is on its way.

Field Type
id (required) string (uuid) The person's id: sub in x-hypamoon-user.
email (required) string
name (required) string or null
role (required) string
created_at (required) string
last_sign_in_at (required) string or null
JSON
{
  "id": "7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5",
  "email": "sam@example.com",
  "name": "Sam",
  "role": "member",
  "created_at": "2026-10-06T10:00:00.000Z",
  "last_sign_in_at": null
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
403 forbidden
409 conflict
422 validation_failed

GET /app-users/{id}

Get one user (from an app, as admin).

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).

200 The user.

Field Type
id (required) string (uuid) The person's id: sub in x-hypamoon-user.
email (required) string
name (required) string or null
role (required) string
created_at (required) string
last_sign_in_at (required) string or null
JSON
{
  "id": "7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5",
  "email": "sam@example.com",
  "name": "Sam",
  "role": "member",
  "created_at": "2026-10-06T10:00:00.000Z",
  "last_sign_in_at": "2026-10-06T10:05:00.000Z"
}

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden
404 not_found
409 conflict

PATCH /app-users/{id}

Change a user's role (from an app, as admin).

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).

Request body

Field Type
role (required) string One of the app's roles.
JSON
{
  "role": "admin"
}

200 The user with the new role.

Field Type
id (required) string (uuid) The person's id: sub in x-hypamoon-user.
email (required) string
name (required) string or null
role (required) string
created_at (required) string
last_sign_in_at (required) string or null
JSON
{
  "id": "7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5",
  "email": "sam@example.com",
  "name": "Sam",
  "role": "admin",
  "created_at": "2026-10-06T10:00:00.000Z",
  "last_sign_in_at": "2026-10-06T10:05:00.000Z"
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
403 forbidden
404 not_found
409 conflict
422 validation_failed

DELETE /app-users/{id}

Remove a user (from an app, as admin).

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).

204 Removed; their data stays.

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden
404 not_found
409 conflict

Email

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.

GET /emails

List sent emails.

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).
to query string Only emails with this address in to, cc or bcc.
status query "queued", "sent", "delivered", "bounced", "complained", "failed", "simulated" Only emails with this status.
since query string (date-time) Only emails sent at or after this moment, like 2026-10-01 or 2026-10-15T09:00:00Z.

200 The emails and the state of the module.

Field Type
data (required) object[]
next_cursor (required) string or null
mail (required) object
JSON
{
  "data": [
    {
      "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
      "app_id": null,
      "from": "Acme Plumbing <hello@acmeplumbing.com>",
      "to": "sam@example.org",
      "recipients": {
        "to": [
          "sam@example.org"
        ],
        "cc": [],
        "bcc": []
      },
      "recipient_count": 1,
      "attachments": [
        {
          "filename": "quote.pdf",
          "content_type": "application/pdf",
          "size": 48213
        }
      ],
      "reply_to": "office@acmeplumbing.com",
      "subject": "We received your request",
      "category": "transactional",
      "status": "sent",
      "recipient_source": "form",
      "test": false,
      "error": null,
      "reason": null,
      "recipient_status": [
        {
          "email": "sam@example.org",
          "field": "to",
          "status": "sent",
          "reason": null,
          "updated_at": "2026-10-06T10:15:01.000Z"
        }
      ],
      "timeline": [
        {
          "status": "queued",
          "at": "2026-10-06T10:15:00.000Z",
          "recipient": null,
          "reason": null
        },
        {
          "status": "sent",
          "at": "2026-10-06T10:15:01.000Z",
          "recipient": null,
          "reason": null
        }
      ],
      "created_at": "2026-10-06T10:15:00.000Z",
      "sent_at": "2026-10-06T10:15:01.000Z",
      "delivered_at": null,
      "bounced_at": null,
      "complained_at": null
    }
  ],
  "next_cursor": null,
  "mail": {
    "enabled": true,
    "paused": false,
    "pause_reason": null,
    "daily_limit": 100,
    "sent_last_24h": 12
  }
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_parameter

POST /emails

Send one email.

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.

Request body

Field Type
from (required) string 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..."
    }
  ]
}

200 The same Idempotency-Key as before: the original email, and nothing is sent again.

Field Type
id (required) string (uuid) The id of the email. sendEmail and sendEmailBatch in an app return it; getEmail(id) reads it back.
app_id (required) string or null (uuid) The app that sent it with env.HYPAMOON.sendEmail; null when it was sent with the API, by an agent or from the portal.
from (required) string
to (required) string The first address in to. All recipients are in recipients.
recipients (required) object Everyone this email went to. After 90 days the addresses are masked, like s***@example.org.
recipient_count (required) integer to, cc and bcc together; each counts toward the limits and the bundle.
attachments (required) object[] Name, type and size of each attachment. The content is never stored.
reply_to (required) string or null
subject (required) string
category (required) "transactional", "other"
status (required) "queued", "sent", "delivered", "bounced", "complained", "failed", "simulated" queued, sent (accepted for delivery), delivered, bounced, complained (marked as spam), failed, or simulated (test mode).
recipient_source (required) "confirmed", "member", "form", "document", "conversation", "app_user", "buyer", "pass_holder", null Where the recipient appears in the project.
test (required) boolean
error (required) string or null
reason (required) string or null Why the email bounced, was marked as spam, is delayed or was not sent, in plain words. null otherwise.
recipient_status (required) object[] The status per recipient. With several recipients one can bounce while the others are delivered.
timeline (required) object[] Every step, oldest first: queued, then sent (or simulated, or failed), then what the recipient's mail server reported.
created_at (required) string
sent_at (required) string or null
delivered_at (required) string or null
bounced_at (required) string or null
complained_at (required) string or null
JSON
{
  "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "app_id": null,
  "from": "Acme Plumbing <hello@acmeplumbing.com>",
  "to": "sam@example.org",
  "recipients": {
    "to": [
      "sam@example.org"
    ],
    "cc": [],
    "bcc": []
  },
  "recipient_count": 1,
  "attachments": [
    {
      "filename": "quote.pdf",
      "content_type": "application/pdf",
      "size": 48213
    }
  ],
  "reply_to": "office@acmeplumbing.com",
  "subject": "We received your request",
  "category": "transactional",
  "status": "sent",
  "recipient_source": "form",
  "test": false,
  "error": null,
  "reason": null,
  "recipient_status": [
    {
      "email": "sam@example.org",
      "field": "to",
      "status": "sent",
      "reason": null,
      "updated_at": "2026-10-06T10:15:01.000Z"
    }
  ],
  "timeline": [
    {
      "status": "queued",
      "at": "2026-10-06T10:15:00.000Z",
      "recipient": null,
      "reason": null
    },
    {
      "status": "sent",
      "at": "2026-10-06T10:15:01.000Z",
      "recipient": null,
      "reason": null
    }
  ],
  "created_at": "2026-10-06T10:15:00.000Z",
  "sent_at": "2026-10-06T10:15:01.000Z",
  "delivered_at": null,
  "bounced_at": null,
  "complained_at": null
}

201 Accepted for delivery (or simulated with a test key).

Field Type
id (required) string (uuid) The id of the email. sendEmail and sendEmailBatch in an app return it; getEmail(id) reads it back.
app_id (required) string or null (uuid) The app that sent it with env.HYPAMOON.sendEmail; null when it was sent with the API, by an agent or from the portal.
from (required) string
to (required) string The first address in to. All recipients are in recipients.
recipients (required) object Everyone this email went to. After 90 days the addresses are masked, like s***@example.org.
recipient_count (required) integer to, cc and bcc together; each counts toward the limits and the bundle.
attachments (required) object[] Name, type and size of each attachment. The content is never stored.
reply_to (required) string or null
subject (required) string
category (required) "transactional", "other"
status (required) "queued", "sent", "delivered", "bounced", "complained", "failed", "simulated" queued, sent (accepted for delivery), delivered, bounced, complained (marked as spam), failed, or simulated (test mode).
recipient_source (required) "confirmed", "member", "form", "document", "conversation", "app_user", "buyer", "pass_holder", null Where the recipient appears in the project.
test (required) boolean
error (required) string or null
reason (required) string or null Why the email bounced, was marked as spam, is delayed or was not sent, in plain words. null otherwise.
recipient_status (required) object[] The status per recipient. With several recipients one can bounce while the others are delivered.
timeline (required) object[] Every step, oldest first: queued, then sent (or simulated, or failed), then what the recipient's mail server reported.
created_at (required) string
sent_at (required) string or null
delivered_at (required) string or null
bounced_at (required) string or null
complained_at (required) string or null
JSON
{
  "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "app_id": null,
  "from": "Acme Plumbing <hello@acmeplumbing.com>",
  "to": "sam@example.org",
  "recipients": {
    "to": [
      "sam@example.org"
    ],
    "cc": [],
    "bcc": []
  },
  "recipient_count": 1,
  "attachments": [
    {
      "filename": "quote.pdf",
      "content_type": "application/pdf",
      "size": 48213
    }
  ],
  "reply_to": "office@acmeplumbing.com",
  "subject": "We received your request",
  "category": "transactional",
  "status": "sent",
  "recipient_source": "form",
  "test": false,
  "error": null,
  "reason": null,
  "recipient_status": [
    {
      "email": "sam@example.org",
      "field": "to",
      "status": "sent",
      "reason": null,
      "updated_at": "2026-10-06T10:15:01.000Z"
    }
  ],
  "timeline": [
    {
      "status": "queued",
      "at": "2026-10-06T10:15:00.000Z",
      "recipient": null,
      "reason": null
    },
    {
      "status": "sent",
      "at": "2026-10-06T10:15:01.000Z",
      "recipient": null,
      "reason": null
    }
  ],
  "created_at": "2026-10-06T10:15:00.000Z",
  "sent_at": "2026-10-06T10:15:01.000Z",
  "delivered_at": null,
  "bounced_at": null,
  "complained_at": null
}

202 With send_at: the scheduled email. get_scheduled_email shows it; once sent, email_id is the email.

Field Type
id (required) string (uuid) The id of the scheduled email. cancel_scheduled_email takes it.
source (required) "api", "automation", "sequence", "list", "confirmation" api: send_email with send_at. automation, sequence, list: sent by those. confirmation: the confirmation email of a list.
status (required) "scheduled", "sending", "sent", "canceled", "skipped", "failed" 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.
reason (required) string or null
test (required) boolean
created_at (required) string (date-time)
JSON
{
  "id": "6c7d8e9f-0a1b-4c2d-9e3f-4a5b6c7d8e9f",
  "source": "api",
  "status": "scheduled",
  "send_at": "2026-10-16T08:00:00.000Z",
  "to": "sam@example.org",
  "subject": "Your appointment tomorrow",
  "template_id": null,
  "email_id": null,
  "reason": null,
  "test": false,
  "created_at": "2026-10-15T10:00:00.000Z"
}

Errors

Besides the errors on every endpoint:

GET /app-emails

List the emails of an app (from the app).

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}.

Scope: data:read

Parameter In Type
limit query integer Emails per page, 1 to 100.
cursor query string The next_cursor of the previous page.
to query string Only emails with this address in to, cc or bcc.
status query "queued", "sent", "delivered", "bounced", "complained", "failed", "simulated" Only emails with this status.
since query string (date-time) Only emails sent at or after this moment, like 2026-10-01 or 2026-10-15T09:00:00Z.

200 The app's emails.

Field Type
data (required) object[]
next_cursor (required) string or null
JSON
{
  "data": [
    {
      "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
      "app_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
      "from": "noreply@acmeplumbing.com",
      "to": "sam@example.org",
      "recipients": {
        "to": [
          "sam@example.org"
        ],
        "cc": [],
        "bcc": []
      },
      "recipient_count": 1,
      "attachments": [],
      "reply_to": null,
      "subject": "Your hours for week 41 are approved",
      "category": "transactional",
      "status": "delivered",
      "recipient_source": "form",
      "test": false,
      "error": null,
      "reason": null,
      "recipient_status": [
        {
          "email": "sam@example.org",
          "field": "to",
          "status": "delivered",
          "reason": null,
          "updated_at": "2026-10-06T10:15:04.000Z"
        }
      ],
      "timeline": [
        {
          "status": "queued",
          "at": "2026-10-06T10:15:00.000Z",
          "recipient": null,
          "reason": null
        },
        {
          "status": "sent",
          "at": "2026-10-06T10:15:01.000Z",
          "recipient": null,
          "reason": null
        },
        {
          "status": "delivered",
          "at": "2026-10-06T10:15:04.000Z",
          "recipient": null,
          "reason": null
        }
      ],
      "created_at": "2026-10-06T10:15:00.000Z",
      "sent_at": "2026-10-06T10:15:01.000Z",
      "delivered_at": "2026-10-06T10:15:04.000Z",
      "bounced_at": null,
      "complained_at": null
    }
  ],
  "next_cursor": null
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_parameter
403 forbidden

POST /app-emails

Send one email (from an app).

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.

Request body

Field Type
from string 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"
}

200 The same Idempotency-Key as before: the original email, and nothing is sent again.

Field Type
id (required) string (uuid) The id of the email. sendEmail and sendEmailBatch in an app return it; getEmail(id) reads it back.
app_id (required) string or null (uuid) The app that sent it with env.HYPAMOON.sendEmail; null when it was sent with the API, by an agent or from the portal.
from (required) string
to (required) string The first address in to. All recipients are in recipients.
recipients (required) object Everyone this email went to. After 90 days the addresses are masked, like s***@example.org.
recipient_count (required) integer to, cc and bcc together; each counts toward the limits and the bundle.
attachments (required) object[] Name, type and size of each attachment. The content is never stored.
reply_to (required) string or null
subject (required) string
category (required) "transactional", "other"
status (required) "queued", "sent", "delivered", "bounced", "complained", "failed", "simulated" queued, sent (accepted for delivery), delivered, bounced, complained (marked as spam), failed, or simulated (test mode).
recipient_source (required) "confirmed", "member", "form", "document", "conversation", "app_user", "buyer", "pass_holder", null Where the recipient appears in the project.
test (required) boolean
error (required) string or null
reason (required) string or null Why the email bounced, was marked as spam, is delayed or was not sent, in plain words. null otherwise.
recipient_status (required) object[] The status per recipient. With several recipients one can bounce while the others are delivered.
timeline (required) object[] Every step, oldest first: queued, then sent (or simulated, or failed), then what the recipient's mail server reported.
created_at (required) string
sent_at (required) string or null
delivered_at (required) string or null
bounced_at (required) string or null
complained_at (required) string or null
JSON
{
  "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "app_id": null,
  "from": "Acme Plumbing <hello@acmeplumbing.com>",
  "to": "sam@example.org",
  "recipients": {
    "to": [
      "sam@example.org"
    ],
    "cc": [],
    "bcc": []
  },
  "recipient_count": 1,
  "attachments": [
    {
      "filename": "quote.pdf",
      "content_type": "application/pdf",
      "size": 48213
    }
  ],
  "reply_to": "office@acmeplumbing.com",
  "subject": "We received your request",
  "category": "transactional",
  "status": "sent",
  "recipient_source": "form",
  "test": false,
  "error": null,
  "reason": null,
  "recipient_status": [
    {
      "email": "sam@example.org",
      "field": "to",
      "status": "sent",
      "reason": null,
      "updated_at": "2026-10-06T10:15:01.000Z"
    }
  ],
  "timeline": [
    {
      "status": "queued",
      "at": "2026-10-06T10:15:00.000Z",
      "recipient": null,
      "reason": null
    },
    {
      "status": "sent",
      "at": "2026-10-06T10:15:01.000Z",
      "recipient": null,
      "reason": null
    }
  ],
  "created_at": "2026-10-06T10:15:00.000Z",
  "sent_at": "2026-10-06T10:15:01.000Z",
  "delivered_at": null,
  "bounced_at": null,
  "complained_at": null
}

201 Accepted for delivery (or simulated with a test key).

Field Type
id (required) string (uuid) The id of the email. sendEmail and sendEmailBatch in an app return it; getEmail(id) reads it back.
app_id (required) string or null (uuid) The app that sent it with env.HYPAMOON.sendEmail; null when it was sent with the API, by an agent or from the portal.
from (required) string
to (required) string The first address in to. All recipients are in recipients.
recipients (required) object Everyone this email went to. After 90 days the addresses are masked, like s***@example.org.
recipient_count (required) integer to, cc and bcc together; each counts toward the limits and the bundle.
attachments (required) object[] Name, type and size of each attachment. The content is never stored.
reply_to (required) string or null
subject (required) string
category (required) "transactional", "other"
status (required) "queued", "sent", "delivered", "bounced", "complained", "failed", "simulated" queued, sent (accepted for delivery), delivered, bounced, complained (marked as spam), failed, or simulated (test mode).
recipient_source (required) "confirmed", "member", "form", "document", "conversation", "app_user", "buyer", "pass_holder", null Where the recipient appears in the project.
test (required) boolean
error (required) string or null
reason (required) string or null Why the email bounced, was marked as spam, is delayed or was not sent, in plain words. null otherwise.
recipient_status (required) object[] The status per recipient. With several recipients one can bounce while the others are delivered.
timeline (required) object[] Every step, oldest first: queued, then sent (or simulated, or failed), then what the recipient's mail server reported.
created_at (required) string
sent_at (required) string or null
delivered_at (required) string or null
bounced_at (required) string or null
complained_at (required) string or null
JSON
{
  "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "app_id": null,
  "from": "Acme Plumbing <hello@acmeplumbing.com>",
  "to": "sam@example.org",
  "recipients": {
    "to": [
      "sam@example.org"
    ],
    "cc": [],
    "bcc": []
  },
  "recipient_count": 1,
  "attachments": [
    {
      "filename": "quote.pdf",
      "content_type": "application/pdf",
      "size": 48213
    }
  ],
  "reply_to": "office@acmeplumbing.com",
  "subject": "We received your request",
  "category": "transactional",
  "status": "sent",
  "recipient_source": "form",
  "test": false,
  "error": null,
  "reason": null,
  "recipient_status": [
    {
      "email": "sam@example.org",
      "field": "to",
      "status": "sent",
      "reason": null,
      "updated_at": "2026-10-06T10:15:01.000Z"
    }
  ],
  "timeline": [
    {
      "status": "queued",
      "at": "2026-10-06T10:15:00.000Z",
      "recipient": null,
      "reason": null
    },
    {
      "status": "sent",
      "at": "2026-10-06T10:15:01.000Z",
      "recipient": null,
      "reason": null
    }
  ],
  "created_at": "2026-10-06T10:15:00.000Z",
  "sent_at": "2026-10-06T10:15:01.000Z",
  "delivered_at": null,
  "bounced_at": null,
  "complained_at": null
}

Errors

Besides the errors on every endpoint:

POST /emails/batch

Send a batch of emails.

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.

Request body

Field Type
emails (required) object[] 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"
    }
  ]
}

200 The same Idempotency-Key as before: the original emails, and nothing is sent again.

Field Type
data (required) object[] The emails in the order of the request.
JSON
{
  "data": [
    {
      "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
      "app_id": null,
      "from": "Acme Plumbing <hello@acmeplumbing.com>",
      "to": "sam@example.org",
      "recipients": {
        "to": [
          "sam@example.org"
        ],
        "cc": [],
        "bcc": []
      },
      "recipient_count": 1,
      "attachments": [
        {
          "filename": "quote.pdf",
          "content_type": "application/pdf",
          "size": 48213
        }
      ],
      "reply_to": "office@acmeplumbing.com",
      "subject": "We received your request",
      "category": "transactional",
      "status": "sent",
      "recipient_source": "form",
      "test": false,
      "error": null,
      "reason": null,
      "recipient_status": [
        {
          "email": "sam@example.org",
          "field": "to",
          "status": "sent",
          "reason": null,
          "updated_at": "2026-10-06T10:15:01.000Z"
        }
      ],
      "timeline": [
        {
          "status": "queued",
          "at": "2026-10-06T10:15:00.000Z",
          "recipient": null,
          "reason": null
        },
        {
          "status": "sent",
          "at": "2026-10-06T10:15:01.000Z",
          "recipient": null,
          "reason": null
        }
      ],
      "created_at": "2026-10-06T10:15:00.000Z",
      "sent_at": "2026-10-06T10:15:01.000Z",
      "delivered_at": null,
      "bounced_at": null,
      "complained_at": null
    }
  ]
}

201 Accepted for delivery (or simulated with a test key), in the order of the request.

Field Type
data (required) object[] The emails in the order of the request.
JSON
{
  "data": [
    {
      "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
      "app_id": null,
      "from": "Acme Plumbing <hello@acmeplumbing.com>",
      "to": "sam@example.org",
      "recipients": {
        "to": [
          "sam@example.org"
        ],
        "cc": [],
        "bcc": []
      },
      "recipient_count": 1,
      "attachments": [
        {
          "filename": "quote.pdf",
          "content_type": "application/pdf",
          "size": 48213
        }
      ],
      "reply_to": "office@acmeplumbing.com",
      "subject": "We received your request",
      "category": "transactional",
      "status": "sent",
      "recipient_source": "form",
      "test": false,
      "error": null,
      "reason": null,
      "recipient_status": [
        {
          "email": "sam@example.org",
          "field": "to",
          "status": "sent",
          "reason": null,
          "updated_at": "2026-10-06T10:15:01.000Z"
        }
      ],
      "timeline": [
        {
          "status": "queued",
          "at": "2026-10-06T10:15:00.000Z",
          "recipient": null,
          "reason": null
        },
        {
          "status": "sent",
          "at": "2026-10-06T10:15:01.000Z",
          "recipient": null,
          "reason": null
        }
      ],
      "created_at": "2026-10-06T10:15:00.000Z",
      "sent_at": "2026-10-06T10:15:01.000Z",
      "delivered_at": null,
      "bounced_at": null,
      "complained_at": null
    }
  ]
}

Errors

Besides the errors on every endpoint:

POST /app-emails/batch

Send a batch of emails (from an app).

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.

Request body

Field Type
emails (required) object[]
idempotency_key string
JSON
{
  "emails": [
    {
      "to": "sam@example.org",
      "subject": "Your invitation",
      "text": "Hi Sam, see you on 1 November.",
      "category": "transactional"
    }
  ]
}

200 The same Idempotency-Key as before: the original emails.

Field Type
data (required) object[] The emails in the order of the request.
JSON
{
  "data": [
    {
      "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
      "app_id": null,
      "from": "Acme Plumbing <hello@acmeplumbing.com>",
      "to": "sam@example.org",
      "recipients": {
        "to": [
          "sam@example.org"
        ],
        "cc": [],
        "bcc": []
      },
      "recipient_count": 1,
      "attachments": [
        {
          "filename": "quote.pdf",
          "content_type": "application/pdf",
          "size": 48213
        }
      ],
      "reply_to": "office@acmeplumbing.com",
      "subject": "We received your request",
      "category": "transactional",
      "status": "sent",
      "recipient_source": "form",
      "test": false,
      "error": null,
      "reason": null,
      "recipient_status": [
        {
          "email": "sam@example.org",
          "field": "to",
          "status": "sent",
          "reason": null,
          "updated_at": "2026-10-06T10:15:01.000Z"
        }
      ],
      "timeline": [
        {
          "status": "queued",
          "at": "2026-10-06T10:15:00.000Z",
          "recipient": null,
          "reason": null
        },
        {
          "status": "sent",
          "at": "2026-10-06T10:15:01.000Z",
          "recipient": null,
          "reason": null
        }
      ],
      "created_at": "2026-10-06T10:15:00.000Z",
      "sent_at": "2026-10-06T10:15:01.000Z",
      "delivered_at": null,
      "bounced_at": null,
      "complained_at": null
    }
  ]
}

201 Accepted for delivery (or simulated with a test key).

Field Type
data (required) object[] The emails in the order of the request.
JSON
{
  "data": [
    {
      "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
      "app_id": null,
      "from": "Acme Plumbing <hello@acmeplumbing.com>",
      "to": "sam@example.org",
      "recipients": {
        "to": [
          "sam@example.org"
        ],
        "cc": [],
        "bcc": []
      },
      "recipient_count": 1,
      "attachments": [
        {
          "filename": "quote.pdf",
          "content_type": "application/pdf",
          "size": 48213
        }
      ],
      "reply_to": "office@acmeplumbing.com",
      "subject": "We received your request",
      "category": "transactional",
      "status": "sent",
      "recipient_source": "form",
      "test": false,
      "error": null,
      "reason": null,
      "recipient_status": [
        {
          "email": "sam@example.org",
          "field": "to",
          "status": "sent",
          "reason": null,
          "updated_at": "2026-10-06T10:15:01.000Z"
        }
      ],
      "timeline": [
        {
          "status": "queued",
          "at": "2026-10-06T10:15:00.000Z",
          "recipient": null,
          "reason": null
        },
        {
          "status": "sent",
          "at": "2026-10-06T10:15:01.000Z",
          "recipient": null,
          "reason": null
        }
      ],
      "created_at": "2026-10-06T10:15:00.000Z",
      "sent_at": "2026-10-06T10:15:01.000Z",
      "delivered_at": null,
      "bounced_at": null,
      "complained_at": null
    }
  ]
}

Errors

Besides the errors on every endpoint:

GET /emails/{id}

Get one email.

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.

Scope: messages:read

Parameter In Type
id (required) path string (uuid) The id of the email.

200 The email.

Field Type
id (required) string (uuid) The id of the email. sendEmail and sendEmailBatch in an app return it; getEmail(id) reads it back.
app_id (required) string or null (uuid) The app that sent it with env.HYPAMOON.sendEmail; null when it was sent with the API, by an agent or from the portal.
from (required) string
to (required) string The first address in to. All recipients are in recipients.
recipients (required) object Everyone this email went to. After 90 days the addresses are masked, like s***@example.org.
recipient_count (required) integer to, cc and bcc together; each counts toward the limits and the bundle.
attachments (required) object[] Name, type and size of each attachment. The content is never stored.
reply_to (required) string or null
subject (required) string
category (required) "transactional", "other"
status (required) "queued", "sent", "delivered", "bounced", "complained", "failed", "simulated" queued, sent (accepted for delivery), delivered, bounced, complained (marked as spam), failed, or simulated (test mode).
recipient_source (required) "confirmed", "member", "form", "document", "conversation", "app_user", "buyer", "pass_holder", null Where the recipient appears in the project.
test (required) boolean
error (required) string or null
reason (required) string or null Why the email bounced, was marked as spam, is delayed or was not sent, in plain words. null otherwise.
recipient_status (required) object[] The status per recipient. With several recipients one can bounce while the others are delivered.
timeline (required) object[] Every step, oldest first: queued, then sent (or simulated, or failed), then what the recipient's mail server reported.
created_at (required) string
sent_at (required) string or null
delivered_at (required) string or null
bounced_at (required) string or null
complained_at (required) string or null
JSON
{
  "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "app_id": null,
  "from": "Acme Plumbing <hello@acmeplumbing.com>",
  "to": "sam@example.org",
  "recipients": {
    "to": [
      "sam@example.org"
    ],
    "cc": [],
    "bcc": []
  },
  "recipient_count": 1,
  "attachments": [
    {
      "filename": "quote.pdf",
      "content_type": "application/pdf",
      "size": 48213
    }
  ],
  "reply_to": "office@acmeplumbing.com",
  "subject": "We received your request",
  "category": "transactional",
  "status": "delivered",
  "recipient_source": "form",
  "test": false,
  "error": null,
  "reason": null,
  "recipient_status": [
    {
      "email": "sam@example.org",
      "field": "to",
      "status": "delivered",
      "reason": null,
      "updated_at": "2026-10-06T10:15:04.000Z"
    }
  ],
  "timeline": [
    {
      "status": "queued",
      "at": "2026-10-06T10:15:00.000Z",
      "recipient": null,
      "reason": null
    },
    {
      "status": "sent",
      "at": "2026-10-06T10:15:01.000Z",
      "recipient": null,
      "reason": null
    },
    {
      "status": "delivered",
      "at": "2026-10-06T10:15:04.000Z",
      "recipient": null,
      "reason": null
    }
  ],
  "created_at": "2026-10-06T10:15:00.000Z",
  "sent_at": "2026-10-06T10:15:01.000Z",
  "delivered_at": "2026-10-06T10:15:04.000Z",
  "bounced_at": null,
  "complained_at": null
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

GET /app-emails/{id}

Get one email of an app (from the app).

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.

Scope: data:read

Parameter In Type
id (required) path string (uuid) The id of the email.

200 The email.

Field Type
id (required) string (uuid) The id of the email. sendEmail and sendEmailBatch in an app return it; getEmail(id) reads it back.
app_id (required) string or null (uuid) The app that sent it with env.HYPAMOON.sendEmail; null when it was sent with the API, by an agent or from the portal.
from (required) string
to (required) string The first address in to. All recipients are in recipients.
recipients (required) object Everyone this email went to. After 90 days the addresses are masked, like s***@example.org.
recipient_count (required) integer to, cc and bcc together; each counts toward the limits and the bundle.
attachments (required) object[] Name, type and size of each attachment. The content is never stored.
reply_to (required) string or null
subject (required) string
category (required) "transactional", "other"
status (required) "queued", "sent", "delivered", "bounced", "complained", "failed", "simulated" queued, sent (accepted for delivery), delivered, bounced, complained (marked as spam), failed, or simulated (test mode).
recipient_source (required) "confirmed", "member", "form", "document", "conversation", "app_user", "buyer", "pass_holder", null Where the recipient appears in the project.
test (required) boolean
error (required) string or null
reason (required) string or null Why the email bounced, was marked as spam, is delayed or was not sent, in plain words. null otherwise.
recipient_status (required) object[] The status per recipient. With several recipients one can bounce while the others are delivered.
timeline (required) object[] Every step, oldest first: queued, then sent (or simulated, or failed), then what the recipient's mail server reported.
created_at (required) string
sent_at (required) string or null
delivered_at (required) string or null
bounced_at (required) string or null
complained_at (required) string or null
JSON
{
  "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "app_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
  "from": "noreply@acmeplumbing.com",
  "to": "sam@example.org",
  "recipients": {
    "to": [
      "sam@example.org"
    ],
    "cc": [],
    "bcc": []
  },
  "recipient_count": 1,
  "attachments": [],
  "reply_to": null,
  "subject": "Your hours for week 41 are approved",
  "category": "transactional",
  "status": "delivered",
  "recipient_source": "form",
  "test": false,
  "error": null,
  "reason": null,
  "recipient_status": [
    {
      "email": "sam@example.org",
      "field": "to",
      "status": "delivered",
      "reason": null,
      "updated_at": "2026-10-06T10:15:04.000Z"
    }
  ],
  "timeline": [
    {
      "status": "queued",
      "at": "2026-10-06T10:15:00.000Z",
      "recipient": null,
      "reason": null
    },
    {
      "status": "sent",
      "at": "2026-10-06T10:15:01.000Z",
      "recipient": null,
      "reason": null
    },
    {
      "status": "delivered",
      "at": "2026-10-06T10:15:04.000Z",
      "recipient": null,
      "reason": null
    }
  ],
  "created_at": "2026-10-06T10:15:00.000Z",
  "sent_at": "2026-10-06T10:15:01.000Z",
  "delivered_at": "2026-10-06T10:15:04.000Z",
  "bounced_at": null,
  "complained_at": null
}

Errors

Besides the errors on every endpoint:

Status Type
403 forbidden
404 not_found

GET /email-domains

List sending domains.

The project's sending domains with their status and DNS records.

Scope: sites:read

200 The sending domains.

Field Type
data (required) object[]
JSON
{
  "data": [
    {
      "id": "8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
      "domain": "acmeplumbing.com",
      "status": "pending",
      "test": false,
      "dns": "managed",
      "records": [
        {
          "type": "MX",
          "name": "send.acmeplumbing.com",
          "value": "feedback-smtp.example.net",
          "priority": 10,
          "state": "set"
        },
        {
          "type": "TXT",
          "name": "send.acmeplumbing.com",
          "value": "v=spf1 include:spf.example.net ~all",
          "state": "set"
        },
        {
          "type": "TXT",
          "name": "mail._domainkey.acmeplumbing.com",
          "value": "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...",
          "state": "set"
        }
      ],
      "dmarc_suggestion": {
        "type": "TXT",
        "name": "_dmarc.acmeplumbing.com",
        "value": "v=DMARC1; p=none;"
      },
      "next_step": "The records are set. Call verify_email_domain in a few minutes; verification usually takes minutes, sometimes up to a few hours.",
      "checked_at": null,
      "verified_at": null,
      "created_at": "2026-10-06T10:00:00.000Z"
    }
  ]
}

POST /email-domains

Add a sending domain.

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.

Scope: sites:write

Request body

Field Type
domain (required) string A domain of this project (connected with connect_domain or bought with register_domain), like example.com.
JSON
{
  "domain": "acmeplumbing.com"
}

200 Already a sending domain of this project.

Field Type
id (required) string (uuid)
domain (required) string
status (required) "pending", "verified", "failed"
test (required) boolean
dns (required) "managed", "manual" managed: the domain's DNS is here and we set the records; manual: the user adds them at their own DNS provider.
records (required) object[]
dmarc_suggestion (required) object or null Only when the domain has no DMARC record yet. A suggestion; we never set it ourselves.
next_step (required) string
checked_at (required) string or null
verified_at (required) string or null
created_at (required) string
JSON
{
  "id": "8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
  "domain": "acmeplumbing.com",
  "status": "pending",
  "test": false,
  "dns": "managed",
  "records": [
    {
      "type": "MX",
      "name": "send.acmeplumbing.com",
      "value": "feedback-smtp.example.net",
      "priority": 10,
      "state": "set"
    },
    {
      "type": "TXT",
      "name": "send.acmeplumbing.com",
      "value": "v=spf1 include:spf.example.net ~all",
      "state": "set"
    },
    {
      "type": "TXT",
      "name": "mail._domainkey.acmeplumbing.com",
      "value": "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...",
      "state": "set"
    }
  ],
  "dmarc_suggestion": {
    "type": "TXT",
    "name": "_dmarc.acmeplumbing.com",
    "value": "v=DMARC1; p=none;"
  },
  "next_step": "The records are set. Call verify_email_domain in a few minutes; verification usually takes minutes, sometimes up to a few hours.",
  "checked_at": null,
  "verified_at": null,
  "created_at": "2026-10-06T10:00:00.000Z"
}

201 The sending domain, with its records.

Field Type
id (required) string (uuid)
domain (required) string
status (required) "pending", "verified", "failed"
test (required) boolean
dns (required) "managed", "manual" managed: the domain's DNS is here and we set the records; manual: the user adds them at their own DNS provider.
records (required) object[]
dmarc_suggestion (required) object or null Only when the domain has no DMARC record yet. A suggestion; we never set it ourselves.
next_step (required) string
checked_at (required) string or null
verified_at (required) string or null
created_at (required) string
JSON
{
  "id": "8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
  "domain": "acmeplumbing.com",
  "status": "pending",
  "test": false,
  "dns": "managed",
  "records": [
    {
      "type": "MX",
      "name": "send.acmeplumbing.com",
      "value": "feedback-smtp.example.net",
      "priority": 10,
      "state": "set"
    },
    {
      "type": "TXT",
      "name": "send.acmeplumbing.com",
      "value": "v=spf1 include:spf.example.net ~all",
      "state": "set"
    },
    {
      "type": "TXT",
      "name": "mail._domainkey.acmeplumbing.com",
      "value": "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...",
      "state": "set"
    }
  ],
  "dmarc_suggestion": {
    "type": "TXT",
    "name": "_dmarc.acmeplumbing.com",
    "value": "v=DMARC1; p=none;"
  },
  "next_step": "The records are set. Call verify_email_domain in a few minutes; verification usually takes minutes, sometimes up to a few hours.",
  "checked_at": null,
  "verified_at": null,
  "created_at": "2026-10-06T10:00:00.000Z"
}

Errors

Besides the errors on every endpoint:

GET /email-domains/{id}

Get a sending domain.

One sending domain with its records and the status of the last check. Read-only; verify_email_domain runs a new check.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the sending domain.

200 The sending domain.

Field Type
id (required) string (uuid)
domain (required) string
status (required) "pending", "verified", "failed"
test (required) boolean
dns (required) "managed", "manual" managed: the domain's DNS is here and we set the records; manual: the user adds them at their own DNS provider.
records (required) object[]
dmarc_suggestion (required) object or null Only when the domain has no DMARC record yet. A suggestion; we never set it ourselves.
next_step (required) string
checked_at (required) string or null
verified_at (required) string or null
created_at (required) string
JSON
{
  "id": "8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
  "domain": "acmeplumbing.com",
  "status": "pending",
  "test": false,
  "dns": "managed",
  "records": [
    {
      "type": "MX",
      "name": "send.acmeplumbing.com",
      "value": "feedback-smtp.example.net",
      "priority": 10,
      "state": "set"
    },
    {
      "type": "TXT",
      "name": "send.acmeplumbing.com",
      "value": "v=spf1 include:spf.example.net ~all",
      "state": "set"
    },
    {
      "type": "TXT",
      "name": "mail._domainkey.acmeplumbing.com",
      "value": "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...",
      "state": "set"
    }
  ],
  "dmarc_suggestion": {
    "type": "TXT",
    "name": "_dmarc.acmeplumbing.com",
    "value": "v=DMARC1; p=none;"
  },
  "next_step": "The records are set. Call verify_email_domain in a few minutes; verification usually takes minutes, sometimes up to a few hours.",
  "checked_at": null,
  "verified_at": null,
  "created_at": "2026-10-06T10:00:00.000Z"
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

DELETE /email-domains/{id}

Remove a sending domain.

Stops sending from this domain. The DNS records remain (they do no harm); the domain itself stays connected.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the sending domain.

200 Removed.

Field Type
id (required) string (uuid)
deleted (required) true
JSON
{
  "id": "8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
  "deleted": true
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
502 upstream_error

POST /email-domains/{id}/verify

Check a sending domain now.

Checks the DNS records of the sending domain now and returns the new status: pending, verified or failed. At most 10 checks per minute per domain.

Scope: sites:write

Parameter In Type
id (required) path string (uuid) The id of the sending domain.

200 The sending domain after the check.

Field Type
id (required) string (uuid)
domain (required) string
status (required) "pending", "verified", "failed"
test (required) boolean
dns (required) "managed", "manual" managed: the domain's DNS is here and we set the records; manual: the user adds them at their own DNS provider.
records (required) object[]
dmarc_suggestion (required) object or null Only when the domain has no DMARC record yet. A suggestion; we never set it ourselves.
next_step (required) string
checked_at (required) string or null
verified_at (required) string or null
created_at (required) string
JSON
{
  "id": "8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
  "domain": "acmeplumbing.com",
  "status": "verified",
  "test": false,
  "dns": "managed",
  "records": [
    {
      "type": "MX",
      "name": "send.acmeplumbing.com",
      "value": "feedback-smtp.example.net",
      "priority": 10,
      "state": "set"
    },
    {
      "type": "TXT",
      "name": "send.acmeplumbing.com",
      "value": "v=spf1 include:spf.example.net ~all",
      "state": "set"
    },
    {
      "type": "TXT",
      "name": "mail._domainkey.acmeplumbing.com",
      "value": "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...",
      "state": "set"
    }
  ],
  "dmarc_suggestion": {
    "type": "TXT",
    "name": "_dmarc.acmeplumbing.com",
    "value": "v=DMARC1; p=none;"
  },
  "next_step": "The records are set. Call verify_email_domain in a few minutes; verification usually takes minutes, sometimes up to a few hours.",
  "checked_at": "2026-10-06T10:05:00.000Z",
  "verified_at": "2026-10-06T10:05:00.000Z",
  "created_at": "2026-10-06T10:00:00.000Z"
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found
502 upstream_error

POST /email-recipients

Confirm a recipient (owner only).

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.

Scope: messages:write

Request body

Field Type
email (required) string The address the owner confirmed they want to email.
JSON
{
  "email": "sam@example.org"
}

201 Confirmed.

Field Type
email (required) string
allowed (required) true
JSON
{
  "email": "sam@example.org",
  "allowed": true
}

Errors

Besides the errors on every endpoint:

POST /email-recipients/check

Check email addresses.

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.

Scope: messages:read

Request body

Field Type
email string One address to check.
emails string[] Or a list of 1 to 50 addresses.
JSON
{
  "emails": [
    "sam@example.org",
    "kim@gmial.com"
  ]
}

200 One result per address, in the order sent.

Field Type
data (required) object[]
JSON
{
  "data": [
    {
      "email": "sam@example.org",
      "result": "valid",
      "code": "ok",
      "reason": "The address looks deliverable: its domain accepts email.",
      "suggestion": null,
      "would_send": {
        "transactional": true,
        "other": true
      }
    },
    {
      "email": "kim@gmial.com",
      "result": "invalid",
      "code": "likely_typo",
      "reason": "This looks like a typo in the domain. Did you mean kim@gmail.com?",
      "suggestion": "kim@gmail.com",
      "would_send": {
        "transactional": false,
        "other": false
      }
    }
  ]
}

Errors

Besides the errors on every endpoint:

Status Type
400 invalid_json
422 validation_failed

GET /email-deliverability

Get delivery per sending domain.

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.

Scope: messages:read

200 Delivery per sending domain.

Field Type
test (required) boolean 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
  }
}

GET /email-domains/{id}/bimi

Get the inbox logo (BIMI) of a sending domain.

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.

Scope: sites:read

Parameter In Type
id (required) path string (uuid) The id of the sending domain.

200 The logo settings of the domain.

Field Type
email_domain_id (required) string
domain (required) string
dmarc (required) object
bimi (required) object
ready (required) boolean
dns_managed (required) boolean 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."
}

Errors

Besides the errors on every endpoint:

Status Type
404 not_found

POST /email-domains/{id}/bimi

Set the inbox logo (BIMI) of a sending domain.

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.</p> <p>Scope: <code>sites:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the sending domain.</td> </tr> </tbody></table></div> <h4 id="request-body-84"><a class="anker" href="#request-body-84">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>logo_url</code> (required)</td> <td>string</td> <td>The https address of your logo as an SVG Tiny PS file (square, with a <title>, at most 32 KB), for example on your own site.</td> </tr> <tr> <td><code>certificate_url</code></td> <td>string</td> <td>Optional: the https address of your mark certificate (VMC or CMC, a .pem file). Gmail needs one; Apple Mail needs a VMC.</td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "logo_url": "https://acmeplumbing.com/logo.svg" }</code></pre></div><h4 id="200-the-record-is-set-or-recordtoadd-says-what-to-add"><a class="anker" href="#200-the-record-is-set-or-recordtoadd-says-what-to-add">200 The record is set (or record_to_add says what to add).</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>email_domain_id</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>domain</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>dmarc</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>bimi</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>ready</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>dns_managed</code> (required)</td> <td>boolean</td> <td>The DNS is hosted here, so set_email_bimi sets the record itself.</td> </tr> <tr> <td><code>steps</code> (required)</td> <td>string[]</td> <td></td> </tr> <tr> <td><code>note</code> (required)</td> <td>string</td> <td>Which mailboxes show the logo, and what they need.</td> </tr> <tr> <td><code>record_to_add</code> (required)</td> <td>object or null</td> <td>When the DNS is hosted elsewhere: the record to add there. Null when we set it.</td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-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 }</code></pre></div><h4 id="errors-183"><a class="anker" href="#errors-183">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/bimi_not_ready"><code>bimi_not_ready</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> <tr> <td>502</td> <td><a href="/docs/errors/upstream_error"><code>upstream_error</code></a></td> </tr> </tbody></table></div> <h3 id="post-email-dedicated-ip"><a class="anker" href="#post-email-dedicated-ip">POST /email-dedicated-ip</a></h3> <p><strong>Request a dedicated sending IP.</strong></p> <p>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.</p> <p>Scope: <code>messages:write</code></p> <h4 id="request-body-85"><a class="anker" href="#request-body-85">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>note</code></td> <td>string</td> <td>Optional: anything we should know, like expected volume.</td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "note": "We send about 150,000 order emails a month." }</code></pre></div><h4 id="200-already-requested-the-open-request"><a class="anker" href="#200-already-requested-the-open-request">200 Already requested: the open request.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>status</code> (required)</td> <td><code>"none"</code>, <code>"requested"</code>, <code>"active"</code>, <code>"declined"</code></td> <td></td> </tr> <tr> <td><code>requested_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>emails_last_30_days</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>price</code> (required)</td> <td>object</td> <td>Per IP per month, excluding VAT; free for founder projects.</td> </tr> <tr> <td><code>advice</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>available</code> (required)</td> <td>boolean</td> <td>Whether this project can request one (Business and Enterprise).</td> </tr> <tr> <td><code>reason</code> (required)</td> <td>string or null</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-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 }</code></pre></div><h4 id="201-requested"><a class="anker" href="#201-requested">201 Requested.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>status</code> (required)</td> <td><code>"none"</code>, <code>"requested"</code>, <code>"active"</code>, <code>"declined"</code></td> <td></td> </tr> <tr> <td><code>requested_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>emails_last_30_days</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>price</code> (required)</td> <td>object</td> <td>Per IP per month, excluding VAT; free for founder projects.</td> </tr> <tr> <td><code>advice</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>available</code> (required)</td> <td>boolean</td> <td>Whether this project can request one (Business and Enterprise).</td> </tr> <tr> <td><code>reason</code> (required)</td> <td>string or null</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-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 }</code></pre></div><h4 id="errors-184"><a class="anker" href="#errors-184">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>403</td> <td><a href="/docs/errors/dedicated_ip_not_available"><code>dedicated_ip_not_available</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="get-email-templates"><a class="anker" href="#get-email-templates">GET /email-templates</a></h3> <p><strong>List email templates.</strong></p> <p>The project's email templates, newest first, with their variables.</p> <p>Scope: <code>messages:read</code></p> <h4 id="200-the-templates"><a class="anker" href="#200-the-templates">200 The templates.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td>object[]</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "data": [ { "id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "name": "Welcome", "subject": "Welcome, {{name|there}}", "html": "<p>Hi {{name|there}},</p><p>Thanks for signing up. Reply to this email if you have a question.</p>", "text": null, "style": "site", "site_id": null, "variables": [ "name" ], "created_at": "2026-10-15T10:00:00.000Z", "updated_at": "2026-10-15T10:00:00.000Z" } ] }</code></pre></div><h3 id="post-email-templates"><a class="anker" href="#post-email-templates">POST /email-templates</a></h3> <p><strong>Create an email template.</strong></p> <p>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.</p> <p>Scope: <code>messages:write</code></p> <h4 id="request-body-86"><a class="anker" href="#request-body-86">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>name</code> (required)</td> <td>string</td> <td>A name for the template, unique in the project, like "Welcome" or "Order shipped".</td> </tr> <tr> <td><code>subject</code> (required)</td> <td>string</td> <td>The subject, with variables like {{name}}.</td> </tr> <tr> <td><code>html</code></td> <td>string</td> <td>The HTML body with variables, like <p>Hi {{name|there}},</p>. Values are escaped. No scripts or forms.</td> </tr> <tr> <td><code>text</code></td> <td>string</td> <td>The plain text body with variables. Without it, the text comes from the html.</td> </tr> <tr> <td><code>style</code></td> <td><code>"site"</code>, <code>"none"</code></td> <td>site (default): the email gets the name, logo and colors of the project's site around it. none: the html is sent as it is.</td> </tr> <tr> <td><code>site_id</code></td> <td>string (uuid)</td> <td>Optional. The site whose style to use; without it the project's first site.</td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "name": "Welcome", "subject": "Welcome, {{name|there}}", "html": "<p>Hi {{name|there}},</p><p>Thanks for signing up.</p>", "style": "site" }</code></pre></div><h4 id="201-the-template"><a class="anker" href="#201-the-template">201 The template.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>subject</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>html</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>text</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>style</code> (required)</td> <td><code>"site"</code>, <code>"none"</code></td> <td>site: the name, logo and colors of the project's site around the email. none: sent as it is.</td> </tr> <tr> <td><code>site_id</code> (required)</td> <td>string or null (uuid)</td> <td></td> </tr> <tr> <td><code>variables</code> (required)</td> <td>string[]</td> <td>The variables in the subject and content, like name or order_number.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> <tr> <td><code>updated_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "name": "Welcome", "subject": "Welcome, {{name|there}}", "html": "<p>Hi {{name|there}},</p><p>Thanks for signing up. Reply to this email if you have a question.</p>", "text": null, "style": "site", "site_id": null, "variables": [ "name" ], "created_at": "2026-10-15T10:00:00.000Z", "updated_at": "2026-10-15T10:00:00.000Z" }</code></pre></div><h4 id="errors-185"><a class="anker" href="#errors-185">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="get-email-templates-id"><a class="anker" href="#get-email-templates-id">GET /email-templates/{id}</a></h3> <p><strong>Get an email template.</strong></p> <p>Scope: <code>messages:read</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the template.</td> </tr> </tbody></table></div> <h4 id="200-the-template"><a class="anker" href="#200-the-template">200 The template.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>subject</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>html</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>text</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>style</code> (required)</td> <td><code>"site"</code>, <code>"none"</code></td> <td>site: the name, logo and colors of the project's site around the email. none: sent as it is.</td> </tr> <tr> <td><code>site_id</code> (required)</td> <td>string or null (uuid)</td> <td></td> </tr> <tr> <td><code>variables</code> (required)</td> <td>string[]</td> <td>The variables in the subject and content, like name or order_number.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> <tr> <td><code>updated_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "name": "Welcome", "subject": "Welcome, {{name|there}}", "html": "<p>Hi {{name|there}},</p><p>Thanks for signing up. Reply to this email if you have a question.</p>", "text": null, "style": "site", "site_id": null, "variables": [ "name" ], "created_at": "2026-10-15T10:00:00.000Z", "updated_at": "2026-10-15T10:00:00.000Z" }</code></pre></div><h4 id="errors-186"><a class="anker" href="#errors-186">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> </tbody></table></div> <h3 id="patch-email-templates-id"><a class="anker" href="#patch-email-templates-id">PATCH /email-templates/{id}</a></h3> <p><strong>Update an email template.</strong></p> <p>Changes only the fields you send. Emails that are scheduled with this template use the new version when they go out.</p> <p>Scope: <code>messages:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the template.</td> </tr> </tbody></table></div> <h4 id="request-body-87"><a class="anker" href="#request-body-87">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>name</code></td> <td>string</td> <td></td> </tr> <tr> <td><code>subject</code></td> <td>string</td> <td></td> </tr> <tr> <td><code>html</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>text</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>style</code></td> <td><code>"site"</code>, <code>"none"</code></td> <td></td> </tr> <tr> <td><code>site_id</code></td> <td>string or null (uuid)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "subject": "Welcome to Acme, {{name|there}}" }</code></pre></div><h4 id="200-the-updated-template"><a class="anker" href="#200-the-updated-template">200 The updated template.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>subject</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>html</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>text</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>style</code> (required)</td> <td><code>"site"</code>, <code>"none"</code></td> <td>site: the name, logo and colors of the project's site around the email. none: sent as it is.</td> </tr> <tr> <td><code>site_id</code> (required)</td> <td>string or null (uuid)</td> <td></td> </tr> <tr> <td><code>variables</code> (required)</td> <td>string[]</td> <td>The variables in the subject and content, like name or order_number.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> <tr> <td><code>updated_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "name": "Welcome", "subject": "Welcome, {{name|there}}", "html": "<p>Hi {{name|there}},</p><p>Thanks for signing up. Reply to this email if you have a question.</p>", "text": null, "style": "site", "site_id": null, "variables": [ "name" ], "created_at": "2026-10-15T10:00:00.000Z", "updated_at": "2026-10-15T10:00:00.000Z" }</code></pre></div><h4 id="errors-187"><a class="anker" href="#errors-187">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="delete-email-templates-id"><a class="anker" href="#delete-email-templates-id">DELETE /email-templates/{id}</a></h3> <p><strong>Delete an email template.</strong></p> <p>Not possible while an automation, a sequence or a list uses it (conflict); change those first.</p> <p>Scope: <code>messages:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the template.</td> </tr> </tbody></table></div> <h4 id="200-deleted"><a class="anker" href="#200-deleted">200 Deleted.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>deleted</code> (required)</td> <td><code>true</code></td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "deleted": true }</code></pre></div><h4 id="errors-188"><a class="anker" href="#errors-188">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> </tbody></table></div> <h3 id="post-email-templates-id-preview"><a class="anker" href="#post-email-templates-id-preview">POST /email-templates/{id}/preview</a></h3> <p><strong>Preview an email template.</strong></p> <p>The template filled with data, in the style of the site, without sending anything. missing_variables lists what has no value.</p> <p>Scope: <code>messages:read</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the template.</td> </tr> </tbody></table></div> <h4 id="request-body-88"><a class="anker" href="#request-body-88">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code></td> <td>object</td> <td>The values for the variables, like { "name": "Sam", "order_number": "1042" }. Text, numbers or true/false.</td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "data": { "name": "Sam" } }</code></pre></div><h4 id="200-the-subject-html-and-text-as-they-would-be-sent"><a class="anker" href="#200-the-subject-html-and-text-as-they-would-be-sent">200 The subject, html and text as they would be sent.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>subject</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>html</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>text</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>missing_variables</code> (required)</td> <td>string[]</td> <td>Variables without a value in data and without a fallback ({{name|there}}); they come out empty.</td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "subject": "Welcome, Sam", "html": "<!doctype html>...", "text": "Hi Sam,\n\nThanks for signing up.", "missing_variables": [] }</code></pre></div><h4 id="errors-189"><a class="anker" href="#errors-189">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="get-email-automations"><a class="anker" href="#get-email-automations">GET /email-automations</a></h3> <p><strong>List email automations.</strong></p> <p>Scope: <code>messages:read</code></p> <h4 id="200-the-automations"><a class="anker" href="#200-the-automations">200 The automations.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td>object[]</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "data": [ { "id": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e", "event": "form.submitted", "template_id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "sequence_id": null, "to": "contact", "from": "Acme Plumbing <hello@acmeplumbing.com>", "reply_to": "office@acmeplumbing.com", "category": "transactional", "delay_minutes": 0, "filter": { "collection": "contact" }, "enabled": true, "created_at": "2026-10-15T10:00:00.000Z" } ] }</code></pre></div><h3 id="post-email-automations"><a class="anker" href="#post-email-automations">POST /email-automations</a></h3> <p><strong>Create an email automation.</strong></p> <p>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.</p> <p>Scope: <code>messages:write</code></p> <h4 id="request-body-89"><a class="anker" href="#request-body-89">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>event</code> (required)</td> <td><code>"form.submitted"</code>, <code>"booking.created"</code>, <code>"order.paid"</code>, <code>"order.payment_failed"</code>, <code>"app_user.created"</code>, <code>"list.subscribed"</code></td> <td>What happens.</td> </tr> <tr> <td><code>template_id</code></td> <td>string (uuid)</td> <td>The template to send. Give template_id or sequence_id.</td> </tr> <tr> <td><code>sequence_id</code></td> <td>string (uuid)</td> <td>Or: start this sequence for the person.</td> </tr> <tr> <td><code>to</code></td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>from</code> (required)</td> <td>string</td> <td>The sender: an address on a verified sending domain of this project, optionally with a name, like "Acme <a href="mailto:hello@acme.com">hello@acme.com</a>".</td> </tr> <tr> <td><code>reply_to</code></td> <td>string</td> <td>Optional. Where replies go, like the owner's own mailbox.</td> </tr> <tr> <td><code>category</code></td> <td><code>"transactional"</code>, <code>"other"</code></td> <td>transactional (default): it answers what the person did. other: gets an unsubscribe link.</td> </tr> <tr> <td><code>delay_minutes</code></td> <td>integer</td> <td>Wait this long after the event, like 60 for one hour. 0 sends right away.</td> </tr> <tr> <td><code>filter</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>enabled</code></td> <td>boolean</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "event": "form.submitted", "template_id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "from": "Acme Plumbing <hello@acmeplumbing.com>", "reply_to": "office@acmeplumbing.com", "filter": { "collection": "contact" } }</code></pre></div><h4 id="201-the-automation"><a class="anker" href="#201-the-automation">201 The automation.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>event</code> (required)</td> <td><code>"form.submitted"</code>, <code>"booking.created"</code>, <code>"order.paid"</code>, <code>"order.payment_failed"</code>, <code>"app_user.created"</code>, <code>"list.subscribed"</code></td> <td></td> </tr> <tr> <td><code>template_id</code> (required)</td> <td>string or null (uuid)</td> <td></td> </tr> <tr> <td><code>sequence_id</code> (required)</td> <td>string or null (uuid)</td> <td></td> </tr> <tr> <td><code>to</code> (required)</td> <td>string</td> <td>contact (the person of the event) or a fixed address.</td> </tr> <tr> <td><code>from</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>reply_to</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>category</code> (required)</td> <td><code>"transactional"</code>, <code>"other"</code></td> <td></td> </tr> <tr> <td><code>delay_minutes</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>filter</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>enabled</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e", "event": "form.submitted", "template_id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "sequence_id": null, "to": "contact", "from": "Acme Plumbing <hello@acmeplumbing.com>", "reply_to": "office@acmeplumbing.com", "category": "transactional", "delay_minutes": 0, "filter": { "collection": "contact" }, "enabled": true, "created_at": "2026-10-15T10:00:00.000Z" }</code></pre></div><h4 id="errors-190"><a class="anker" href="#errors-190">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>403</td> <td><a href="/docs/errors/recipient_not_allowed"><code>recipient_not_allowed</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/email_domain_not_verified"><code>email_domain_not_verified</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="patch-email-automations-id"><a class="anker" href="#patch-email-automations-id">PATCH /email-automations/{id}</a></h3> <p><strong>Update an email automation.</strong></p> <p>Changes only the fields you send, like enabled: false to pause it.</p> <p>Scope: <code>messages:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the automation.</td> </tr> </tbody></table></div> <h4 id="request-body-90"><a class="anker" href="#request-body-90">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>template_id</code></td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>to</code></td> <td>string</td> <td></td> </tr> <tr> <td><code>from</code></td> <td>string</td> <td>The sender: an address on a verified sending domain of this project, optionally with a name, like "Acme <a href="mailto:hello@acme.com">hello@acme.com</a>".</td> </tr> <tr> <td><code>reply_to</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>category</code></td> <td><code>"transactional"</code>, <code>"other"</code></td> <td></td> </tr> <tr> <td><code>delay_minutes</code></td> <td>integer</td> <td></td> </tr> <tr> <td><code>filter</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>enabled</code></td> <td>boolean</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "enabled": false }</code></pre></div><h4 id="200-the-updated-automation"><a class="anker" href="#200-the-updated-automation">200 The updated automation.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>event</code> (required)</td> <td><code>"form.submitted"</code>, <code>"booking.created"</code>, <code>"order.paid"</code>, <code>"order.payment_failed"</code>, <code>"app_user.created"</code>, <code>"list.subscribed"</code></td> <td></td> </tr> <tr> <td><code>template_id</code> (required)</td> <td>string or null (uuid)</td> <td></td> </tr> <tr> <td><code>sequence_id</code> (required)</td> <td>string or null (uuid)</td> <td></td> </tr> <tr> <td><code>to</code> (required)</td> <td>string</td> <td>contact (the person of the event) or a fixed address.</td> </tr> <tr> <td><code>from</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>reply_to</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>category</code> (required)</td> <td><code>"transactional"</code>, <code>"other"</code></td> <td></td> </tr> <tr> <td><code>delay_minutes</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>filter</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>enabled</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e", "event": "form.submitted", "template_id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "sequence_id": null, "to": "contact", "from": "Acme Plumbing <hello@acmeplumbing.com>", "reply_to": "office@acmeplumbing.com", "category": "transactional", "delay_minutes": 0, "filter": { "collection": "contact" }, "enabled": false, "created_at": "2026-10-15T10:00:00.000Z" }</code></pre></div><h4 id="errors-191"><a class="anker" href="#errors-191">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>403</td> <td><a href="/docs/errors/recipient_not_allowed"><code>recipient_not_allowed</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/email_domain_not_verified"><code>email_domain_not_verified</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="delete-email-automations-id"><a class="anker" href="#delete-email-automations-id">DELETE /email-automations/{id}</a></h3> <p><strong>Delete an email automation.</strong></p> <p>Emails it already scheduled still go; cancel those with POST /scheduled-emails/{id}/cancel.</p> <p>Scope: <code>messages:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the automation.</td> </tr> </tbody></table></div> <h4 id="200-deleted-2"><a class="anker" href="#200-deleted-2">200 Deleted.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>deleted</code> (required)</td> <td><code>true</code></td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e", "deleted": true }</code></pre></div><h4 id="errors-192"><a class="anker" href="#errors-192">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> </tbody></table></div> <h3 id="get-scheduled-emails"><a class="anker" href="#get-scheduled-emails">GET /scheduled-emails</a></h3> <p><strong>List scheduled emails.</strong></p> <p>Emails that go later or went: send_at, automations, sequence steps, list sends and confirmation emails, newest first, with their status.</p> <p>Scope: <code>messages:read</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>limit</code></td> <td>query</td> <td>integer</td> <td>Per page, 1 to 100.</td> </tr> <tr> <td><code>cursor</code></td> <td>query</td> <td>string</td> <td>The next_cursor of the previous page.</td> </tr> <tr> <td><code>status</code></td> <td>query</td> <td><code>"scheduled"</code>, <code>"sending"</code>, <code>"sent"</code>, <code>"canceled"</code>, <code>"skipped"</code>, <code>"failed"</code></td> <td>Only this status.</td> </tr> <tr> <td><code>source</code></td> <td>query</td> <td><code>"api"</code>, <code>"automation"</code>, <code>"sequence"</code>, <code>"list"</code>, <code>"confirmation"</code></td> <td>Only this source.</td> </tr> </tbody></table></div> <h4 id="200-the-scheduled-emails"><a class="anker" href="#200-the-scheduled-emails">200 The scheduled emails.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td>object[]</td> <td></td> </tr> <tr> <td><code>next_cursor</code> (required)</td> <td>string or null</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "data": [ { "id": "6c7d8e9f-0a1b-4c2d-9e3f-4a5b6c7d8e9f", "source": "api", "status": "scheduled", "send_at": "2026-10-16T08:00:00.000Z", "to": "sam@example.org", "subject": "Your appointment tomorrow", "template_id": null, "email_id": null, "reason": null, "test": false, "created_at": "2026-10-15T10:00:00.000Z" } ], "next_cursor": null }</code></pre></div><h4 id="errors-193"><a class="anker" href="#errors-193">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_parameter"><code>invalid_parameter</code></a></td> </tr> </tbody></table></div> <h3 id="get-scheduled-emails-id"><a class="anker" href="#get-scheduled-emails-id">GET /scheduled-emails/{id}</a></h3> <p><strong>Get a scheduled email.</strong></p> <p>Scope: <code>messages:read</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the scheduled email.</td> </tr> </tbody></table></div> <h4 id="200-the-scheduled-email"><a class="anker" href="#200-the-scheduled-email">200 The scheduled email.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the scheduled email. cancel_scheduled_email takes it.</td> </tr> <tr> <td><code>source</code> (required)</td> <td><code>"api"</code>, <code>"automation"</code>, <code>"sequence"</code>, <code>"list"</code>, <code>"confirmation"</code></td> <td>api: send_email with send_at. automation, sequence, list: sent by those. confirmation: the confirmation email of a list.</td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"scheduled"</code>, <code>"sending"</code>, <code>"sent"</code>, <code>"canceled"</code>, <code>"skipped"</code>, <code>"failed"</code></td> <td>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).</td> </tr> <tr> <td><code>send_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> <tr> <td><code>to</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>subject</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>template_id</code> (required)</td> <td>string or null (uuid)</td> <td></td> </tr> <tr> <td><code>email_id</code> (required)</td> <td>string or null (uuid)</td> <td>The email once it was sent; get_email shows whether it was delivered.</td> </tr> <tr> <td><code>reason</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "6c7d8e9f-0a1b-4c2d-9e3f-4a5b6c7d8e9f", "source": "api", "status": "scheduled", "send_at": "2026-10-16T08:00:00.000Z", "to": "sam@example.org", "subject": "Your appointment tomorrow", "template_id": null, "email_id": null, "reason": null, "test": false, "created_at": "2026-10-15T10:00:00.000Z" }</code></pre></div><h4 id="errors-194"><a class="anker" href="#errors-194">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> </tbody></table></div> <h3 id="post-scheduled-emails-id-cancel"><a class="anker" href="#post-scheduled-emails-id-cancel">POST /scheduled-emails/{id}/cancel</a></h3> <p><strong>Cancel a scheduled email.</strong></p> <p>An email that is still scheduled will not be sent. Once it is sent (or sending) it cannot be canceled (conflict).</p> <p>Scope: <code>messages:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the scheduled email.</td> </tr> </tbody></table></div> <h4 id="200-canceled"><a class="anker" href="#200-canceled">200 Canceled.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the scheduled email. cancel_scheduled_email takes it.</td> </tr> <tr> <td><code>source</code> (required)</td> <td><code>"api"</code>, <code>"automation"</code>, <code>"sequence"</code>, <code>"list"</code>, <code>"confirmation"</code></td> <td>api: send_email with send_at. automation, sequence, list: sent by those. confirmation: the confirmation email of a list.</td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"scheduled"</code>, <code>"sending"</code>, <code>"sent"</code>, <code>"canceled"</code>, <code>"skipped"</code>, <code>"failed"</code></td> <td>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).</td> </tr> <tr> <td><code>send_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> <tr> <td><code>to</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>subject</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>template_id</code> (required)</td> <td>string or null (uuid)</td> <td></td> </tr> <tr> <td><code>email_id</code> (required)</td> <td>string or null (uuid)</td> <td>The email once it was sent; get_email shows whether it was delivered.</td> </tr> <tr> <td><code>reason</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "6c7d8e9f-0a1b-4c2d-9e3f-4a5b6c7d8e9f", "source": "api", "status": "canceled", "send_at": "2026-10-16T08:00:00.000Z", "to": "sam@example.org", "subject": "Your appointment tomorrow", "template_id": null, "email_id": null, "reason": "canceled", "test": false, "created_at": "2026-10-15T10:00:00.000Z" }</code></pre></div><h4 id="errors-195"><a class="anker" href="#errors-195">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> </tbody></table></div> <h3 id="get-email-sequences"><a class="anker" href="#get-email-sequences">GET /email-sequences</a></h3> <p><strong>List email sequences.</strong></p> <p>Scope: <code>messages:read</code></p> <h4 id="200-the-sequences-with-how-many-people-are-in-each-now"><a class="anker" href="#200-the-sequences-with-how-many-people-are-in-each-now">200 The sequences, with how many people are in each now.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td>object[]</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "data": [ { "id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a", "name": "Onboarding", "steps": [ { "template_id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "wait_minutes": 0 }, { "template_id": "8e9f0a1b-2c3d-4e4f-9a5b-6c7d8e9f0a1b", "wait_minutes": 2880 } ], "stop_on": [ "order.paid" ], "from": "Acme Plumbing <hello@acmeplumbing.com>", "reply_to": null, "category": "other", "active": 12, "created_at": "2026-10-15T10:00:00.000Z" } ] }</code></pre></div><h3 id="post-email-sequences"><a class="anker" href="#post-email-sequences">POST /email-sequences</a></h3> <p><strong>Create an email sequence.</strong></p> <p>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.</p> <p>Scope: <code>messages:write</code></p> <h4 id="request-body-91"><a class="anker" href="#request-body-91">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>name</code> (required)</td> <td>string</td> <td>A name, like "Onboarding".</td> </tr> <tr> <td><code>steps</code> (required)</td> <td>object[]</td> <td>1 to 20 emails, each a template with a wait before it.</td> </tr> <tr> <td><code>stop_on</code></td> <td><code>"form.submitted"</code>, <code>"booking.created"</code>, <code>"order.paid"</code>, <code>"order.payment_failed"</code>, <code>"app_user.created"</code>, <code>"list.subscribed"</code>[]</td> <td>Stop for a person when one of these happens for their address, like order.paid. Unsubscribing always stops.</td> </tr> <tr> <td><code>from</code> (required)</td> <td>string</td> <td>The sender: an address on a verified sending domain of this project, optionally with a name, like "Acme <a href="mailto:hello@acme.com">hello@acme.com</a>".</td> </tr> <tr> <td><code>reply_to</code></td> <td>string</td> <td>Optional. Where replies go, like the owner's own mailbox.</td> </tr> <tr> <td><code>category</code></td> <td><code>"transactional"</code>, <code>"other"</code></td> <td>other (default): every email has an unsubscribe link. transactional only for steps that follow up on something the person did.</td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "name": "Onboarding", "steps": [ { "template_id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "wait_minutes": 0 }, { "template_id": "8e9f0a1b-2c3d-4e4f-9a5b-6c7d8e9f0a1b", "wait_minutes": 2880 } ], "stop_on": [ "order.paid" ], "from": "Acme Plumbing <hello@acmeplumbing.com>" }</code></pre></div><h4 id="201-the-sequence"><a class="anker" href="#201-the-sequence">201 The sequence.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>steps</code> (required)</td> <td>object[]</td> <td></td> </tr> <tr> <td><code>stop_on</code> (required)</td> <td><code>"form.submitted"</code>, <code>"booking.created"</code>, <code>"order.paid"</code>, <code>"order.payment_failed"</code>, <code>"app_user.created"</code>, <code>"list.subscribed"</code>[]</td> <td></td> </tr> <tr> <td><code>from</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>reply_to</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>category</code> (required)</td> <td><code>"transactional"</code>, <code>"other"</code></td> <td></td> </tr> <tr> <td><code>active</code> (required)</td> <td>integer</td> <td>How many people are in the sequence now.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a", "name": "Onboarding", "steps": [ { "template_id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "wait_minutes": 0 }, { "template_id": "8e9f0a1b-2c3d-4e4f-9a5b-6c7d8e9f0a1b", "wait_minutes": 2880 } ], "stop_on": [ "order.paid" ], "from": "Acme Plumbing <hello@acmeplumbing.com>", "reply_to": null, "category": "other", "active": 0, "created_at": "2026-10-15T10:00:00.000Z" }</code></pre></div><h4 id="errors-196"><a class="anker" href="#errors-196">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/email_domain_not_verified"><code>email_domain_not_verified</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="get-email-sequences-id"><a class="anker" href="#get-email-sequences-id">GET /email-sequences/{id}</a></h3> <p><strong>Get an email sequence.</strong></p> <p>The sequence with its last 100 enrollments.</p> <p>Scope: <code>messages:read</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the sequence.</td> </tr> </tbody></table></div> <h4 id="200-the-sequence"><a class="anker" href="#200-the-sequence">200 The sequence.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>steps</code> (required)</td> <td>object[]</td> <td></td> </tr> <tr> <td><code>stop_on</code> (required)</td> <td><code>"form.submitted"</code>, <code>"booking.created"</code>, <code>"order.paid"</code>, <code>"order.payment_failed"</code>, <code>"app_user.created"</code>, <code>"list.subscribed"</code>[]</td> <td></td> </tr> <tr> <td><code>from</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>reply_to</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>category</code> (required)</td> <td><code>"transactional"</code>, <code>"other"</code></td> <td></td> </tr> <tr> <td><code>active</code> (required)</td> <td>integer</td> <td>How many people are in the sequence now.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> <tr> <td><code>enrollments</code> (required)</td> <td>object[]</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a", "name": "Onboarding", "steps": [ { "template_id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "wait_minutes": 0 }, { "template_id": "8e9f0a1b-2c3d-4e4f-9a5b-6c7d8e9f0a1b", "wait_minutes": 2880 } ], "stop_on": [ "order.paid" ], "from": "Acme Plumbing <hello@acmeplumbing.com>", "reply_to": null, "category": "other", "active": 12, "created_at": "2026-10-15T10:00:00.000Z", "enrollments": [ { "id": "9f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c", "sequence_id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a", "email": "sam@example.org", "status": "active", "step": 0, "next_send_at": "2026-10-15T10:00:00.000Z", "stop_reason": null, "created_at": "2026-10-15T10:00:00.000Z" } ] }</code></pre></div><h4 id="errors-197"><a class="anker" href="#errors-197">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> </tbody></table></div> <h3 id="delete-email-sequences-id"><a class="anker" href="#delete-email-sequences-id">DELETE /email-sequences/{id}</a></h3> <p><strong>Delete an email sequence.</strong></p> <p>Everyone in it stops; steps that were scheduled are not sent.</p> <p>Scope: <code>messages:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the sequence.</td> </tr> </tbody></table></div> <h4 id="200-deleted-3"><a class="anker" href="#200-deleted-3">200 Deleted.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>deleted</code> (required)</td> <td><code>true</code></td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a", "deleted": true }</code></pre></div><h4 id="errors-198"><a class="anker" href="#errors-198">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> </tbody></table></div> <h3 id="post-email-sequences-id-enrollments"><a class="anker" href="#post-email-sequences-id-enrollments">POST /email-sequences/{id}/enrollments</a></h3> <p><strong>Add someone to an email sequence.</strong></p> <p>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).</p> <p>Scope: <code>messages:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the sequence.</td> </tr> </tbody></table></div> <h4 id="request-body-92"><a class="anker" href="#request-body-92">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>email</code> (required)</td> <td>string</td> <td>The person's address. It must appear in the project, like any recipient.</td> </tr> <tr> <td><code>data</code></td> <td>object</td> <td>The values for the variables, like { "name": "Sam", "order_number": "1042" }. Text, numbers or true/false.</td> </tr> <tr> <td><code>in_reply_to</code></td> <td>string (uuid)</td> <td>The form submission or chat conversation this person came from, when that is the only place the address appears.</td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "email": "sam@example.org", "data": { "name": "Sam" } }</code></pre></div><h4 id="200-already-in-the-sequence"><a class="anker" href="#200-already-in-the-sequence">200 Already in the sequence.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>sequence_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>email</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"active"</code>, <code>"completed"</code>, <code>"stopped"</code></td> <td></td> </tr> <tr> <td><code>step</code> (required)</td> <td>integer</td> <td>The step that is next (0 is the first).</td> </tr> <tr> <td><code>next_send_at</code> (required)</td> <td>string or null (date-time)</td> <td></td> </tr> <tr> <td><code>stop_reason</code> (required)</td> <td>string or null</td> <td>Why it stopped: unsubscribed, stopped, or the event (like order.paid).</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "9f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c", "sequence_id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a", "email": "sam@example.org", "status": "active", "step": 0, "next_send_at": "2026-10-15T10:00:00.000Z", "stop_reason": null, "created_at": "2026-10-15T10:00:00.000Z" }</code></pre></div><h4 id="201-added-the-first-step-goes-after-its-wait"><a class="anker" href="#201-added-the-first-step-goes-after-its-wait">201 Added; the first step goes after its wait.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>sequence_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>email</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"active"</code>, <code>"completed"</code>, <code>"stopped"</code></td> <td></td> </tr> <tr> <td><code>step</code> (required)</td> <td>integer</td> <td>The step that is next (0 is the first).</td> </tr> <tr> <td><code>next_send_at</code> (required)</td> <td>string or null (date-time)</td> <td></td> </tr> <tr> <td><code>stop_reason</code> (required)</td> <td>string or null</td> <td>Why it stopped: unsubscribed, stopped, or the event (like order.paid).</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "9f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c", "sequence_id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a", "email": "sam@example.org", "status": "active", "step": 0, "next_send_at": "2026-10-15T10:00:00.000Z", "stop_reason": null, "created_at": "2026-10-15T10:00:00.000Z" }</code></pre></div><h4 id="errors-199"><a class="anker" href="#errors-199">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>403</td> <td><a href="/docs/errors/recipient_not_allowed"><code>recipient_not_allowed</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="post-email-sequences-id-stop"><a class="anker" href="#post-email-sequences-id-stop">POST /email-sequences/{id}/stop</a></h3> <p><strong>Stop an email sequence for someone.</strong></p> <p>For example when the person replied. Steps that were scheduled for them are not sent.</p> <p>Scope: <code>messages:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the sequence.</td> </tr> </tbody></table></div> <h4 id="request-body-93"><a class="anker" href="#request-body-93">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>email</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "email": "sam@example.org" }</code></pre></div><h4 id="200-stopped"><a class="anker" href="#200-stopped">200 Stopped.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>sequence_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>email</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"active"</code>, <code>"completed"</code>, <code>"stopped"</code></td> <td></td> </tr> <tr> <td><code>step</code> (required)</td> <td>integer</td> <td>The step that is next (0 is the first).</td> </tr> <tr> <td><code>next_send_at</code> (required)</td> <td>string or null (date-time)</td> <td></td> </tr> <tr> <td><code>stop_reason</code> (required)</td> <td>string or null</td> <td>Why it stopped: unsubscribed, stopped, or the event (like order.paid).</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "9f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c", "sequence_id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a", "email": "sam@example.org", "status": "stopped", "step": 0, "next_send_at": null, "stop_reason": "stopped", "created_at": "2026-10-15T10:00:00.000Z" }</code></pre></div><h4 id="errors-200"><a class="anker" href="#errors-200">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h2 id="agencies"><a class="anker" href="#agencies">Agencies</a></h2> <p>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.</p> <h3 id="get-clients"><a class="anker" href="#get-clients">GET /clients</a></h3> <p><strong>List your client projects.</strong></p> <p>The client projects of your agency: pending access requests and active relations, newest first. With all=true also declined and ended ones.</p> <p>Scope: <code>clients:read</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>all</code></td> <td>query</td> <td>boolean</td> <td>Also include declined and ended relations.</td> </tr> </tbody></table></div> <h4 id="200-the-client-relations"><a class="anker" href="#200-the-client-relations">200 The client relations.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"list"</code></td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object[]</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "list", "data": [ { "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "active", "stage": "building", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "client", "budget_eur": null, "payer_change": null, "owner_invite": null, "confirmed_at": null, "ended_at": null, "created_at": "2026-10-12T09:00:00.000Z" } ] }</code></pre></div><h3 id="post-clients"><a class="anker" href="#post-clients">POST /clients</a></h3> <p><strong>Set up a project for a client.</strong></p> <p>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.</p> <p>Scope: <code>clients:write</code></p> <h4 id="request-body-94"><a class="anker" href="#request-body-94">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>name</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>payer</code></td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "name": "Bakery Jansen", "payer": "agency" }</code></pre></div><h4 id="201-the-new-client-project-and-its-relation"><a class="anker" href="#201-the-new-client-project-and-its-relation">201 The new client project and its relation.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_relation"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>agency</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>client</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"pending"</code>, <code>"active"</code>, <code>"declined"</code>, <code>"ended"</code></td> <td></td> </tr> <tr> <td><code>stage</code> (required)</td> <td><code>"building"</code>, <code>"client_owned"</code></td> <td></td> </tr> <tr> <td><code>role</code> (required)</td> <td><code>"viewer"</code>, <code>"builder"</code>, <code>"admin"</code></td> <td></td> </tr> <tr> <td><code>max_mode</code> (required)</td> <td><code>"test"</code>, <code>"live"</code></td> <td></td> </tr> <tr> <td><code>four_eyes</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>payer</code> (required)</td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> <tr> <td><code>budget_eur</code> (required)</td> <td>number or null</td> <td></td> </tr> <tr> <td><code>payer_change</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>owner_invite</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>confirmed_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>transfer</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>ended_via</code></td> <td><code>"client"</code>, <code>"agency"</code>, <code>"admin"</code>, <code>"handover"</code>, <code>"expired"</code>, <code>"cleanup"</code></td> <td></td> </tr> <tr> <td><code>cleanup</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>project</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "active", "stage": "building", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "client", "budget_eur": null, "payer_change": null, "owner_invite": null, "confirmed_at": null, "ended_at": null, "created_at": "2026-10-12T09:00:00.000Z", "project": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" } }</code></pre></div><h4 id="errors-201"><a class="anker" href="#errors-201">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="post-clients-access-requests"><a class="anker" href="#post-clients-access-requests">POST /clients/access-requests</a></h3> <p><strong>Ask a client for access to their project.</strong></p> <p>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).</p> <p>Scope: <code>clients:write</code></p> <h4 id="request-body-95"><a class="anker" href="#request-body-95">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>project_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>message</code></td> <td>string</td> <td></td> </tr> <tr> <td><code>payer</code></td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "message": "As discussed on the phone.", "payer": "agency" }</code></pre></div><h4 id="200-the-open-request-or-active-relation-that-already-existed"><a class="anker" href="#200-the-open-request-or-active-relation-that-already-existed">200 The open request or active relation that already existed.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_relation"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>agency</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>client</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"pending"</code>, <code>"active"</code>, <code>"declined"</code>, <code>"ended"</code></td> <td></td> </tr> <tr> <td><code>stage</code> (required)</td> <td><code>"building"</code>, <code>"client_owned"</code></td> <td></td> </tr> <tr> <td><code>role</code> (required)</td> <td><code>"viewer"</code>, <code>"builder"</code>, <code>"admin"</code></td> <td></td> </tr> <tr> <td><code>max_mode</code> (required)</td> <td><code>"test"</code>, <code>"live"</code></td> <td></td> </tr> <tr> <td><code>four_eyes</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>payer</code> (required)</td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> <tr> <td><code>budget_eur</code> (required)</td> <td>number or null</td> <td></td> </tr> <tr> <td><code>payer_change</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>owner_invite</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>confirmed_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>transfer</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>ended_via</code></td> <td><code>"client"</code>, <code>"agency"</code>, <code>"admin"</code>, <code>"handover"</code>, <code>"expired"</code>, <code>"cleanup"</code></td> <td></td> </tr> <tr> <td><code>cleanup</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>confirm_url</code> (required)</td> <td>string (uri)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "active", "stage": "client_owned", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "client", "budget_eur": null, "payer_change": null, "owner_invite": null, "confirmed_at": null, "ended_at": null, "created_at": "2026-10-12T09:00:00.000Z", "confirm_url": "https://app.hypamoon.com/agencies?request=6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716" }</code></pre></div><h4 id="201-the-new-request"><a class="anker" href="#201-the-new-request">201 The new request.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_relation"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>agency</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>client</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"pending"</code>, <code>"active"</code>, <code>"declined"</code>, <code>"ended"</code></td> <td></td> </tr> <tr> <td><code>stage</code> (required)</td> <td><code>"building"</code>, <code>"client_owned"</code></td> <td></td> </tr> <tr> <td><code>role</code> (required)</td> <td><code>"viewer"</code>, <code>"builder"</code>, <code>"admin"</code></td> <td></td> </tr> <tr> <td><code>max_mode</code> (required)</td> <td><code>"test"</code>, <code>"live"</code></td> <td></td> </tr> <tr> <td><code>four_eyes</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>payer</code> (required)</td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> <tr> <td><code>budget_eur</code> (required)</td> <td>number or null</td> <td></td> </tr> <tr> <td><code>payer_change</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>owner_invite</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>confirmed_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>transfer</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>ended_via</code></td> <td><code>"client"</code>, <code>"agency"</code>, <code>"admin"</code>, <code>"handover"</code>, <code>"expired"</code>, <code>"cleanup"</code></td> <td></td> </tr> <tr> <td><code>cleanup</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>confirm_url</code> (required)</td> <td>string (uri)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "pending", "stage": "client_owned", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "client", "budget_eur": null, "payer_change": null, "owner_invite": null, "confirmed_at": null, "ended_at": null, "created_at": "2026-10-12T09:00:00.000Z", "confirm_url": "https://app.hypamoon.com/agencies?request=6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716" }</code></pre></div><h4 id="errors-202"><a class="anker" href="#errors-202">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="post-clients-id-owner-invite"><a class="anker" href="#post-clients-id-owner-invite">POST /clients/{id}/owner-invite</a></h3> <p><strong>Invite the client to own the project.</strong></p> <p>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.</p> <p>Scope: <code>clients:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> </tbody></table></div> <h4 id="request-body-96"><a class="anker" href="#request-body-96">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>email</code> (required)</td> <td>string (email)</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "email": "owner@example.org" }</code></pre></div><h4 id="200-the-relation-with-the-open-invitation"><a class="anker" href="#200-the-relation-with-the-open-invitation">200 The relation with the open invitation.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_relation"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>agency</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>client</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"pending"</code>, <code>"active"</code>, <code>"declined"</code>, <code>"ended"</code></td> <td></td> </tr> <tr> <td><code>stage</code> (required)</td> <td><code>"building"</code>, <code>"client_owned"</code></td> <td></td> </tr> <tr> <td><code>role</code> (required)</td> <td><code>"viewer"</code>, <code>"builder"</code>, <code>"admin"</code></td> <td></td> </tr> <tr> <td><code>max_mode</code> (required)</td> <td><code>"test"</code>, <code>"live"</code></td> <td></td> </tr> <tr> <td><code>four_eyes</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>payer</code> (required)</td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> <tr> <td><code>budget_eur</code> (required)</td> <td>number or null</td> <td></td> </tr> <tr> <td><code>payer_change</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>owner_invite</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>confirmed_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>transfer</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>ended_via</code></td> <td><code>"client"</code>, <code>"agency"</code>, <code>"admin"</code>, <code>"handover"</code>, <code>"expired"</code>, <code>"cleanup"</code></td> <td></td> </tr> <tr> <td><code>cleanup</code></td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "active", "stage": "building", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "client", "budget_eur": null, "payer_change": null, "owner_invite": { "email": "owner@example.org", "expires_at": "2026-10-26T09:00:00.000Z", "agency_keeps_access": true }, "confirmed_at": null, "ended_at": null, "created_at": "2026-10-12T09:00:00.000Z" }</code></pre></div><h4 id="errors-203"><a class="anker" href="#errors-203">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="delete-clients-id"><a class="anker" href="#delete-clients-id">DELETE /clients/{id}</a></h3> <p><strong>Stop working on a client project.</strong></p> <p>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.</p> <p>Scope: <code>clients:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> </tbody></table></div> <h4 id="200-the-ended-relation"><a class="anker" href="#200-the-ended-relation">200 The ended relation.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_relation"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>agency</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>client</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"pending"</code>, <code>"active"</code>, <code>"declined"</code>, <code>"ended"</code></td> <td></td> </tr> <tr> <td><code>stage</code> (required)</td> <td><code>"building"</code>, <code>"client_owned"</code></td> <td></td> </tr> <tr> <td><code>role</code> (required)</td> <td><code>"viewer"</code>, <code>"builder"</code>, <code>"admin"</code></td> <td></td> </tr> <tr> <td><code>max_mode</code> (required)</td> <td><code>"test"</code>, <code>"live"</code></td> <td></td> </tr> <tr> <td><code>four_eyes</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>payer</code> (required)</td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> <tr> <td><code>budget_eur</code> (required)</td> <td>number or null</td> <td></td> </tr> <tr> <td><code>payer_change</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>owner_invite</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>confirmed_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>transfer</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>ended_via</code></td> <td><code>"client"</code>, <code>"agency"</code>, <code>"admin"</code>, <code>"handover"</code>, <code>"expired"</code>, <code>"cleanup"</code></td> <td></td> </tr> <tr> <td><code>cleanup</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>payer_stopped</code></td> <td>boolean</td> <td></td> </tr> <tr> <td><code>open_payments</code></td> <td>object[]</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "ended", "stage": "building", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "client", "budget_eur": null, "payer_change": null, "owner_invite": null, "confirmed_at": null, "ended_at": "2026-10-20T09:00:00.000Z", "created_at": "2026-10-12T09:00:00.000Z", "payer_stopped": false, "open_payments": [] }</code></pre></div><h4 id="errors-204"><a class="anker" href="#errors-204">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> </tbody></table></div> <h3 id="post-clients-id-transfer"><a class="anker" href="#post-clients-id-transfer">POST /clients/{id}/transfer</a></h3> <p><strong>Hand over a client project.</strong></p> <p>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.</p> <p>Scope: <code>clients:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> </tbody></table></div> <h4 id="request-body-97"><a class="anker" href="#request-body-97">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>to_email</code></td> <td>string (email)</td> <td></td> </tr> <tr> <td><code>agency_keeps_access</code></td> <td>boolean</td> <td></td> </tr> <tr> <td><code>to_agency_project_id</code></td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>message</code></td> <td>string</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "to_agency_project_id": "5c4b3a29-1807-4f6e-8d5c-4b3a29180706", "message": "As agreed, you take over the build." }</code></pre></div><h4 id="202-the-handover-waits-for-the-receiving-side"><a class="anker" href="#202-the-handover-waits-for-the-receiving-side">202 The handover waits for the receiving side.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_relation"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>agency</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>client</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"pending"</code>, <code>"active"</code>, <code>"declined"</code>, <code>"ended"</code></td> <td></td> </tr> <tr> <td><code>stage</code> (required)</td> <td><code>"building"</code>, <code>"client_owned"</code></td> <td></td> </tr> <tr> <td><code>role</code> (required)</td> <td><code>"viewer"</code>, <code>"builder"</code>, <code>"admin"</code></td> <td></td> </tr> <tr> <td><code>max_mode</code> (required)</td> <td><code>"test"</code>, <code>"live"</code></td> <td></td> </tr> <tr> <td><code>four_eyes</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>payer</code> (required)</td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> <tr> <td><code>budget_eur</code> (required)</td> <td>number or null</td> <td></td> </tr> <tr> <td><code>payer_change</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>owner_invite</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>confirmed_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>transfer</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>ended_via</code></td> <td><code>"client"</code>, <code>"agency"</code>, <code>"admin"</code>, <code>"handover"</code>, <code>"expired"</code>, <code>"cleanup"</code></td> <td></td> </tr> <tr> <td><code>cleanup</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>transfer_relation</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>open_payments</code> (required)</td> <td>object[]</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "active", "stage": "building", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "client", "budget_eur": null, "payer_change": null, "owner_invite": null, "confirmed_at": null, "ended_at": null, "created_at": "2026-10-12T09:00:00.000Z", "transfer": { "direction": "outgoing", "agency": { "project_id": "5c4b3a29-1807-4f6e-8d5c-4b3a29180706", "name": "Studio South" }, "relation_id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918", "expires_at": "2026-10-26T09:00:00.000Z" }, "open_payments": [] }</code></pre></div><h4 id="errors-205"><a class="anker" href="#errors-205">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="delete-clients-id-transfer"><a class="anker" href="#delete-clients-id-transfer">DELETE /clients/{id}/transfer</a></h3> <p><strong>Cancel a handover.</strong></p> <p>Withdraws an open owner invitation or an open handover to another agency for a project in the building stage. canceled says how many were withdrawn.</p> <p>Scope: <code>clients:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> </tbody></table></div> <h4 id="200-the-relation-without-the-open-handover"><a class="anker" href="#200-the-relation-without-the-open-handover">200 The relation, without the open handover.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_relation"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>agency</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>client</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"pending"</code>, <code>"active"</code>, <code>"declined"</code>, <code>"ended"</code></td> <td></td> </tr> <tr> <td><code>stage</code> (required)</td> <td><code>"building"</code>, <code>"client_owned"</code></td> <td></td> </tr> <tr> <td><code>role</code> (required)</td> <td><code>"viewer"</code>, <code>"builder"</code>, <code>"admin"</code></td> <td></td> </tr> <tr> <td><code>max_mode</code> (required)</td> <td><code>"test"</code>, <code>"live"</code></td> <td></td> </tr> <tr> <td><code>four_eyes</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>payer</code> (required)</td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> <tr> <td><code>budget_eur</code> (required)</td> <td>number or null</td> <td></td> </tr> <tr> <td><code>payer_change</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>owner_invite</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>confirmed_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>transfer</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>ended_via</code></td> <td><code>"client"</code>, <code>"agency"</code>, <code>"admin"</code>, <code>"handover"</code>, <code>"expired"</code>, <code>"cleanup"</code></td> <td></td> </tr> <tr> <td><code>cleanup</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>canceled</code> (required)</td> <td>integer</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "active", "stage": "building", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "client", "budget_eur": null, "payer_change": null, "owner_invite": null, "confirmed_at": null, "ended_at": null, "created_at": "2026-10-12T09:00:00.000Z", "canceled": 1 }</code></pre></div><h4 id="errors-206"><a class="anker" href="#errors-206">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> </tbody></table></div> <h3 id="post-clients-id-restore"><a class="anker" href="#post-clients-id-restore">POST /clients/{id}/restore</a></h3> <p><strong>Restore an ended client project.</strong></p> <p>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.</p> <p>Scope: <code>clients:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> </tbody></table></div> <h4 id="200-the-new-relation"><a class="anker" href="#200-the-new-relation">200 The new relation.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_relation"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>agency</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>client</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"pending"</code>, <code>"active"</code>, <code>"declined"</code>, <code>"ended"</code></td> <td></td> </tr> <tr> <td><code>stage</code> (required)</td> <td><code>"building"</code>, <code>"client_owned"</code></td> <td></td> </tr> <tr> <td><code>role</code> (required)</td> <td><code>"viewer"</code>, <code>"builder"</code>, <code>"admin"</code></td> <td></td> </tr> <tr> <td><code>max_mode</code> (required)</td> <td><code>"test"</code>, <code>"live"</code></td> <td></td> </tr> <tr> <td><code>four_eyes</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>payer</code> (required)</td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> <tr> <td><code>budget_eur</code> (required)</td> <td>number or null</td> <td></td> </tr> <tr> <td><code>payer_change</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>owner_invite</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>confirmed_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>transfer</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>ended_via</code></td> <td><code>"client"</code>, <code>"agency"</code>, <code>"admin"</code>, <code>"handover"</code>, <code>"expired"</code>, <code>"cleanup"</code></td> <td></td> </tr> <tr> <td><code>cleanup</code></td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "active", "stage": "building", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "client", "budget_eur": null, "payer_change": null, "owner_invite": null, "confirmed_at": null, "ended_at": null, "created_at": "2026-10-12T09:00:00.000Z" }</code></pre></div><h4 id="errors-207"><a class="anker" href="#errors-207">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> </tbody></table></div> <h3 id="get-clients-id-usage"><a class="anker" href="#get-clients-id-usage">GET /clients/{id}/usage</a></h3> <p><strong>Get the usage of a client project.</strong></p> <p>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.</p> <p>Scope: <code>clients:read</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> <tr> <td><code>months</code></td> <td>query</td> <td>integer</td> <td>How many months back, the current month included (at most 12).</td> </tr> </tbody></table></div> <h4 id="200-usage-per-month"><a class="anker" href="#200-usage-per-month">200 Usage per month.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_usage"</code></td> <td></td> </tr> <tr> <td><code>project_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>currency</code> (required)</td> <td><code>"EUR"</code></td> <td></td> </tr> <tr> <td><code>payer</code></td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> <tr> <td><code>budget_eur</code></td> <td>number or null</td> <td></td> </tr> <tr> <td><code>months</code> (required)</td> <td>object[]</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_usage", "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "currency": "EUR", "payer": "agency", "budget_eur": 50, "months": [ { "month": "2026-10", "cost_eur": 4.2, "cost_by_source": { "calls": 3.9, "sms": 0.3 }, "cost_by_payer": { "client": 0, "agency": 4.2 }, "cost_by_payer_source": { "client": {}, "agency": { "calls": 3.9, "sms": 0.3 } }, "top_ups_eur": 0, "app_requests": 1830, "app_errors": 2, "app_cpu_ms": 51200 } ] }</code></pre></div><h4 id="errors-208"><a class="anker" href="#errors-208">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="put-clients-id-payer"><a class="anker" href="#put-clients-id-payer">PUT /clients/{id}/payer</a></h3> <p><strong>Change who pays for a client project.</strong></p> <p>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.</p> <p>Scope: <code>clients:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> </tbody></table></div> <h4 id="request-body-98"><a class="anker" href="#request-body-98">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>payer</code> (required)</td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "payer": "agency" }</code></pre></div><h4 id="200-the-relation-with-payerchange-while-it-waits-for-the-client"><a class="anker" href="#200-the-relation-with-payerchange-while-it-waits-for-the-client">200 The relation, with payer_change while it waits for the client.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_relation"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>agency</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>client</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"pending"</code>, <code>"active"</code>, <code>"declined"</code>, <code>"ended"</code></td> <td></td> </tr> <tr> <td><code>stage</code> (required)</td> <td><code>"building"</code>, <code>"client_owned"</code></td> <td></td> </tr> <tr> <td><code>role</code> (required)</td> <td><code>"viewer"</code>, <code>"builder"</code>, <code>"admin"</code></td> <td></td> </tr> <tr> <td><code>max_mode</code> (required)</td> <td><code>"test"</code>, <code>"live"</code></td> <td></td> </tr> <tr> <td><code>four_eyes</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>payer</code> (required)</td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> <tr> <td><code>budget_eur</code> (required)</td> <td>number or null</td> <td></td> </tr> <tr> <td><code>payer_change</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>owner_invite</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>confirmed_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>transfer</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>ended_via</code></td> <td><code>"client"</code>, <code>"agency"</code>, <code>"admin"</code>, <code>"handover"</code>, <code>"expired"</code>, <code>"cleanup"</code></td> <td></td> </tr> <tr> <td><code>cleanup</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>applied</code> (required)</td> <td>boolean</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "active", "stage": "client_owned", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "client", "budget_eur": null, "payer_change": { "to": "agency", "proposed_by": "agency", "proposed_at": "2026-10-12T09:00:00.000Z" }, "owner_invite": null, "confirmed_at": null, "ended_at": null, "created_at": "2026-10-12T09:00:00.000Z", "applied": false }</code></pre></div><h4 id="errors-209"><a class="anker" href="#errors-209">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="put-clients-id-budget"><a class="anker" href="#put-clients-id-budget">PUT /clients/{id}/budget</a></h3> <p><strong>Set the monthly budget for a client project.</strong></p> <p>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.</p> <p>Scope: <code>clients:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> </tbody></table></div> <h4 id="request-body-99"><a class="anker" href="#request-body-99">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>monthly_budget_eur</code> (required)</td> <td>number or null</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "monthly_budget_eur": 50 }</code></pre></div><h4 id="200-the-relation-with-the-budget"><a class="anker" href="#200-the-relation-with-the-budget">200 The relation with the budget.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_relation"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>agency</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>client</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"pending"</code>, <code>"active"</code>, <code>"declined"</code>, <code>"ended"</code></td> <td></td> </tr> <tr> <td><code>stage</code> (required)</td> <td><code>"building"</code>, <code>"client_owned"</code></td> <td></td> </tr> <tr> <td><code>role</code> (required)</td> <td><code>"viewer"</code>, <code>"builder"</code>, <code>"admin"</code></td> <td></td> </tr> <tr> <td><code>max_mode</code> (required)</td> <td><code>"test"</code>, <code>"live"</code></td> <td></td> </tr> <tr> <td><code>four_eyes</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>payer</code> (required)</td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> <tr> <td><code>budget_eur</code> (required)</td> <td>number or null</td> <td></td> </tr> <tr> <td><code>payer_change</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>owner_invite</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>confirmed_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>transfer</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>ended_via</code></td> <td><code>"client"</code>, <code>"agency"</code>, <code>"admin"</code>, <code>"handover"</code>, <code>"expired"</code>, <code>"cleanup"</code></td> <td></td> </tr> <tr> <td><code>cleanup</code></td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "active", "stage": "building", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "agency", "budget_eur": 50, "payer_change": null, "owner_invite": null, "confirmed_at": null, "ended_at": null, "created_at": "2026-10-12T09:00:00.000Z" }</code></pre></div><h4 id="errors-210"><a class="anker" href="#errors-210">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="get-clients-id-invoice-lines"><a class="anker" href="#get-clients-id-invoice-lines">GET /clients/{id}/invoice-lines</a></h3> <p><strong>Export invoice lines for a client project.</strong></p> <p>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.</p> <p>Scope: <code>clients:read</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> <tr> <td><code>months</code></td> <td>query</td> <td>integer</td> <td>How many months back, the current month included.</td> </tr> <tr> <td><code>payer</code></td> <td>query</td> <td><code>"agency"</code>, <code>"client"</code>, <code>"all"</code></td> <td>Only the lines your agency paid, only those the client paid, or all.</td> </tr> <tr> <td><code>format</code></td> <td>query</td> <td><code>"json"</code>, <code>"csv"</code></td> <td>json, or csv for a spreadsheet or import.</td> </tr> </tbody></table></div> <h4 id="200-the-invoice-lines"><a class="anker" href="#200-the-invoice-lines">200 The invoice lines.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"list"</code></td> <td></td> </tr> <tr> <td><code>project_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>currency</code> (required)</td> <td><code>"EUR"</code></td> <td></td> </tr> <tr> <td><code>months</code> (required)</td> <td>string[]</td> <td></td> </tr> <tr> <td><code>total_eur</code> (required)</td> <td>number</td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object[]</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "list", "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "currency": "EUR", "months": [ "2026-10" ], "total_eur": 4.2, "data": [ { "month": "2026-10", "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "project_name": "Bakery Jansen", "payer": "agency", "source": "calls", "description": "Calls, Bakery Jansen, 2026-10", "amount_eur": 3.9, "currency": "EUR" }, { "month": "2026-10", "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "project_name": "Bakery Jansen", "payer": "agency", "source": "sms", "description": "Text messages, Bakery Jansen, 2026-10", "amount_eur": 0.3, "currency": "EUR" } ] }</code></pre></div><h4 id="errors-211"><a class="anker" href="#errors-211">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="post-clients-id-payment-links"><a class="anker" href="#post-clients-id-payment-links">POST /clients/{id}/payment-links</a></h3> <p><strong>Create a payment link for a client.</strong></p> <p>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.</p> <p>Scope: <code>clients:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> </tbody></table></div> <h4 id="request-body-100"><a class="anker" href="#request-body-100">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>amount_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>currency</code></td> <td>string</td> <td></td> </tr> <tr> <td><code>description</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>redirect_url</code> (required)</td> <td>string (uri)</td> <td></td> </tr> <tr> <td><code>idempotency_key</code></td> <td>string</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "amount_cents": 12100, "currency": "EUR", "description": "Website maintenance October", "redirect_url": "https://portal.studionorth.example/paid", "idempotency_key": "inv-2026-10-bakery" }</code></pre></div><h4 id="200-the-payment-link-made-earlier-with-this-idempotencykey"><a class="anker" href="#200-the-payment-link-made-earlier-with-this-idempotencykey">200 The payment link made earlier with this idempotency_key.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_payment_link"</code>, <code>"client_subscription"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>client_project_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"open"</code>, <code>"paid"</code>, <code>"failed"</code>, <code>"expired"</code>, <code>"active"</code>, <code>"canceled"</code></td> <td></td> </tr> <tr> <td><code>amount_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>fee_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>currency</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>description</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>interval</code></td> <td><code>"month"</code>, <code>"year"</code>, <code>null</code></td> <td></td> </tr> <tr> <td><code>paid_until</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>canceled_at</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>checkout_url</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>paid_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_payment_link", "id": "0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6", "client_project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "status": "open", "amount_cents": 12100, "fee_cents": 131, "currency": "EUR", "description": "Website maintenance October", "test": true, "checkout_url": "https://checkout.example/pay/7UhSN1zuXS", "paid_at": null, "created_at": "2026-10-12T09:00:00.000Z" }</code></pre></div><h4 id="201-the-payment-link"><a class="anker" href="#201-the-payment-link">201 The payment link.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_payment_link"</code>, <code>"client_subscription"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>client_project_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"open"</code>, <code>"paid"</code>, <code>"failed"</code>, <code>"expired"</code>, <code>"active"</code>, <code>"canceled"</code></td> <td></td> </tr> <tr> <td><code>amount_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>fee_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>currency</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>description</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>interval</code></td> <td><code>"month"</code>, <code>"year"</code>, <code>null</code></td> <td></td> </tr> <tr> <td><code>paid_until</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>canceled_at</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>checkout_url</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>paid_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_payment_link", "id": "0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6", "client_project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "status": "open", "amount_cents": 12100, "fee_cents": 131, "currency": "EUR", "description": "Website maintenance October", "test": true, "checkout_url": "https://checkout.example/pay/7UhSN1zuXS", "paid_at": null, "created_at": "2026-10-12T09:00:00.000Z" }</code></pre></div><h4 id="errors-212"><a class="anker" href="#errors-212">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> <tr> <td>502</td> <td><a href="/docs/errors/upstream_error"><code>upstream_error</code></a></td> </tr> </tbody></table></div> <h3 id="post-clients-id-subscriptions"><a class="anker" href="#post-clients-id-subscriptions">POST /clients/{id}/subscriptions</a></h3> <p><strong>Create a subscription for a client.</strong></p> <p>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.</p> <p>Scope: <code>clients:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> </tbody></table></div> <h4 id="request-body-101"><a class="anker" href="#request-body-101">Request body</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>amount_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>currency</code></td> <td>string</td> <td></td> </tr> <tr> <td><code>description</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>redirect_url</code> (required)</td> <td>string (uri)</td> <td></td> </tr> <tr> <td><code>idempotency_key</code></td> <td>string</td> <td></td> </tr> <tr> <td><code>interval</code> (required)</td> <td><code>"month"</code>, <code>"year"</code></td> <td></td> </tr> <tr> <td><code>email</code> (required)</td> <td>string (email)</td> <td></td> </tr> <tr> <td><code>name</code></td> <td>string</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "amount_cents": 4900, "currency": "EUR", "interval": "month", "description": "Hosting and support", "email": "owner@example.org", "name": "Bakery Jansen", "redirect_url": "https://portal.studionorth.example/subscribed" }</code></pre></div><h4 id="200-the-subscription-made-earlier-with-this-idempotencykey"><a class="anker" href="#200-the-subscription-made-earlier-with-this-idempotencykey">200 The subscription made earlier with this idempotency_key.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_payment_link"</code>, <code>"client_subscription"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>client_project_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"open"</code>, <code>"paid"</code>, <code>"failed"</code>, <code>"expired"</code>, <code>"active"</code>, <code>"canceled"</code></td> <td></td> </tr> <tr> <td><code>amount_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>fee_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>currency</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>description</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>interval</code></td> <td><code>"month"</code>, <code>"year"</code>, <code>null</code></td> <td></td> </tr> <tr> <td><code>paid_until</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>canceled_at</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>checkout_url</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>paid_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_subscription", "id": "0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6", "client_project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "status": "open", "amount_cents": 12100, "fee_cents": 131, "currency": "EUR", "description": "Website maintenance October", "test": true, "checkout_url": "https://checkout.example/pay/7UhSN1zuXS", "paid_at": null, "created_at": "2026-10-12T09:00:00.000Z", "interval": "month", "paid_until": null, "canceled_at": null }</code></pre></div><h4 id="201-the-subscription-open-until-the-first-payment"><a class="anker" href="#201-the-subscription-open-until-the-first-payment">201 The subscription, open until the first payment.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_payment_link"</code>, <code>"client_subscription"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>client_project_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"open"</code>, <code>"paid"</code>, <code>"failed"</code>, <code>"expired"</code>, <code>"active"</code>, <code>"canceled"</code></td> <td></td> </tr> <tr> <td><code>amount_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>fee_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>currency</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>description</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>interval</code></td> <td><code>"month"</code>, <code>"year"</code>, <code>null</code></td> <td></td> </tr> <tr> <td><code>paid_until</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>canceled_at</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>checkout_url</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>paid_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_subscription", "id": "0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6", "client_project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "status": "open", "amount_cents": 4900, "fee_cents": 59, "currency": "EUR", "description": "Hosting and support", "test": true, "checkout_url": "https://checkout.example/pay/7UhSN1zuXS", "paid_at": null, "created_at": "2026-10-12T09:00:00.000Z", "interval": "month", "paid_until": null, "canceled_at": null }</code></pre></div><h4 id="errors-213"><a class="anker" href="#errors-213">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>400</td> <td><a href="/docs/errors/invalid_json"><code>invalid_json</code></a></td> </tr> <tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> <tr> <td>502</td> <td><a href="/docs/errors/upstream_error"><code>upstream_error</code></a></td> </tr> </tbody></table></div> <h3 id="get-clients-id-payments"><a class="anker" href="#get-clients-id-payments">GET /clients/{id}/payments</a></h3> <p><strong>List payment links and subscriptions for a client.</strong></p> <p>The payment links and subscriptions your agency made for this client project, newest first. Without the payer's email or name.</p> <p>Scope: <code>clients:read</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> <tr> <td><code>limit</code></td> <td>query</td> <td>integer</td> <td>How many.</td> </tr> </tbody></table></div> <h4 id="200-the-payments"><a class="anker" href="#200-the-payments">200 The payments.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"list"</code></td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object[]</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "list", "data": [ { "object": "client_payment_link", "id": "0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6", "client_project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "status": "open", "amount_cents": 12100, "fee_cents": 131, "currency": "EUR", "description": "Website maintenance October", "test": true, "checkout_url": "https://checkout.example/pay/7UhSN1zuXS", "paid_at": null, "created_at": "2026-10-12T09:00:00.000Z" } ] }</code></pre></div><h4 id="errors-214"><a class="anker" href="#errors-214">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="delete-clients-id-subscriptions-subscriptionid"><a class="anker" href="#delete-clients-id-subscriptions-subscriptionid">DELETE /clients/{id}/subscriptions/{subscriptionId}</a></h3> <p><strong>Cancel a client subscription.</strong></p> <p>Stops a subscription at your payment provider right away: no more charges. What was paid stays paid; nothing is refunded.</p> <p>Scope: <code>clients:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> <tr> <td><code>subscriptionId</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the subscription.</td> </tr> </tbody></table></div> <h4 id="200-the-canceled-subscription"><a class="anker" href="#200-the-canceled-subscription">200 The canceled subscription.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_payment_link"</code>, <code>"client_subscription"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>client_project_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"open"</code>, <code>"paid"</code>, <code>"failed"</code>, <code>"expired"</code>, <code>"active"</code>, <code>"canceled"</code></td> <td></td> </tr> <tr> <td><code>amount_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>fee_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>currency</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>description</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>interval</code></td> <td><code>"month"</code>, <code>"year"</code>, <code>null</code></td> <td></td> </tr> <tr> <td><code>paid_until</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>canceled_at</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>checkout_url</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>paid_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_subscription", "id": "0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6", "client_project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "status": "canceled", "amount_cents": 12100, "fee_cents": 131, "currency": "EUR", "description": "Website maintenance October", "test": true, "checkout_url": null, "paid_at": null, "created_at": "2026-10-12T09:00:00.000Z", "interval": "month", "paid_until": "2026-11-12T09:00:00.000Z", "canceled_at": "2026-10-20T09:00:00.000Z" }</code></pre></div><h4 id="errors-215"><a class="anker" href="#errors-215">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>409</td> <td><a href="/docs/errors/conflict"><code>conflict</code></a></td> </tr> </tbody></table></div> <h3 id="delete-clients-id-payment-links-linkid"><a class="anker" href="#delete-clients-id-payment-links-linkid">DELETE /clients/{id}/payment-links/{linkId}</a></h3> <p><strong>Withdraw an open payment link.</strong></p> <p>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.</p> <p>Scope: <code>clients:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> <tr> <td><code>linkId</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the payment link.</td> </tr> </tbody></table></div> <h4 id="200-the-withdrawn-payment-link"><a class="anker" href="#200-the-withdrawn-payment-link">200 The withdrawn payment link.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_payment_link"</code>, <code>"client_subscription"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>client_project_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"open"</code>, <code>"paid"</code>, <code>"failed"</code>, <code>"expired"</code>, <code>"active"</code>, <code>"canceled"</code></td> <td></td> </tr> <tr> <td><code>amount_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>fee_cents</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>currency</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>description</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>interval</code></td> <td><code>"month"</code>, <code>"year"</code>, <code>null</code></td> <td></td> </tr> <tr> <td><code>paid_until</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>canceled_at</code></td> <td>string or null</td> <td></td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>checkout_url</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>paid_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_payment_link", "id": "0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6", "client_project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "status": "expired", "amount_cents": 12100, "fee_cents": 131, "currency": "EUR", "description": "Website maintenance October", "test": true, "checkout_url": null, "paid_at": null, "created_at": "2026-10-12T09:00:00.000Z" }</code></pre></div><h4 id="errors-216"><a class="anker" href="#errors-216">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> </tbody></table></div> <h3 id="get-clients-id-activity"><a class="anker" href="#get-clients-id-activity">GET /clients/{id}/activity</a></h3> <p><strong>Read the activity log of a client project.</strong></p> <p>The activity log of a client project, newest first. Actions by your agency carry details.via_bureau. For the next page, pass next_before as before.</p> <p>Scope: <code>clients:read</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the client project.</td> </tr> <tr> <td><code>limit</code></td> <td>query</td> <td>integer</td> <td>How many entries.</td> </tr> <tr> <td><code>before</code></td> <td>query</td> <td>string</td> <td>next_before from the previous page.</td> </tr> </tbody></table></div> <h4 id="200-the-log-entries"><a class="anker" href="#200-the-log-entries">200 The log entries.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"list"</code></td> <td></td> </tr> <tr> <td><code>project_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object[]</td> <td></td> </tr> <tr> <td><code>next_before</code> (required)</td> <td>string or null</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "list", "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "data": [], "next_before": null }</code></pre></div><h4 id="errors-217"><a class="anker" href="#errors-217">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> <tr> <td>422</td> <td><a href="/docs/errors/validation_failed"><code>validation_failed</code></a></td> </tr> </tbody></table></div> <h3 id="get-agencies"><a class="anker" href="#get-agencies">GET /agencies</a></h3> <p><strong>List the agencies on this project.</strong></p> <p>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.</p> <p>Scope: <code>project:read</code></p> <h4 id="200-the-agency-relations-of-this-project"><a class="anker" href="#200-the-agency-relations-of-this-project">200 The agency relations of this project.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"list"</code></td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object[]</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "list", "data": [ { "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "active", "stage": "client_owned", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "client", "budget_eur": null, "payer_change": null, "owner_invite": null, "confirmed_at": null, "ended_at": null, "created_at": "2026-10-12T09:00:00.000Z" } ] }</code></pre></div><h3 id="delete-agencies-id"><a class="anker" href="#delete-agencies-id">DELETE /agencies/{id}</a></h3> <p><strong>Revoke an agency's access.</strong></p> <p>For the owner of a client project: ends an agency's access right away, or declines its open request. What the agency built stays.</p> <p>Scope: <code>project:write</code></p> <div class="tabel"><table> <thead> <tr> <th>Parameter</th> <th>In</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>path</td> <td>string (uuid)</td> <td>The id of the agency relation.</td> </tr> </tbody></table></div> <h4 id="200-the-ended-relation-2"><a class="anker" href="#200-the-ended-relation-2">200 The ended relation.</a></h4> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"client_relation"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>agency</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>client</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"pending"</code>, <code>"active"</code>, <code>"declined"</code>, <code>"ended"</code></td> <td></td> </tr> <tr> <td><code>stage</code> (required)</td> <td><code>"building"</code>, <code>"client_owned"</code></td> <td></td> </tr> <tr> <td><code>role</code> (required)</td> <td><code>"viewer"</code>, <code>"builder"</code>, <code>"admin"</code></td> <td></td> </tr> <tr> <td><code>max_mode</code> (required)</td> <td><code>"test"</code>, <code>"live"</code></td> <td></td> </tr> <tr> <td><code>four_eyes</code> (required)</td> <td>boolean</td> <td></td> </tr> <tr> <td><code>payer</code> (required)</td> <td><code>"client"</code>, <code>"agency"</code></td> <td></td> </tr> <tr> <td><code>budget_eur</code> (required)</td> <td>number or null</td> <td></td> </tr> <tr> <td><code>payer_change</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>owner_invite</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>confirmed_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>transfer</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>ended_via</code></td> <td><code>"client"</code>, <code>"agency"</code>, <code>"admin"</code>, <code>"handover"</code>, <code>"expired"</code>, <code>"cleanup"</code></td> <td></td> </tr> <tr> <td><code>cleanup</code></td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "object": "client_relation", "id": "6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716", "agency": { "project_id": "3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b", "name": "Studio North" }, "client": { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Bakery Jansen" }, "status": "ended", "stage": "client_owned", "role": "builder", "max_mode": "test", "four_eyes": false, "payer": "client", "budget_eur": null, "payer_change": null, "owner_invite": null, "confirmed_at": null, "ended_at": "2026-10-20T09:00:00.000Z", "created_at": "2026-10-12T09:00:00.000Z" }</code></pre></div><h4 id="errors-218"><a class="anker" href="#errors-218">Errors</a></h4> <p>Besides the <a href="#errors-on-every-endpoint">errors on every endpoint</a>:</p> <div class="tabel"><table> <thead> <tr> <th>Status</th> <th>Type</th> </tr> </thead> <tbody><tr> <td>403</td> <td><a href="/docs/errors/forbidden"><code>forbidden</code></a></td> </tr> <tr> <td>404</td> <td><a href="/docs/errors/not_found"><code>not_found</code></a></td> </tr> </tbody></table></div> <h2 id="webhook-events"><a class="anker" href="#webhook-events">Webhook events</a></h2> <p>What we <code>POST</code> to your endpoint. See <a href="/docs/webhooks">Webhooks</a> for signatures, retries and endpoints.</p> <h3 id="call-completed"><a class="anker" href="#call-completed">call.completed</a></h3> <p><strong>A call ended.</strong></p> <p>Sent after a call ends.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"call.completed"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3", "created_at": "2026-10-01T09:15:38.000Z", "type": "call.completed", "data": { "call": { "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 } } }</code></pre></div><h3 id="call-summary-ready"><a class="anker" href="#call-summary-ready">call.summary.ready</a></h3> <p><strong>The summary of a call is ready.</strong></p> <p>Sent when the summary of a call is ready. Without the transcript: fetch that with GET /calls/{data.call.id}.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"call.summary.ready"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3", "created_at": "2026-10-01T09:15:38.000Z", "type": "call.summary.ready", "data": { "call": { "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 }, "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." } } }</code></pre></div><h3 id="message-delivered"><a class="anker" href="#message-delivered">message.delivered</a></h3> <p><strong>An SMS was delivered.</strong></p> <p>Sent when the phone of the recipient confirmed the message.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"message.delivered"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-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 } } }</code></pre></div><h3 id="message-failed"><a class="anker" href="#message-failed">message.failed</a></h3> <p><strong>An SMS could not be delivered.</strong></p> <p>Sent when a message could not be delivered. data.message.error says why.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"message.failed"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-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 } } }</code></pre></div><h3 id="conversation-summary-ready"><a class="anker" href="#conversation-summary-ready">conversation.summary.ready</a></h3> <p><strong>The summary of a chat conversation is ready.</strong></p> <p>Sent when the summary of a closed chat conversation is ready. Without the messages: fetch those with GET /conversations/{data.conversation.id}.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"conversation.summary.ready"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-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." } } }</code></pre></div><h3 id="balance-low"><a class="anker" href="#balance-low">balance.low</a></h3> <p><strong>The balance dropped below the top-up threshold.</strong></p> <p>Sent when a charge takes the balance below the automatic top-up threshold. Carries balance_eur and threshold_eur.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"balance.low"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3", "created_at": "2026-10-01T11:00:01.000Z", "type": "balance.low", "data": { "balance_eur": 4.88, "threshold_eur": 5 } }</code></pre></div><h3 id="balance-toppedup"><a class="anker" href="#balance-toppedup">balance.topped_up</a></h3> <p><strong>A top-up was added to the balance.</strong></p> <p>Sent when a top-up was paid and added to the balance, by hand or automatically. Carries balance_eur and transaction.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"balance.topped_up"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3", "created_at": "2026-10-01T11:00:01.000Z", "type": "balance.topped_up", "data": { "balance_eur": 24.76, "transaction": { "id": "4c3b2a19-0f8e-4d7c-9b6a-5e4d3c2b1a09", "type": "top_up", "source": "payment", "amount_eur": 20, "balance_after_eur": 24.76, "description": "Top-up", "quantity": null, "unit": null, "reference_id": "b7e5a1c4-2d93-4f60-8a1e-9c3d5b7f2e10", "created_at": "2026-10-01T09:30:00.000Z" } } }</code></pre></div><h3 id="balance-reloadfailed"><a class="anker" href="#balance-reloadfailed">balance.reload_failed</a></h3> <p><strong>An automatic top-up failed.</strong></p> <p>Sent when an automatic top-up failed. Carries balance_eur, threshold_eur and reload. After three failures in a row automatic top-up is switched off.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"balance.reload_failed"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3", "created_at": "2026-10-01T11:00:01.000Z", "type": "balance.reload_failed", "data": { "balance_eur": 4.88, "threshold_eur": 5, "reload": { "id": "d2f4a6b8-1c3e-4a5b-9d7f-0e1a3c5b7d92", "amount_eur": 20, "status": "failed", "failure_reason": "insufficient_funds", "created_at": "2026-10-01T11:00:00.000Z" } } }</code></pre></div><h3 id="document-created"><a class="anker" href="#document-created">document.created</a></h3> <p><strong>A document was added to a collection.</strong></p> <p>Sent for every new document in a collection with webhook_on_insert, from the API or a form. A document deleted before delivery is not sent.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"document.created"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3", "created_at": "2026-10-01T09:30:01.000Z", "type": "document.created", "data": { "collection": { "id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f", "name": "contact" }, "document": { "id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918", "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" } } }</code></pre></div><h3 id="email-sent"><a class="anker" href="#email-sent">email.sent</a></h3> <p><strong>An email was accepted for delivery (one event per recipient).</strong></p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The event id, evt_ plus 32 hex characters. The same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"email.sent"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "evt_3b2f6c1d9e8a4f7b8c6d5e4f3a2b1c0d", "type": "email.sent", "created_at": "2026-10-15T09:30:05.000Z", "data": { "email_id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d", "recipient": "sam@example.org", "recipient_field": "to", "status": "sent", "reason": null, "occurred_at": "2026-10-15T09:30:04.000Z", "test": false } }</code></pre></div><h3 id="email-delivered"><a class="anker" href="#email-delivered">email.delivered</a></h3> <p><strong>An email reached the recipient's mail server.</strong></p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The event id, evt_ plus 32 hex characters. The same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"email.delivered"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "evt_3b2f6c1d9e8a4f7b8c6d5e4f3a2b1c0d", "type": "email.delivered", "created_at": "2026-10-15T09:30:05.000Z", "data": { "email_id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d", "recipient": "sam@example.org", "recipient_field": "to", "status": "delivered", "reason": null, "occurred_at": "2026-10-15T09:30:04.000Z", "test": false } }</code></pre></div><h3 id="email-delayed"><a class="anker" href="#email-delayed">email.delayed</a></h3> <p><strong>Delivery of an email is delayed; it is retried automatically.</strong></p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The event id, evt_ plus 32 hex characters. The same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"email.delayed"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "evt_3b2f6c1d9e8a4f7b8c6d5e4f3a2b1c0d", "type": "email.delayed", "created_at": "2026-10-15T09:30:05.000Z", "data": { "email_id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d", "recipient": "sam@example.org", "recipient_field": "to", "status": "sent", "reason": "Delayed: the recipient's mail server has not accepted it yet. Delivery is retried automatically.", "occurred_at": "2026-10-15T09:30:04.000Z", "test": false } }</code></pre></div><h3 id="email-bounced"><a class="anker" href="#email-bounced">email.bounced</a></h3> <p><strong>An email bounced.</strong></p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The event id, evt_ plus 32 hex characters. The same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"email.bounced"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "evt_3b2f6c1d9e8a4f7b8c6d5e4f3a2b1c0d", "type": "email.bounced", "created_at": "2026-10-15T09:30:05.000Z", "data": { "email_id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d", "recipient": "sam@example.org", "recipient_field": "to", "status": "bounced", "reason": "The address does not exist or does not accept email.", "occurred_at": "2026-10-15T09:30:04.000Z", "test": false } }</code></pre></div><h3 id="email-complained"><a class="anker" href="#email-complained">email.complained</a></h3> <p><strong>The recipient marked an email as spam.</strong></p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The event id, evt_ plus 32 hex characters. The same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"email.complained"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "evt_3b2f6c1d9e8a4f7b8c6d5e4f3a2b1c0d", "type": "email.complained", "created_at": "2026-10-15T09:30:05.000Z", "data": { "email_id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d", "recipient": "sam@example.org", "recipient_field": "to", "status": "complained", "reason": "The recipient marked it as spam.", "occurred_at": "2026-10-15T09:30:04.000Z", "test": false } }</code></pre></div><h3 id="webhook-test"><a class="anker" href="#webhook-test">webhook.test</a></h3> <p><strong>A test event from POST /webhooks/{id}/test.</strong></p> <p>Sent by POST /webhooks/{id}/test. Needs no subscription.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"webhook.test"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <div class="code"><div class="code-kop"><span class="code-taal">JSON</span><button type="button" class="kopieer" aria-label="Copy code">Copy</button></div><pre tabindex="0"><code class="taal-json">{ "id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3", "created_at": "2026-10-01T09:15:38.000Z", "type": "webhook.test", "data": { "endpoint_id": "9e8d7c6b-5a49-4382-9716-a5b4c3d2e1f0" } }</code></pre></div><h2 id="objects"><a class="anker" href="#objects">Objects</a></h2> <h3 id="object-mcprequest"><a class="anker" href="#object-mcprequest">McpRequest</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>jsonrpc</code> (required)</td> <td><code>"2.0"</code></td> <td></td> </tr> <tr> <td><code>id</code></td> <td>one of</td> <td></td> </tr> <tr> <td><code>method</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>params</code></td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-mcpresponse"><a class="anker" href="#object-mcpresponse">McpResponse</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>jsonrpc</code> (required)</td> <td><code>"2.0"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>one of</td> <td></td> </tr> <tr> <td><code>result</code></td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-project"><a class="anker" href="#object-project">Project</a></h3> <p>The project behind the API key.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the project.</td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td>The name of the project. The assistant uses it in its greeting.</td> </tr> <tr> <td><code>edition</code> (required)</td> <td>string</td> <td>Always api for a project of the Hypamoon API.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the project was created.</td> </tr> <tr> <td><code>spending_limit_eur</code></td> <td>number or null</td> <td>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.</td> </tr> <tr> <td><code>numbers_count</code></td> <td>integer</td> <td>How many numbers the project has. Left out for a limited key or connection.</td> </tr> <tr> <td><code>mode</code> (required)</td> <td><code>"live"</code>, <code>"test"</code></td> <td>live or test: the mode of the key, which is always the mode of its project.</td> </tr> <tr> <td><code>live_project_id</code></td> <td>string or null (uuid)</td> <td>Only in test mode: the live project this test project belongs to, or the project itself when it has no separate test project.</td> </tr> <tr> <td><code>rails</code></td> <td>object</td> <td>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.</td> </tr> <tr> <td><code>verification</code></td> <td>object</td> <td>The verification (KYC) of the project. In test mode: the verification of the live project.</td> </tr> <tr> <td><code>access</code> (required)</td> <td>object or null</td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-advice"><a class="anker" href="#object-advice">Advice</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>type</code> (required)</td> <td><code>"webshop"</code>, <code>"bookings"</code>, <code>"restaurant"</code>, <code>"course_membership"</code>, <code>"wordpress_portal"</code></td> <td></td> </tr> <tr> <td><code>title</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>summary</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>country</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>region</code> (required)</td> <td><code>"eu"</code>, <code>"uk"</code>, <code>"us"</code>, <code>"other"</code></td> <td>eu (detailed), uk and us (basics), other (a general fallback).</td> </tr> <tr> <td><code>audience</code> (required)</td> <td><code>"consumers"</code>, <code>"businesses"</code>, <code>"both"</code></td> <td></td> </tr> <tr> <td><code>how_to_use</code> (required)</td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>ask_first</code> (required)</td> <td>object[]</td> <td></td> </tr> <tr> <td><code>build_plan</code> (required)</td> <td>object[]</td> <td></td> </tr> <tr> <td><code>go_live_checklist</code> (required)</td> <td>object[]</td> <td></td> </tr> <tr> <td><code>pitfalls</code> (required)</td> <td>object[]</td> <td></td> </tr> <tr> <td><code>structured_data</code> (required)</td> <td>object</td> <td>Structured data for search engines, ready to fill in and add with deploy_site.</td> </tr> <tr> <td><code>note</code> (required)</td> <td>string</td> <td>Guidance, not legal or tax advice.</td> </tr> <tr> <td><code>other_types</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>known_problems</code> (required)</td> <td>object[]</td> <td>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=<id> returns one.</td> </tr> <tr> <td><code>summary_markdown</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <h3 id="object-status"><a class="anker" href="#object-status">Status</a></h3> <p>Whether Hypamoon is working, per component.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>status</code> (required)</td> <td><code>"operational"</code>, <code>"degraded"</code>, <code>"down"</code>, <code>"unknown"</code></td> <td>The overall status: the worst of the components (unknown does not count).</td> </tr> <tr> <td><code>source</code> (required)</td> <td><code>"status_page"</code>, <code>"self_check"</code></td> <td>status_page: from status.hypamoon.com. self_check: that page did not answer, so only a minimal check of our own.</td> </tr> <tr> <td><code>components</code> (required)</td> <td>object[]</td> <td>Per component: sites, api, portal, phone, ai, mail, payments.</td> </tr> <tr> <td><code>incidents</code> (required)</td> <td>object[]</td> <td>Reported problems that are not resolved yet.</td> </tr> <tr> <td><code>status_page</code> (required)</td> <td>string</td> <td>The public status page.</td> </tr> <tr> <td><code>checked_at</code> (required)</td> <td>string</td> <td>When the status was measured.</td> </tr> <tr> <td><code>user_message</code> (required)</td> <td>string</td> <td>One calm sentence for the user, like "Hypamoon is working normally. The problem is likely in your chat app."</td> </tr> <tr> <td><code>next_step</code> (required)</td> <td>string</td> <td>What the chat or agent should do next.</td> </tr> </tbody></table></div> <h3 id="object-movetoliveinput"><a class="anker" href="#object-movetoliveinput">MoveToLiveInput</a></h3> <p>Move sites from the test project to the live project.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>sites</code></td> <td>string[]</td> <td>Optional. The sites to move, by id or slug. Leave it out to move every site of the test project.</td> </tr> <tr> <td><code>collections</code></td> <td>string[]</td> <td>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).</td> </tr> <tr> <td><code>shared_collections</code></td> <td><code>"refuse"</code>, <code>"copy"</code></td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-movetoliveundoinput"><a class="anker" href="#object-movetoliveundoinput">MoveToLiveUndoInput</a></h3> <p>Undo a move to live within 24 hours.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>move_id</code></td> <td>string (uuid)</td> <td>Optional. The id from move_to_live. Leave it out to undo the latest move of this project.</td> </tr> </tbody></table></div> <h3 id="object-movetolive"><a class="anker" href="#object-movetolive">MoveToLive</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of this move, for undo_move_to_live.</td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"done"</code>, <code>"partly_done"</code></td> <td>partly_done: some steps after the move did not finish; see errors. Support is notified.</td> </tr> <tr> <td><code>from_project</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>to_project</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>moved</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>stayed_in_test</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>warnings</code> (required)</td> <td>string[]</td> <td></td> </tr> <tr> <td><code>errors</code> (required)</td> <td>string[]</td> <td></td> </tr> <tr> <td><code>undo</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>connection</code> (required)</td> <td>object or null</td> <td>For a chat connected through OAuth: which project the connection points to now.</td> </tr> <tr> <td><code>summary</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>next_step</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <h3 id="object-movetoliveundo"><a class="anker" href="#object-movetoliveundo">MoveToLiveUndo</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"undone"</code>, <code>"partly_undone"</code></td> <td></td> </tr> <tr> <td><code>sites</code> (required)</td> <td>string[]</td> <td></td> </tr> <tr> <td><code>collections</code> (required)</td> <td>number</td> <td></td> </tr> <tr> <td><code>apps</code> (required)</td> <td>number</td> <td></td> </tr> <tr> <td><code>agents</code> (required)</td> <td>number</td> <td></td> </tr> <tr> <td><code>domains</code> (required)</td> <td>number</td> <td></td> </tr> <tr> <td><code>errors</code> (required)</td> <td>string[]</td> <td></td> </tr> <tr> <td><code>summary</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>next_step</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <h3 id="object-sitereadiness"><a class="anker" href="#object-sitereadiness">SiteReadiness</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>site</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"webshop"</code>, <code>"bookings"</code>, <code>"restaurant"</code>, <code>"course_membership"</code>, <code>"wordpress_portal"</code>, <code>null</code></td> <td></td> </tr> <tr> <td><code>type_source</code> (required)</td> <td><code>"given"</code>, <code>"detected"</code>, <code>"none"</code></td> <td></td> </tr> <tr> <td><code>region</code> (required)</td> <td><code>"eu"</code>, <code>"uk"</code>, <code>"us"</code>, <code>"other"</code></td> <td></td> </tr> <tr> <td><code>country</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>works</code> (required)</td> <td>object[]</td> <td></td> </tr> <tr> <td><code>missing</code> (required)</td> <td>object[]</td> <td></td> </tr> <tr> <td><code>check_with_owner</code> (required)</td> <td>object[]</td> <td>What cannot be checked on the site; ask the owner, never require an answer.</td> </tr> <tr> <td><code>pages_checked</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>note</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>summary_markdown</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <h3 id="object-searchconsoleverifyinput"><a class="anker" href="#object-searchconsoleverifyinput">SearchConsoleVerifyInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>site_id</code></td> <td>string (uuid)</td> <td>The site to verify. For method meta this is the site that gets the tag; for dns the domain connected to this site is used.</td> </tr> <tr> <td><code>domain</code></td> <td>string</td> <td>A domain connected in this project (connect_domain), like example.com. For method dns; for meta, the site on this domain.</td> </tr> <tr> <td><code>method</code> (required)</td> <td><code>"dns"</code>, <code>"meta"</code></td> <td>dns: a TXT record on the connected domain (a domain property in Search Console). meta: a meta tag on the home page (property type URL prefix).</td> </tr> <tr> <td><code>token</code> (required)</td> <td>string</td> <td>The verification token from Search Console: the value alone, google-site-verification=..., or the whole meta tag.</td> </tr> </tbody></table></div> <h3 id="object-searchconsoleverification"><a class="anker" href="#object-searchconsoleverification">SearchConsoleVerification</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>method</code> (required)</td> <td><code>"dns"</code>, <code>"meta"</code></td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"added"</code>, <code>"already_present"</code>, <code>"waiting_for_approval"</code></td> <td>added: done now. already_present: it was there already. waiting_for_approval: the project needs a second person to approve the new version first.</td> </tr> <tr> <td><code>site_id</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>domain</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>property</code> (required)</td> <td>string</td> <td>What to enter in Search Console: sc-domain:<domain> for a domain property, or the address for a URL prefix property.</td> </tr> <tr> <td><code>record</code></td> <td>object</td> <td></td> </tr> <tr> <td><code>version</code></td> <td>integer or null</td> <td>The new site version with the tag (meta), when versions are on.</td> </tr> <tr> <td><code>sitemap</code> (required)</td> <td>string</td> <td>The sitemap to submit in Search Console under Sitemaps; the site serves it automatically.</td> </tr> <tr> <td><code>next_steps</code> (required)</td> <td>string[]</td> <td></td> </tr> </tbody></table></div> <h3 id="object-sitestats"><a class="anker" href="#object-sitestats">SiteStats</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>site_id</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>slug</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>days</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>from</code> (required)</td> <td>string</td> <td>The first day (UTC) in the range.</td> </tr> <tr> <td><code>to</code> (required)</td> <td>string</td> <td>The last day (UTC), today.</td> </tr> <tr> <td><code>totals</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>per_day</code> (required)</td> <td>object[]</td> <td></td> </tr> <tr> <td><code>top_pages</code> (required)</td> <td>object[]</td> <td>Up to 10 paths with the most page views. Paths are recorded since 9 October 2026.</td> </tr> <tr> <td><code>origins</code> (required)</td> <td>object[]</td> <td>Where page views came from: direct (no referrer), internal (from the site itself), search, social, email, shortener or external (another site).</td> </tr> <tr> <td><code>note</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <h3 id="object-sitepage"><a class="anker" href="#object-sitepage">SitePage</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>url</code> (required)</td> <td>string</td> <td>The address that was asked for.</td> </tr> <tr> <td><code>final_url</code> (required)</td> <td>string</td> <td>The address that answered, after the redirects that were followed.</td> </tr> <tr> <td><code>method</code> (required)</td> <td><code>"GET"</code>, <code>"HEAD"</code></td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td>integer</td> <td>The HTTP status of the final answer.</td> </tr> <tr> <td><code>ok</code> (required)</td> <td>boolean</td> <td>true for a 2xx status.</td> </tr> <tr> <td><code>headers</code> (required)</td> <td>object</td> <td>content-type, cache-control, location, etag, x-* and a few more. Never set-cookie.</td> </tr> <tr> <td><code>redirects</code> (required)</td> <td>object[]</td> <td>The redirects that were followed, in order.</td> </tr> <tr> <td><code>redirect_blocked</code></td> <td>string</td> <td>Set when a redirect was not followed (another host, not https, or too many); status and headers are then of that redirect.</td> </tr> <tr> <td><code>content_type</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>body</code> (required)</td> <td>string or null</td> <td>The body as text, at most 20 KB; null for HEAD, an empty body or a body that is not text.</td> </tr> <tr> <td><code>body_bytes</code> (required)</td> <td>integer</td> <td>Bytes received (reading stops after 256 KB).</td> </tr> <tr> <td><code>truncated</code> (required)</td> <td>boolean</td> <td>true when the body is longer than what is returned.</td> </tr> <tr> <td><code>body_note</code></td> <td>string</td> <td>Why the body is not shown, when it is not.</td> </tr> <tr> <td><code>elapsed_ms</code> (required)</td> <td>integer</td> <td></td> </tr> </tbody></table></div> <h3 id="object-companyinput"><a class="anker" href="#object-companyinput">CompanyInput</a></h3> <p>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.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>account_type</code></td> <td><code>"personal"</code>, <code>"business"</code></td> <td>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.</td> </tr> <tr> <td><code>company_name</code></td> <td>string or null</td> <td>The legal name. Required for a business, not allowed for a personal account.</td> </tr> <tr> <td><code>kvk_number</code></td> <td>string or null</td> <td>For a company in the Netherlands (required there): the KvK number, 8 digits. Spaces and dots are removed.</td> </tr> <tr> <td><code>registration_number</code></td> <td>string or null</td> <td>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.</td> </tr> <tr> <td><code>vat_number</code></td> <td>string or null</td> <td>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.</td> </tr> <tr> <td><code>address</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>contact</code> (required)</td> <td>object</td> <td>A person the regulator can reach today. For a personal account: the user.</td> </tr> <tr> <td><code>registrar_contact_name</code></td> <td>string or null</td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-company"><a class="anker" href="#object-company">Company</a></h3> <p>The company details of the project (or, for a personal account, your own details), as saved.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>account_type</code> (required)</td> <td><code>"personal"</code>, <code>"business"</code></td> <td>personal (your own details, no business register number) or business.</td> </tr> <tr> <td><code>company_name</code> (required)</td> <td>string or null</td> <td>The legal company name; null for a personal account.</td> </tr> <tr> <td><code>kvk_number</code> (required)</td> <td>string or null</td> <td>8 digits, for a company in the Netherlands; null elsewhere.</td> </tr> <tr> <td><code>registration_number</code> (required)</td> <td>string or null</td> <td>The number in the business register of the company's country, for a company outside the Netherlands (there it is kvk_number).</td> </tr> <tr> <td><code>vat_number</code> (required)</td> <td>string or null</td> <td>The VAT number, with country prefix in the EU (like DE123456789).</td> </tr> <tr> <td><code>vat_status</code> (required)</td> <td><code>"valid"</code>, <code>"invalid"</code>, <code>"unverified"</code>, <code>"missing"</code>, <code>"not_required"</code></td> <td>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.</td> </tr> <tr> <td><code>vat_checked_at</code> (required)</td> <td>string or null</td> <td>When VIES last checked vat_number, ISO 8601.</td> </tr> <tr> <td><code>address</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>contact</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>registrar_contact_name</code></td> <td>string or null</td> <td>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).</td> </tr> <tr> <td><code>complete</code> (required)</td> <td>boolean</td> <td>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.</td> </tr> <tr> <td><code>domain_contacts</code></td> <td>object[]</td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-number"><a class="anker" href="#object-number">Number</a></h3> <p>A phone number of the project.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the number. Use it for the call plan.</td> </tr> <tr> <td><code>e164</code> (required)</td> <td>string</td> <td>The number in E.164, like +447700900123.</td> </tr> <tr> <td><code>type</code> (required)</td> <td>string</td> <td>national (non-geographic), geographic (with an area code), mobile or tollfree.</td> </tr> <tr> <td><code>status</code> (required)</td> <td>string</td> <td>pending (the registration is with the carrier, usually active within one business day), active or porting.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the number was added to the project.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true for a test number (test mode), from the range +447700900000 to +447700900999. It can never be called for real.</td> </tr> </tbody></table></div> <h3 id="object-orderednumber"><a class="anker" href="#object-orderednumber">OrderedNumber</a></h3> <p>The number you ordered.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the number. Use it for the call plan.</td> </tr> <tr> <td><code>e164</code> (required)</td> <td>string</td> <td>The number in E.164, like +447700900123.</td> </tr> <tr> <td><code>type</code> (required)</td> <td>string</td> <td>national (non-geographic), geographic (with an area code), mobile or tollfree.</td> </tr> <tr> <td><code>status</code> (required)</td> <td>string</td> <td>pending (the registration is with the carrier, usually active within one business day), active or porting.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the number was added to the project.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true for a test number (test mode), from the range +447700900000 to +447700900999. It can never be called for real.</td> </tr> <tr> <td><code>from_stock</code> (required)</td> <td>boolean</td> <td>Whether the number came from our stock. A number from stock works right away.</td> </tr> </tbody></table></div> <h3 id="object-numberlist"><a class="anker" href="#object-numberlist">NumberList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-number">Number</a>[]</td> <td></td> </tr> </tbody></table></div> <h3 id="object-releasednumber"><a class="anker" href="#object-releasednumber">ReleasedNumber</a></h3> <p>A test number that was released.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the number that was released.</td> </tr> <tr> <td><code>e164</code> (required)</td> <td>string</td> <td>The number in E.164, like +447700900123.</td> </tr> <tr> <td><code>released</code> (required)</td> <td><code>true</code></td> <td>Always true: the number is no longer in the project.</td> </tr> <tr> <td><code>test</code> (required)</td> <td><code>true</code></td> <td>Always true: only test numbers can be released through the API.</td> </tr> </tbody></table></div> <h3 id="object-availablenumber"><a class="anker" href="#object-availablenumber">AvailableNumber</a></h3> <p>A number you can order with POST /numbers.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>e164</code> (required)</td> <td>string</td> <td>The number in E.164, like +447700900123.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"national"</code>, <code>"geographic"</code></td> <td></td> </tr> <tr> <td><code>from_stock</code> (required)</td> <td>boolean</td> <td>Whether the number is in our stock and works right away.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true for a test number (test mode).</td> </tr> </tbody></table></div> <h3 id="object-availablenumberlist"><a class="anker" href="#object-availablenumberlist">AvailableNumberList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-availablenumber">AvailableNumber</a>[]</td> <td></td> </tr> </tbody></table></div> <h3 id="object-numberorder"><a class="anker" href="#object-numberorder">NumberOrder</a></h3> <p>Send either phone_number or area, not both.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>phone_number</code></td> <td>string</td> <td>A number from GET /numbers/available, in E.164. With a test key: a test number from that same search.</td> </tr> <tr> <td><code>area</code></td> <td>string</td> <td>Instead of phone_number: we pick the first free number with this area code, as GET /numbers/available returns it.</td> </tr> </tbody></table></div> <h3 id="object-assistantinput"><a class="anker" href="#object-assistantinput">AssistantInput</a></h3> <p>PUT replaces all fields: a field you leave out goes back to its default.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>voice</code></td> <td><code>"saar"</code>, <code>"fenna"</code>, <code>"merel"</code>, <code>"julia"</code>, <code>"roos"</code>, <code>"tim"</code>, <code>"bram"</code>, <code>"daan"</code>, <code>"ruben"</code>, <code>"ava"</code>, <code>"nora"</code>, <code>"liam"</code>, <code>"owen"</code>, <code>"lena"</code>, <code>"mara"</code>, <code>"jonas"</code>, <code>"felix"</code>, <code>"chloe"</code>, <code>"manon"</code>, <code>"hugo"</code>, <code>"theo"</code>, <code>"lucia"</code>, <code>"elena"</code>, <code>"pablo"</code>, <code>"diego"</code></td> <td>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.</td> </tr> <tr> <td><code>language</code></td> <td><code>"nl"</code>, <code>"en"</code>, <code>"de"</code>, <code>"fr"</code>, <code>"es"</code></td> <td>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).</td> </tr> <tr> <td><code>languages</code></td> <td><code>"nl"</code>, <code>"en"</code>, <code>"de"</code>, <code>"fr"</code>, <code>"es"</code>[]</td> <td>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].</td> </tr> <tr> <td><code>instructions</code></td> <td>string</td> <td>What the assistant does, as free text. Default empty.</td> </tr> <tr> <td><code>website</code></td> <td>string or null</td> <td>Optional. We read the page and use it for opening hours, address and services. Must be a public web address.</td> </tr> </tbody></table></div> <h3 id="object-assistant"><a class="anker" href="#object-assistant">Assistant</a></h3> <p>The assistant of the project.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>configured</code> (required)</td> <td>boolean</td> <td>Whether the assistant was ever set up with PUT /assistant.</td> </tr> <tr> <td><code>voice</code> (required)</td> <td>string or null</td> <td>The voice, like ava. It speaks the language.</td> </tr> <tr> <td><code>language</code> (required)</td> <td>string</td> <td>The language the assistant opens and speaks in, like en.</td> </tr> <tr> <td><code>languages</code> (required)</td> <td>string[]</td> <td>The languages your callers speak, with language first.</td> </tr> <tr> <td><code>speech_recognition</code> (required)</td> <td>string</td> <td>The language of speech recognition: the one language, or multi when there are two or more.</td> </tr> <tr> <td><code>instructions</code> (required)</td> <td>string</td> <td>What the assistant does: your instructions.</td> </tr> <tr> <td><code>website</code> (required)</td> <td>string or null</td> <td>The website the assistant reads for opening hours, address and services.</td> </tr> <tr> <td><code>greeting</code> (required)</td> <td>string</td> <td>The fixed greeting, in the language. It follows from the project name and always says the caller is talking to a digital assistant.</td> </tr> <tr> <td><code>synced_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the assistant was last updated at the carrier.</td> </tr> </tbody></table></div> <h3 id="object-assistantsaved"><a class="anker" href="#object-assistantsaved">AssistantSaved</a></h3> <p>The assistant as saved.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>configured</code> (required)</td> <td>boolean</td> <td>Whether the assistant was ever set up with PUT /assistant.</td> </tr> <tr> <td><code>voice</code> (required)</td> <td>string or null</td> <td>The voice, like ava. It speaks the language.</td> </tr> <tr> <td><code>language</code> (required)</td> <td>string</td> <td>The language the assistant opens and speaks in, like en.</td> </tr> <tr> <td><code>languages</code> (required)</td> <td>string[]</td> <td>The languages your callers speak, with language first.</td> </tr> <tr> <td><code>speech_recognition</code> (required)</td> <td>string</td> <td>The language of speech recognition: the one language, or multi when there are two or more.</td> </tr> <tr> <td><code>instructions</code> (required)</td> <td>string</td> <td>What the assistant does: your instructions.</td> </tr> <tr> <td><code>website</code> (required)</td> <td>string or null</td> <td>The website the assistant reads for opening hours, address and services.</td> </tr> <tr> <td><code>greeting</code> (required)</td> <td>string</td> <td>The fixed greeting, in the language. It follows from the project name and always says the caller is talking to a digital assistant.</td> </tr> <tr> <td><code>synced_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the assistant was last updated at the carrier.</td> </tr> <tr> <td><code>sync</code> (required)</td> <td><code>"synced"</code>, <code>"failed"</code>, <code>"skipped_test_mode"</code></td> <td>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.</td> </tr> <tr> <td><code>call_plan</code> (required)</td> <td><code>"added"</code>, <code>"in_plan"</code>, <code>"not_in_plan"</code></td> <td>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.</td> </tr> <tr> <td><code>next_step</code> (required)</td> <td>string</td> <td>What to do next.</td> </tr> <tr> <td><code>user_message</code> (required)</td> <td>string</td> <td>A sentence you can show the user as it is.</td> </tr> </tbody></table></div> <h3 id="object-callplan"><a class="anker" href="#object-callplan">CallPlan</a></h3> <p>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.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>entry</code> (required)</td> <td>string</td> <td>The id of the first node.</td> </tr> <tr> <td><code>nodes</code> (required)</td> <td>object</td> <td>The nodes by id. The key must be the same as the node's id.</td> </tr> </tbody></table></div> <h3 id="object-callplanresponse"><a class="anker" href="#object-callplanresponse">CallPlanResponse</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>number_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>version</code> (required)</td> <td>integer</td> <td>The version of the number's own plan; 0 when the number has no own plan yet. Also in the ETag header.</td> </tr> <tr> <td><code>source</code> (required)</td> <td><code>"number"</code>, <code>"project"</code>, <code>"none"</code></td> <td>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.</td> </tr> <tr> <td><code>plan</code> (required)</td> <td>one of</td> <td>The plan that calls to this number follow: its own plan, or else the plan of the project; null when there is none.</td> </tr> </tbody></table></div> <h3 id="object-callplansaved"><a class="anker" href="#object-callplansaved">CallPlanSaved</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>number_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>version</code> (required)</td> <td>integer</td> <td>The version of the number's own plan; 0 when the number has no own plan yet. Also in the ETag header.</td> </tr> <tr> <td><code>plan</code> (required)</td> <td><a href="#object-callplan">CallPlan</a></td> <td>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.</td> </tr> <tr> <td><code>assistant_sync</code> (required)</td> <td><code>"synced"</code>, <code>"failed"</code>, <code>"not_needed"</code>, <code>"skipped_test_mode"</code></td> <td>Whether the assistant was updated with this plan. not_needed for a plan without ai_agent; skipped_test_mode with a test key.</td> </tr> </tbody></table></div> <h3 id="object-call"><a class="anker" href="#object-call">Call</a></h3> <p>A call. The same shape in GET /calls, GET /calls/{id} and every webhook.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the call.</td> </tr> <tr> <td><code>direction</code> (required)</td> <td><code>"inbound"</code>, <code>"outbound"</code>, <code>null</code></td> <td>inbound, or outbound for a call your assistant made with POST /calls.</td> </tr> <tr> <td><code>from</code> (required)</td> <td>string or null</td> <td>The caller, in E.164.</td> </tr> <tr> <td><code>to</code> (required)</td> <td>string or null</td> <td>The number that was called, in E.164.</td> </tr> <tr> <td><code>status</code> (required)</td> <td>string or null</td> <td>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.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the call reached us.</td> </tr> <tr> <td><code>started_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the call started.</td> </tr> <tr> <td><code>answered_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the platform picked up. With a call plan the platform always picks up first, so this does not prove a person answered.</td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the call ended.</td> </tr> <tr> <td><code>duration_sec</code> (required)</td> <td>integer or null</td> <td>The length of the call in seconds.</td> </tr> <tr> <td><code>hangup_cause</code> (required)</td> <td>string or null</td> <td>Why the call ended, as reported by the carrier, like normal_clearing.</td> </tr> <tr> <td><code>has_summary</code> (required)</td> <td>boolean</td> <td>Whether the call has a summary. Fetch it with GET /calls/{id}.</td> </tr> <tr> <td><code>summary_ready_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the summary of a recorded call was ready. Empty for a call handled by the voice AI; look at has_summary instead.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>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.</td> </tr> <tr> <td><code>outbound</code> (required)</td> <td>object or null</td> <td>The request behind an outbound call started with POST /calls.</td> </tr> </tbody></table></div> <h3 id="object-calldetail"><a class="anker" href="#object-calldetail">CallDetail</a></h3> <p>A call with its transcript and summary.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the call.</td> </tr> <tr> <td><code>direction</code> (required)</td> <td><code>"inbound"</code>, <code>"outbound"</code>, <code>null</code></td> <td>inbound, or outbound for a call your assistant made with POST /calls.</td> </tr> <tr> <td><code>from</code> (required)</td> <td>string or null</td> <td>The caller, in E.164.</td> </tr> <tr> <td><code>to</code> (required)</td> <td>string or null</td> <td>The number that was called, in E.164.</td> </tr> <tr> <td><code>status</code> (required)</td> <td>string or null</td> <td>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.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the call reached us.</td> </tr> <tr> <td><code>started_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the call started.</td> </tr> <tr> <td><code>answered_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the platform picked up. With a call plan the platform always picks up first, so this does not prove a person answered.</td> </tr> <tr> <td><code>ended_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the call ended.</td> </tr> <tr> <td><code>duration_sec</code> (required)</td> <td>integer or null</td> <td>The length of the call in seconds.</td> </tr> <tr> <td><code>hangup_cause</code> (required)</td> <td>string or null</td> <td>Why the call ended, as reported by the carrier, like normal_clearing.</td> </tr> <tr> <td><code>has_summary</code> (required)</td> <td>boolean</td> <td>Whether the call has a summary. Fetch it with GET /calls/{id}.</td> </tr> <tr> <td><code>summary_ready_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the summary of a recorded call was ready. Empty for a call handled by the voice AI; look at has_summary instead.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>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.</td> </tr> <tr> <td><code>outbound</code> (required)</td> <td>object or null</td> <td>The request behind an outbound call started with POST /calls.</td> </tr> <tr> <td><code>transcript</code> (required)</td> <td>string or null</td> <td>The transcript. Only here, never in a webhook.</td> </tr> <tr> <td><code>summary</code> (required)</td> <td>one of</td> <td>The summary of a call.</td> </tr> </tbody></table></div> <h3 id="object-calllist"><a class="anker" href="#object-calllist">CallList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-call">Call</a>[]</td> <td></td> </tr> <tr> <td><code>has_more</code> (required)</td> <td>boolean</td> <td>Whether there is a next page.</td> </tr> <tr> <td><code>next_cursor</code> (required)</td> <td>string or null</td> <td>Pass this as cursor to get the next page. Null on the last page.</td> </tr> </tbody></table></div> <h3 id="object-summary"><a class="anker" href="#object-summary">Summary</a></h3> <p>The summary of a call.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>text</code> (required)</td> <td>string or null</td> <td>The summary in a few sentences.</td> </tr> <tr> <td><code>topics</code> (required)</td> <td>string[]</td> <td>What the call was about.</td> </tr> <tr> <td><code>sentiment</code> (required)</td> <td><code>"positive"</code>, <code>"neutral"</code>, <code>"negative"</code>, <code>null</code></td> <td>The mood of the caller.</td> </tr> <tr> <td><code>action_items</code> (required)</td> <td>string[]</td> <td>What someone has to do after this call.</td> </tr> <tr> <td><code>next_step</code> (required)</td> <td>string or null</td> <td>The agreed next step, if any.</td> </tr> </tbody></table></div> <h3 id="object-testcallinput"><a class="anker" href="#object-testcallinput">TestCallInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>number_id</code> (required)</td> <td>string (uuid)</td> <td>The id of one of your test numbers, from GET /numbers.</td> </tr> <tr> <td><code>scenario</code> (required)</td> <td><code>"reservation"</code>, <code>"callback_request"</code>, <code>"question"</code>, <code>"missed"</code></td> <td>What happens in the call: reservation, callback_request, question, missed. missed has no transcript and no summary, so only call.completed is sent.</td> </tr> <tr> <td><code>from</code></td> <td>string</td> <td>Optional: the caller, a fictional number from +447700900000 to +447700900999.</td> </tr> <tr> <td><code>caller_goal</code></td> <td>string</td> <td></td> </tr> </tbody></table></div> <h3 id="object-webhookendpointinput"><a class="anker" href="#object-webhookendpointinput">WebhookEndpointInput</a></h3> <p>A new webhook endpoint.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>url</code> (required)</td> <td>string</td> <td>An https URL with a domain name on the default port, without credentials, that resolves to a public address.</td> </tr> <tr> <td><code>events</code> (required)</td> <td><code>"call.completed"</code>, <code>"call.summary.ready"</code>, <code>"message.delivered"</code>, <code>"message.failed"</code>, <code>"conversation.summary.ready"</code>, <code>"balance.low"</code>, <code>"balance.topped_up"</code>, <code>"balance.reload_failed"</code>, <code>"document.created"</code>, <code>"email.sent"</code>, <code>"email.delivered"</code>, <code>"email.delayed"</code>, <code>"email.bounced"</code>, <code>"email.complained"</code>[]</td> <td>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.</td> </tr> <tr> <td><code>app_id</code></td> <td>string (uuid)</td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-webhookendpoint"><a class="anker" href="#object-webhookendpoint">WebhookEndpoint</a></h3> <p>A webhook endpoint. Never with the secret.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>url</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>events</code> (required)</td> <td>string[]</td> <td>The subscribed events, from call.completed, call.summary.ready, message.delivered, message.failed, conversation.summary.ready, balance.low, balance.topped_up, balance.reload_failed, document.created.</td> </tr> <tr> <td><code>active</code> (required)</td> <td>boolean</td> <td>false after 50 failed deliveries in a row; switch it back on with POST /webhooks/{id}/enable.</td> </tr> <tr> <td><code>secret_prefix</code> (required)</td> <td>string</td> <td>The first characters of the secret, to recognize it.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the endpoint was created.</td> </tr> <tr> <td><code>last_success_at</code> (required)</td> <td>string or null (date-time)</td> <td>The last delivery that got a 2xx.</td> </tr> <tr> <td><code>last_failure_at</code> (required)</td> <td>string or null (date-time)</td> <td>The last delivery that failed.</td> </tr> <tr> <td><code>consecutive_failures</code> (required)</td> <td>integer</td> <td>Failed deliveries in a row.</td> </tr> <tr> <td><code>app_id</code></td> <td>string (uuid)</td> <td>Only with a filter on one app: the endpoint gets the email events of that app's emails only.</td> </tr> </tbody></table></div> <h3 id="object-webhookendpointcreated"><a class="anker" href="#object-webhookendpointcreated">WebhookEndpointCreated</a></h3> <p>The new webhook endpoint, with its secret.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>url</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>events</code> (required)</td> <td>string[]</td> <td>The subscribed events, from call.completed, call.summary.ready, message.delivered, message.failed, conversation.summary.ready, balance.low, balance.topped_up, balance.reload_failed, document.created.</td> </tr> <tr> <td><code>active</code> (required)</td> <td>boolean</td> <td>false after 50 failed deliveries in a row; switch it back on with POST /webhooks/{id}/enable.</td> </tr> <tr> <td><code>secret_prefix</code> (required)</td> <td>string</td> <td>The first characters of the secret, to recognize it.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the endpoint was created.</td> </tr> <tr> <td><code>last_success_at</code> (required)</td> <td>string or null (date-time)</td> <td>The last delivery that got a 2xx.</td> </tr> <tr> <td><code>last_failure_at</code> (required)</td> <td>string or null (date-time)</td> <td>The last delivery that failed.</td> </tr> <tr> <td><code>consecutive_failures</code> (required)</td> <td>integer</td> <td>Failed deliveries in a row.</td> </tr> <tr> <td><code>app_id</code></td> <td>string (uuid)</td> <td>Only with a filter on one app: the endpoint gets the email events of that app's emails only.</td> </tr> <tr> <td><code>secret</code> (required)</td> <td>string</td> <td>The signing secret, whsec_ plus 43 characters. Shown only in this response: store it now.</td> </tr> </tbody></table></div> <h3 id="object-webhookendpointlist"><a class="anker" href="#object-webhookendpointlist">WebhookEndpointList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-webhookendpoint">WebhookEndpoint</a>[]</td> <td></td> </tr> </tbody></table></div> <h3 id="object-emailsentevent"><a class="anker" href="#object-emailsentevent">EmailSentEvent</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The event id, evt_ plus 32 hex characters. The same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"email.sent"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-emaildeliveredevent"><a class="anker" href="#object-emaildeliveredevent">EmailDeliveredEvent</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The event id, evt_ plus 32 hex characters. The same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"email.delivered"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-emaildelayedevent"><a class="anker" href="#object-emaildelayedevent">EmailDelayedEvent</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The event id, evt_ plus 32 hex characters. The same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"email.delayed"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-emailbouncedevent"><a class="anker" href="#object-emailbouncedevent">EmailBouncedEvent</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The event id, evt_ plus 32 hex characters. The same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"email.bounced"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-emailcomplainedevent"><a class="anker" href="#object-emailcomplainedevent">EmailComplainedEvent</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The event id, evt_ plus 32 hex characters. The same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"email.complained"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-webhooktestresult"><a class="anker" href="#object-webhooktestresult">WebhookTestResult</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>delivery_id</code> (required)</td> <td>string (uuid)</td> <td></td> </tr> <tr> <td><code>event_id</code> (required)</td> <td>string</td> <td>The id of the webhook.test event, evt_ plus 32 hex characters.</td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"delivered"</code>, <code>"pending"</code>, <code>"failed"</code></td> <td>delivered: your endpoint answered 2xx. pending: it did not, and we retry with backoff. failed: we gave up.</td> </tr> <tr> <td><code>response_status</code> (required)</td> <td>integer or null</td> <td>The HTTP status your endpoint answered with, if any.</td> </tr> <tr> <td><code>error</code> (required)</td> <td><code>"timeout"</code>, <code>"connection_failed"</code>, <code>"non_2xx_status"</code>, <code>"not_public_host"</code>, <code>"invalid_url"</code>, <code>"response_too_large"</code>, <code>"secret_unreadable"</code>, <code>"endpoint_disabled"</code>, <code>"call_deleted"</code>, <code>"summary_deleted"</code>, <code>"message_deleted"</code>, <code>"conversation_deleted"</code>, <code>"document_deleted"</code>, <code>"invalid_reference"</code>, <code>"delivery_failed"</code>, <code>null</code></td> <td>Why the delivery did not succeed, as a short code.</td> </tr> </tbody></table></div> <h3 id="object-callcompletedevent"><a class="anker" href="#object-callcompletedevent">CallCompletedEvent</a></h3> <p>Sent after a call ends.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"call.completed"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-callsummaryreadyevent"><a class="anker" href="#object-callsummaryreadyevent">CallSummaryReadyEvent</a></h3> <p>Sent when the summary of a call is ready. Without the transcript: fetch that with GET /calls/{data.call.id}.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"call.summary.ready"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-webhooktestevent"><a class="anker" href="#object-webhooktestevent">WebhookTestEvent</a></h3> <p>Sent by POST /webhooks/{id}/test. Needs no subscription.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"webhook.test"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-callinput"><a class="anker" href="#object-callinput">CallInput</a></h3> <p>An outbound call by your project's assistant to one person who expects it.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>to</code> (required)</td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>from</code></td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>assistant_instructions</code></td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>on_voicemail</code></td> <td><code>"hangup"</code>, <code>"leave_message"</code></td> <td>What to do when an answering machine picks up: hang up (default) or leave a short message after the beep.</td> </tr> <tr> <td><code>voicemail_message</code></td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>max_duration_sec</code></td> <td>integer</td> <td>Optional: the call ends after this many seconds of talking. Default 600, at most 1200.</td> </tr> <tr> <td><code>reference</code></td> <td>string</td> <td>Optional: your own id for this call, like an appointment id. Comes back on the call and in its webhooks.</td> </tr> <tr> <td><code>metadata</code></td> <td>object</td> <td>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.</td> </tr> <tr> <td><code>calling_window</code></td> <td>object</td> <td>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.</td> </tr> <tr> <td><code>purpose</code></td> <td><code>"transactional"</code>, <code>"marketing"</code></td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-callconsentinput"><a class="anker" href="#object-callconsentinput">CallConsentInput</a></h3> <p>Consent from one person to receive marketing calls from this project.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>phone_number</code> (required)</td> <td>string</td> <td>The number of the person who gave consent, in E.164. Stored only as a keyed hash plus its last two digits.</td> </tr> <tr> <td><code>source</code> (required)</td> <td><code>"form"</code>, <code>"call"</code>, <code>"import"</code>, <code>"manual"</code></td> <td>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).</td> </tr> <tr> <td><code>evidence</code> (required)</td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>consented_at</code></td> <td>string (date-time)</td> <td>When the person gave consent (ISO 8601). Default: now. Cannot be in the future.</td> </tr> </tbody></table></div> <h3 id="object-callconsent"><a class="anker" href="#object-callconsent">CallConsent</a></h3> <p>Recorded consent from one person to receive marketing calls from this project.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the consent.</td> </tr> <tr> <td><code>phone_number_ending</code> (required)</td> <td>string</td> <td>The last two digits of the number. The number itself is never stored, only a keyed hash.</td> </tr> <tr> <td><code>source</code> (required)</td> <td><code>"form"</code>, <code>"call"</code>, <code>"import"</code>, <code>"manual"</code></td> <td>Where the consent came from: form, call, import or manual.</td> </tr> <tr> <td><code>evidence</code> (required)</td> <td>string</td> <td>Your reference to the proof.</td> </tr> <tr> <td><code>consented_at</code> (required)</td> <td>string (date-time)</td> <td>When the person gave consent.</td> </tr> <tr> <td><code>recorded_at</code> (required)</td> <td>string (date-time)</td> <td>When it was recorded here.</td> </tr> <tr> <td><code>revoked_at</code> (required)</td> <td>string or null (date-time)</td> <td>When it was revoked; null while it is valid.</td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"active"</code>, <code>"revoked"</code></td> <td>active: marketing calls to this number are allowed. revoked: no longer.</td> </tr> </tbody></table></div> <h3 id="object-callconsentlist"><a class="anker" href="#object-callconsentlist">CallConsentList</a></h3> <p>The project's recorded consents.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-callconsent">CallConsent</a>[]</td> <td>The consents, newest first (at most 100).</td> </tr> </tbody></table></div> <h3 id="object-messageinput"><a class="anker" href="#object-messageinput">MessageInput</a></h3> <p>A transactional SMS to one recipient.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>to</code> (required)</td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>body</code> (required)</td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>from</code></td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>reference</code></td> <td>string</td> <td>Optional: your own id for this message, like an order number. Comes back in the message and its webhooks.</td> </tr> </tbody></table></div> <h3 id="object-message"><a class="anker" href="#object-message">Message</a></h3> <p>An SMS message. The same shape in POST /messages, GET /messages, GET /messages/{id} and the message webhooks.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the message.</td> </tr> <tr> <td><code>to</code> (required)</td> <td>string</td> <td>The recipient, in E.164.</td> </tr> <tr> <td><code>from</code> (required)</td> <td>string</td> <td>The sender the recipient sees: an alphanumeric name of up to 11 characters.</td> </tr> <tr> <td><code>body</code> (required)</td> <td>string</td> <td>The text as it was sent, after typographic quotes and dashes were made SMS-safe.</td> </tr> <tr> <td><code>segments</code> (required)</td> <td>integer</td> <td>How many SMS segments the text took. A segment is 160 GSM-7 or 70 UCS-2 characters (153 and 67 when split).</td> </tr> <tr> <td><code>encoding</code> (required)</td> <td><code>"gsm7"</code>, <code>"ucs2"</code>, <code>null</code></td> <td>gsm7, or ucs2 when the text has a character outside GSM-7 (an emoji, for example), which makes every segment shorter.</td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"queued"</code>, <code>"sent"</code>, <code>"delivered"</code>, <code>"failed"</code></td> <td>queued: accepted. sent: handed to the mobile network. delivered: the phone confirmed it. failed: it did not arrive; see error.</td> </tr> <tr> <td><code>error</code> (required)</td> <td>string or null</td> <td>With status failed: a short code, like delivery_failed or sending_failed. Otherwise null.</td> </tr> <tr> <td><code>reference</code> (required)</td> <td>string or null</td> <td>Your own id for this message, as you sent it (an order number, for example).</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When we accepted the message.</td> </tr> <tr> <td><code>status_updated_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the status last changed.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true for a message sent with a test key to a test number. It never reaches a phone.</td> </tr> </tbody></table></div> <h3 id="object-messagelist"><a class="anker" href="#object-messagelist">MessageList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-message">Message</a>[]</td> <td></td> </tr> <tr> <td><code>has_more</code> (required)</td> <td>boolean</td> <td>Whether there is another page.</td> </tr> <tr> <td><code>next_cursor</code> (required)</td> <td>string or null</td> <td>Pass this as cursor to get the next page.</td> </tr> </tbody></table></div> <h3 id="object-messagedeliveredevent"><a class="anker" href="#object-messagedeliveredevent">MessageDeliveredEvent</a></h3> <p>Sent when the phone of the recipient confirmed the message.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"message.delivered"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-messagefailedevent"><a class="anker" href="#object-messagefailedevent">MessageFailedEvent</a></h3> <p>Sent when a message could not be delivered. data.message.error says why.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"message.failed"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-conversationinput"><a class="anker" href="#object-conversationinput">ConversationInput</a></h3> <p>A new chat conversation with the assistant of the project.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>channel</code></td> <td><code>"chat"</code></td> <td>The channel. Only chat for now; it is also the default.</td> </tr> <tr> <td><code>reference</code></td> <td>string</td> <td>Optional: your own id for this conversation, like a session or ticket id. Comes back in the conversation and its webhook.</td> </tr> <tr> <td><code>metadata</code></td> <td>object</td> <td>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.</td> </tr> <tr> <td><code>customer</code></td> <td>object</td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-conversation"><a class="anker" href="#object-conversation">Conversation</a></h3> <p>A chat conversation with the assistant. The same shape in every response and in conversation.summary.ready.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the conversation.</td> </tr> <tr> <td><code>channel</code> (required)</td> <td><code>"chat"</code>, <code>"widget"</code></td> <td>The channel: chat when your app started it with an API key, widget when a visitor started it in the chat widget on a website (public chat token).</td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"open"</code>, <code>"closed"</code></td> <td>open: takes messages. closed: takes no more messages; the summary follows.</td> </tr> <tr> <td><code>reference</code> (required)</td> <td>string or null</td> <td>Your own id for this conversation, as you sent it.</td> </tr> <tr> <td><code>metadata</code> (required)</td> <td>object</td> <td>Your metadata, as you sent it. The assistant never sees it.</td> </tr> <tr> <td><code>customer</code> (required)</td> <td>object</td> <td>What your app told us about the customer when the conversation started.</td> </tr> <tr> <td><code>language</code> (required)</td> <td>string</td> <td>The language the assistant opened in (nl, en, de, fr or es). It replies in the customer's language when that is one of its languages.</td> </tr> <tr> <td><code>turns</code> (required)</td> <td>integer</td> <td>Customer messages answered so far.</td> </tr> <tr> <td><code>max_turns</code> (required)</td> <td>integer</td> <td>After this many turns the conversation closes (close_reason turn_limit).</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the conversation started.</td> </tr> <tr> <td><code>last_message_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the last message was sent.</td> </tr> <tr> <td><code>closed_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the conversation closed.</td> </tr> <tr> <td><code>close_reason</code> (required)</td> <td><code>"closed"</code>, <code>"inactive"</code>, <code>"turn_limit"</code>, <code>null</code></td> <td>closed: POST /conversations/{id}/close. inactive: no message for 30 minutes. turn_limit: max_turns reached.</td> </tr> <tr> <td><code>has_summary</code> (required)</td> <td>boolean</td> <td>Whether the summary is ready. Fetch it with GET /conversations/{id}.</td> </tr> <tr> <td><code>summary_ready_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the summary was ready.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true for a conversation in test mode (hm_test_): real replies, free, with a low daily limit.</td> </tr> </tbody></table></div> <h3 id="object-conversationdetail"><a class="anker" href="#object-conversationdetail">ConversationDetail</a></h3> <p>A conversation with its messages and summary.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the conversation.</td> </tr> <tr> <td><code>channel</code> (required)</td> <td><code>"chat"</code>, <code>"widget"</code></td> <td>The channel: chat when your app started it with an API key, widget when a visitor started it in the chat widget on a website (public chat token).</td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"open"</code>, <code>"closed"</code></td> <td>open: takes messages. closed: takes no more messages; the summary follows.</td> </tr> <tr> <td><code>reference</code> (required)</td> <td>string or null</td> <td>Your own id for this conversation, as you sent it.</td> </tr> <tr> <td><code>metadata</code> (required)</td> <td>object</td> <td>Your metadata, as you sent it. The assistant never sees it.</td> </tr> <tr> <td><code>customer</code> (required)</td> <td>object</td> <td>What your app told us about the customer when the conversation started.</td> </tr> <tr> <td><code>language</code> (required)</td> <td>string</td> <td>The language the assistant opened in (nl, en, de, fr or es). It replies in the customer's language when that is one of its languages.</td> </tr> <tr> <td><code>turns</code> (required)</td> <td>integer</td> <td>Customer messages answered so far.</td> </tr> <tr> <td><code>max_turns</code> (required)</td> <td>integer</td> <td>After this many turns the conversation closes (close_reason turn_limit).</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the conversation started.</td> </tr> <tr> <td><code>last_message_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the last message was sent.</td> </tr> <tr> <td><code>closed_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the conversation closed.</td> </tr> <tr> <td><code>close_reason</code> (required)</td> <td><code>"closed"</code>, <code>"inactive"</code>, <code>"turn_limit"</code>, <code>null</code></td> <td>closed: POST /conversations/{id}/close. inactive: no message for 30 minutes. turn_limit: max_turns reached.</td> </tr> <tr> <td><code>has_summary</code> (required)</td> <td>boolean</td> <td>Whether the summary is ready. Fetch it with GET /conversations/{id}.</td> </tr> <tr> <td><code>summary_ready_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the summary was ready.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true for a conversation in test mode (hm_test_): real replies, free, with a low daily limit.</td> </tr> <tr> <td><code>messages</code> (required)</td> <td><a href="#object-chatmessage">ChatMessage</a>[]</td> <td>All messages, oldest first, starting with the fixed greeting.</td> </tr> <tr> <td><code>summary</code> (required)</td> <td>one of</td> <td>The summary, after the conversation closed. Same shape as the summary of a call.</td> </tr> </tbody></table></div> <h3 id="object-conversationlist"><a class="anker" href="#object-conversationlist">ConversationList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-conversation">Conversation</a>[]</td> <td></td> </tr> <tr> <td><code>has_more</code> (required)</td> <td>boolean</td> <td>Whether there is another page.</td> </tr> <tr> <td><code>next_cursor</code> (required)</td> <td>string or null</td> <td>Pass this as cursor to get the next page.</td> </tr> </tbody></table></div> <h3 id="object-chatmessage"><a class="anker" href="#object-chatmessage">ChatMessage</a></h3> <p>One message in a conversation.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the message.</td> </tr> <tr> <td><code>role</code> (required)</td> <td><code>"customer"</code>, <code>"assistant"</code></td> <td>Who wrote it: the customer, or the assistant (also the fixed greeting).</td> </tr> <tr> <td><code>text</code> (required)</td> <td>string</td> <td>The text.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the message was stored.</td> </tr> </tbody></table></div> <h3 id="object-chatmessageinput"><a class="anker" href="#object-chatmessageinput">ChatMessageInput</a></h3> <p>One message from the customer.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>text</code> (required)</td> <td>string</td> <td>What the customer typed, at most 2000 characters. Send it as is; it is never treated as an instruction to the assistant.</td> </tr> </tbody></table></div> <h3 id="object-chatturn"><a class="anker" href="#object-chatturn">ChatTurn</a></h3> <p>One turn: the customer's message and the assistant's reply.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>message</code> (required)</td> <td>object</td> <td>The customer's message as stored.</td> </tr> <tr> <td><code>reply</code> (required)</td> <td>object</td> <td>The assistant's reply. Show this text to the customer.</td> </tr> <tr> <td><code>conversation</code> (required)</td> <td>object</td> <td>The conversation after this turn. status closed means the turn limit was reached.</td> </tr> </tbody></table></div> <h3 id="object-conversationsummaryreadyevent"><a class="anker" href="#object-conversationsummaryreadyevent">ConversationSummaryReadyEvent</a></h3> <p>Sent when the summary of a closed chat conversation is ready. Without the messages: fetch those with GET /conversations/{data.conversation.id}.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"conversation.summary.ready"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-chattokeninput"><a class="anker" href="#object-chattokeninput">ChatTokenInput</a></h3> <p>A new public chat token for the website widget.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>name</code> (required)</td> <td>string</td> <td>A name for your own overview, like the website it is for.</td> </tr> <tr> <td><code>display_name</code></td> <td>string or null</td> <td>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.</td> </tr> <tr> <td><code>allowed_origins</code> (required)</td> <td>string[]</td> <td>The exact origins that may use this token, like <a href="https://www.example.com" rel="noopener">https://www.example.com</a> (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.</td> </tr> <tr> <td><code>turnstile</code></td> <td>boolean</td> <td>Ask for a bot check before a conversation starts. Default true.</td> </tr> <tr> <td><code>max_conversations_per_day</code></td> <td>integer</td> <td>New conversations per day through this token. Default 200, at most 1000.</td> </tr> </tbody></table></div> <h3 id="object-chattokenupdate"><a class="anker" href="#object-chattokenupdate">ChatTokenUpdate</a></h3> <p>Change a public chat token. Fields you leave out stay as they are.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>name</code></td> <td>string</td> <td></td> </tr> <tr> <td><code>display_name</code></td> <td>string or null</td> <td>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.</td> </tr> <tr> <td><code>allowed_origins</code></td> <td>string[]</td> <td></td> </tr> <tr> <td><code>turnstile</code></td> <td>boolean</td> <td></td> </tr> <tr> <td><code>max_conversations_per_day</code></td> <td>integer</td> <td></td> </tr> </tbody></table></div> <h3 id="object-chattoken"><a class="anker" href="#object-chattoken">ChatToken</a></h3> <p>A public chat token for the chat widget on a website.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the public chat token. Use it to change or revoke the token.</td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td>Your name for the token, like the website it is for.</td> </tr> <tr> <td><code>display_name</code> (required)</td> <td>string or null</td> <td>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.</td> </tr> <tr> <td><code>token</code> (required)</td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>mode</code> (required)</td> <td><code>"live"</code>, <code>"test"</code></td> <td>live or test, from the project the key belongs to.</td> </tr> <tr> <td><code>allowed_origins</code> (required)</td> <td>string[]</td> <td>The exact origins that may use the token, like <a href="https://www.example.com" rel="noopener">https://www.example.com</a>. A test token also works from localhost.</td> </tr> <tr> <td><code>turnstile</code> (required)</td> <td>boolean</td> <td>Whether a visitor passes a bot check before a conversation starts.</td> </tr> <tr> <td><code>max_conversations_per_day</code> (required)</td> <td>integer</td> <td>New conversations per day through this token, counted until midnight, Europe/Amsterdam.</td> </tr> <tr> <td><code>revoked</code> (required)</td> <td>boolean</td> <td>true once the token is revoked: the widget stops working right away, also in open conversations.</td> </tr> <tr> <td><code>revoked_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the token was revoked.</td> </tr> <tr> <td><code>last_used_at</code> (required)</td> <td>string or null (date-time)</td> <td>When a browser last used the token, to the minute.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the token was created.</td> </tr> <tr> <td><code>embed_code</code> (required)</td> <td>string</td> <td>The script tag for your website, with this token in it. Paste it before </body>.</td> </tr> <tr> <td><code>kind</code> (required)</td> <td><code>"chat"</code>, <code>"form"</code></td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-chattokenlist"><a class="anker" href="#object-chattokenlist">ChatTokenList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-chattoken">ChatToken</a>[]</td> <td>The public chat tokens of the project, newest first, also the revoked ones.</td> </tr> <tr> <td><code>daily_spend_limit_eur</code> (required)</td> <td>number</td> <td>What the public chat of this project may spend per day in replies, in euro. Default 5; change it in the portal. 0 turns the live widget off.</td> </tr> </tbody></table></div> <h3 id="object-siteinput"><a class="anker" href="#object-siteinput">SiteInput</a></h3> <p>A new static site.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>slug</code></td> <td>string</td> <td>The address: https://<slug>.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).</td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td>A name for your own overview, like the business or the page.</td> </tr> <tr> <td><code>files</code> (required)</td> <td>object[]</td> <td>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.</td> </tr> <tr> <td><code>password</code></td> <td>string or null</td> <td>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.</td> </tr> <tr> <td><code>media</code></td> <td>string[]</td> <td>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.</td> </tr> <tr> <td><code>message</code></td> <td>string</td> <td>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".</td> </tr> </tbody></table></div> <h3 id="object-siteupdate"><a class="anker" href="#object-siteupdate">SiteUpdate</a></h3> <p>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.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>name</code></td> <td>string</td> <td>A name for your own overview, like the business or the page.</td> </tr> <tr> <td><code>files</code></td> <td>object[]</td> <td>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.</td> </tr> <tr> <td><code>keep_existing</code></td> <td>boolean</td> <td>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.</td> </tr> <tr> <td><code>delete_paths</code></td> <td>string[]</td> <td>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.</td> </tr> <tr> <td><code>media</code></td> <td>string[]</td> <td>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.</td> </tr> <tr> <td><code>if_match_version</code></td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>password</code></td> <td>string or null</td> <td>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.</td> </tr> <tr> <td><code>message</code></td> <td>string</td> <td>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".</td> </tr> <tr> <td><code>preview</code></td> <td>boolean</td> <td>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.</td> </tr> <tr> <td><code>confirm_replace</code></td> <td>boolean</td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-site"><a class="anker" href="#object-site">Site</a></h3> <p>A static site on hypamoon.app.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the site. Use it to update or delete the site.</td> </tr> <tr> <td><code>slug</code> (required)</td> <td>string</td> <td>The first part of the address: https://<slug>.hypamoon.app.</td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td>Your name for the site.</td> </tr> <tr> <td><code>url</code> (required)</td> <td>string</td> <td>The public address of the site, live right away.</td> </tr> <tr> <td><code>files</code> (required)</td> <td><a href="#object-sitefile">SiteFile</a>[]</td> <td>The files of the site, sorted by path, without their content.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the site was created.</td> </tr> <tr> <td><code>updated_at</code> (required)</td> <td>string (date-time)</td> <td>When the site or its files last changed.</td> </tr> <tr> <td><code>version</code> (required)</td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>password_protected</code> (required)</td> <td>boolean</td> <td>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.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true for a site of a test project. It is public all the same.</td> </tr> <tr> <td><code>taken_offline</code> (required)</td> <td>object or null</td> <td>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 <a href="mailto:hello@hypamoon.com">hello@hypamoon.com</a>.</td> </tr> <tr> <td><code>warnings</code></td> <td>one of[]</td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-domainconnectinput"><a class="anker" href="#object-domainconnectinput">DomainConnectInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>domain</code> (required)</td> <td>string</td> <td>The domain, like example.com.</td> </tr> <tr> <td><code>site_id</code></td> <td>string or null (uuid)</td> <td>Optional. A site of this project (from deploy_site or list_sites) to serve on the domain and on www.</td> </tr> <tr> <td><code>method</code></td> <td><code>"nameservers"</code>, <code>"cname"</code></td> <td>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.</td> </tr> <tr> <td><code>dkim_selectors</code></td> <td>string[]</td> <td>Optional. The DKIM selector(s) of the user's mail provider (like google or selector1), so their DKIM records are copied too.</td> </tr> <tr> <td><code>replace_existing</code></td> <td>boolean</td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-domainregisterinput"><a class="anker" href="#object-domainregisterinput">DomainRegisterInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>domain</code> (required)</td> <td>string</td> <td>The domain, like example.com.</td> </tr> <tr> <td><code>years</code></td> <td>integer</td> <td>Registration period in years. Most extensions only allow 1.</td> </tr> <tr> <td><code>accept_registry_terms</code> (required)</td> <td>boolean</td> <td>Must be true: the user (the registrant) explicitly accepted the terms of the registry at registry_terms_url from check_domain.</td> </tr> <tr> <td><code>max_price_eur</code> (required)</td> <td>number</td> <td>The highest total the user agreed to pay for all years together, as shown by check_domain. Refused if the price is higher.</td> </tr> <tr> <td><code>accept_price_eur</code></td> <td>number</td> <td>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.</td> </tr> <tr> <td><code>site_id</code></td> <td>string or null (uuid)</td> <td>Optional. A site of this project to serve on the domain once it is active.</td> </tr> </tbody></table></div> <h3 id="object-domainsiteinput"><a class="anker" href="#object-domainsiteinput">DomainSiteInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>site_id</code> (required)</td> <td>string or null (uuid)</td> <td>The site to serve on the domain, or null for none.</td> </tr> </tbody></table></div> <h3 id="object-domainupdateinput"><a class="anker" href="#object-domainupdateinput">DomainUpdateInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>hsts</code></td> <td><code>"off"</code>, <code>"on"</code>, <code>"on_with_subdomains"</code></td> <td>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.</td> </tr> <tr> <td><code>auto_renew</code></td> <td>boolean</td> <td>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.</td> </tr> <tr> <td><code>primary</code></td> <td><code>"apex"</code>, <code>"www"</code></td> <td>Which address of the domain is the main one. apex (default): example.com, and <a href="http://www.example.com" rel="noopener">www.example.com</a> redirects to it. www: <a href="http://www.example.com" rel="noopener">www.example.com</a>, 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.</td> </tr> </tbody></table></div> <h3 id="object-dnsrecordinput"><a class="anker" href="#object-dnsrecordinput">DnsRecordInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>record_id</code></td> <td>string</td> <td>Optional. The id from list_dns_records of the record to change or delete. Without it, a new record is added.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"A"</code>, <code>"AAAA"</code>, <code>"CNAME"</code>, <code>"MX"</code>, <code>"TXT"</code>, <code>"SRV"</code>, <code>"CAA"</code>, <code>"NS"</code></td> <td>The record type.</td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td>The name: @ for the domain itself, or a subdomain like www, mail or _dmarc.</td> </tr> <tr> <td><code>content</code></td> <td>string</td> <td>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"'.</td> </tr> <tr> <td><code>ttl</code></td> <td>integer</td> <td>Seconds; 1 means automatic.</td> </tr> <tr> <td><code>priority</code></td> <td>integer</td> <td>For MX: the priority (lower is preferred).</td> </tr> <tr> <td><code>proxied</code></td> <td>boolean</td> <td>For A, AAAA and CNAME: through the CDN (true) or DNS only (false, default).</td> </tr> <tr> <td><code>delete</code></td> <td>boolean</td> <td>true deletes the record with record_id.</td> </tr> <tr> <td><code>confirm_overwrite</code></td> <td>boolean</td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-deployapproval"><a class="anker" href="#object-deployapproval">DeployApproval</a></h3> <p>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.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>object</code> (required)</td> <td><code>"deploy_approval"</code></td> <td></td> </tr> <tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the request; pass it as approval_id to approve_deploy or reject_deploy.</td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"pending"</code>, <code>"processing"</code>, <code>"approved"</code>, <code>"rejected"</code>, <code>"failed"</code>, <code>"superseded"</code>, <code>"expired"</code></td> <td>pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days.</td> </tr> <tr> <td><code>kind</code> (required)</td> <td><code>"new_site"</code>, <code>"site_update"</code>, <code>"site_delete"</code>, <code>"app_deploy"</code>, <code>"app_rollback"</code></td> <td>app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules.</td> </tr> <tr> <td><code>site_id</code> (required)</td> <td>string or null (uuid)</td> <td></td> </tr> <tr> <td><code>name</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>slug</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>url</code> (required)</td> <td>string or null</td> <td>The address of the site. For an update or deletion it still shows the live version until approval.</td> </tr> <tr> <td><code>files</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>bytes</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>password_change</code> (required)</td> <td><code>"none"</code>, <code>"remove"</code>, <code>"set"</code></td> <td></td> </tr> <tr> <td><code>requested_by</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>requested_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>expires_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>reviewed_by</code> (required)</td> <td>string or null (uuid)</td> <td></td> </tr> <tr> <td><code>reviewed_at</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>reason</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>result</code> (required)</td> <td>object or null</td> <td></td> </tr> </tbody></table></div> <h3 id="object-zonefileinput"><a class="anker" href="#object-zonefileinput">ZoneFileInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>zone_file</code> (required)</td> <td>string</td> <td>The zone file in BIND format, as exported from the current DNS provider (often called 'export zone' or 'download zone file'). At most 256 KB.</td> </tr> <tr> <td><code>replace_existing</code></td> <td>boolean</td> <td>Optional, default false. true: for every name and type in the file, records here that are not in the file are deleted. false: records are only added.</td> </tr> </tbody></table></div> <h3 id="object-dnsrestoreinput"><a class="anker" href="#object-dnsrestoreinput">DnsRestoreInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>snapshot_id</code></td> <td>string (uuid)</td> <td>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).</td> </tr> </tbody></table></div> <h3 id="object-domaintransferinput"><a class="anker" href="#object-domaintransferinput">DomainTransferInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>domain</code> (required)</td> <td>string</td> <td>The domain to move to Hypamoon, like example.com.</td> </tr> <tr> <td><code>authcode</code></td> <td>string</td> <td>The transfer code (auth code, EPP code; for .nl the token) the user got from their current registrar. Required for most extensions. Never stored.</td> </tr> <tr> <td><code>accept_registry_terms</code> (required)</td> <td>boolean</td> <td>Must be true: the user (the registrant) explicitly accepted the terms of the registry at registry_terms_url from check_domain.</td> </tr> <tr> <td><code>max_price_eur</code> (required)</td> <td>number</td> <td>The highest amount the user agreed to pay for the transfer. Refused if the price is higher.</td> </tr> <tr> <td><code>dns</code></td> <td><code>"hypamoon"</code>, <code>"keep"</code></td> <td>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.</td> </tr> <tr> <td><code>site_id</code></td> <td>string or null (uuid)</td> <td>Optional. A site of this project to serve on the domain once it is active.</td> </tr> <tr> <td><code>dkim_selectors</code></td> <td>string[]</td> <td>Optional. The DKIM selector(s) of the user's mail provider, so their DKIM records are copied too.</td> </tr> <tr> <td><code>ignore_preflight</code></td> <td>boolean</td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-domainnameserversinput"><a class="anker" href="#object-domainnameserversinput">DomainNameserversInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>use</code> (required)</td> <td><code>"hypamoon"</code>, <code>"previous"</code></td> <td>hypamoon: the nameservers of the DNS zone here. previous: back to the nameservers the domain had before (to undo the move).</td> </tr> <tr> <td><code>ignore_preflight</code></td> <td>boolean</td> <td>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).</td> </tr> </tbody></table></div> <h3 id="object-siteimportinput"><a class="anker" href="#object-siteimportinput">SiteImportInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>url</code> (required)</td> <td>string</td> <td>The https address of the existing site, on a domain this project connected (like <a href="https://www.example.com" rel="noopener">https://www.example.com</a>).</td> </tr> <tr> <td><code>site_id</code></td> <td>string (uuid)</td> <td>Optional. An existing site of this project to replace with the imported files. Without it, a new site is created.</td> </tr> <tr> <td><code>name</code></td> <td>string</td> <td>Optional. A name for the new site; default the host name.</td> </tr> <tr> <td><code>slug</code></td> <td>string</td> <td>Optional. The address of a new site, like deploy_site's slug.</td> </tr> <tr> <td><code>max_pages</code></td> <td>integer</td> <td>Optional. At most this many pages (default 25, at most 50).</td> </tr> </tbody></table></div> <h3 id="object-wordpressinspectinput"><a class="anker" href="#object-wordpressinspectinput">WordpressInspectInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>url</code> (required)</td> <td>string</td> <td>The https address of the WordPress site, like <a href="https://www.example.com" rel="noopener">https://www.example.com</a>. Any public site: nothing is changed and no login is used.</td> </tr> </tbody></table></div> <h3 id="object-wordpressimportinput"><a class="anker" href="#object-wordpressimportinput">WordpressImportInput</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>url</code> (required)</td> <td>string</td> <td>The https address of the WordPress site, on a domain this project has proven it owns (like <a href="https://www.example.com" rel="noopener">https://www.example.com</a>).</td> </tr> <tr> <td><code>site_id</code></td> <td>string (uuid)</td> <td>Optional. An existing site of this project to put the import in. Without it, a new site is created.</td> </tr> <tr> <td><code>keep_existing</code></td> <td>boolean</td> <td>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.</td> </tr> <tr> <td><code>if_match_version</code></td> <td>string</td> <td>With keep_existing: the version of the site from get_site or list_sites. If the site changed since, nothing changes (409).</td> </tr> <tr> <td><code>name</code></td> <td>string</td> <td>Optional. A name for the new site; default the site's own name.</td> </tr> <tr> <td><code>slug</code></td> <td>string</td> <td>Optional. The address of a new site, like deploy_site's slug.</td> </tr> <tr> <td><code>max_posts</code></td> <td>integer</td> <td>Optional. Read at most this many posts, newest first (default all, at most 500).</td> </tr> </tbody></table></div> <h3 id="object-siteuploadinput"><a class="anker" href="#object-siteuploadinput">SiteUploadInput</a></h3> <p>Ask for upload links for files that are too large to send inline.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>site_id</code></td> <td>string (uuid)</td> <td>Optional. The site the files are for, when you change an existing one (from deploy_site or list_sites). Leave it out for a new site.</td> </tr> <tr> <td><code>files</code> (required)</td> <td>object[]</td> <td>The files to upload, at most 200 per request.</td> </tr> </tbody></table></div> <h3 id="object-siteuploadlist"><a class="anker" href="#object-siteuploadlist">SiteUploadList</a></h3> <p>Upload links for large files.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td>object[]</td> <td>One upload link per file, in the order you sent them.</td> </tr> <tr> <td><code>hint</code> (required)</td> <td>string</td> <td>What to do when the PUT to upload_url fails from your environment (a sandbox may block it).</td> </tr> <tr> <td><code>limits</code> (required)</td> <td>object</td> <td>The site limits of this project's plan.</td> </tr> </tbody></table></div> <h3 id="object-sitedropinput"><a class="anker" href="#object-sitedropinput">SiteDropInput</a></h3> <p>Create a drop link: a page where the user uploads files and whole folders in a browser.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>site_id</code></td> <td>string (uuid)</td> <td>Optional. The site the files are for (from deploy_site or list_sites). Leave it out for a new site.</td> </tr> <tr> <td><code>strip</code></td> <td><code>"location"</code>, <code>"all"</code>, <code>"none"</code></td> <td>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.</td> </tr> <tr> <td><code>folder</code></td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>as_is</code></td> <td>boolean</td> <td>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/<folder>/.</td> </tr> </tbody></table></div> <h3 id="object-sitedrop"><a class="anker" href="#object-sitedrop">SiteDrop</a></h3> <p>A drop link and the files the user uploaded with it.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the drop link; pass it to get_drop (GET /sites/drops/{id}).</td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"open"</code>, <code>"done"</code>, <code>"expired"</code></td> <td>open: waiting for the user. done: the user uploaded the files. expired: 24 hours passed; create a new one.</td> </tr> <tr> <td><code>site_id</code> (required)</td> <td>string or null (uuid)</td> <td>The site the link is for, or null.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>expires_at</code> (required)</td> <td>string</td> <td>When the link stops working (24 hours after it was made).</td> </tr> <tr> <td><code>completed_at</code> (required)</td> <td>string or null</td> <td>When the user finished uploading, or null.</td> </tr> <tr> <td><code>images_only</code> (required)</td> <td>boolean</td> <td>True when the link only takes images (an editor made it).</td> </tr> <tr> <td><code>as_is</code></td> <td>boolean</td> <td>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.</td> </tr> <tr> <td><code>strip</code> (required)</td> <td><code>"location"</code>, <code>"all"</code>, <code>"none"</code></td> <td>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.</td> </tr> <tr> <td><code>folder</code> (required)</td> <td>string or null</td> <td>The folder under /media/ the files land in, or null for the user's own folder names.</td> </tr> <tr> <td><code>max_files</code> (required)</td> <td>integer</td> <td>How many files the user can add with this link (a photo with its versions counts once).</td> </tr> <tr> <td><code>files</code> (required)</td> <td>object[]</td> <td>The files that arrived, in order. Private originals are not listed.</td> </tr> <tr> <td><code>originals_kept</code> (required)</td> <td>integer</td> <td>How many originals were kept. They are private: never on a site, only downloadable in the portal.</td> </tr> <tr> <td><code>site_version</code> (required)</td> <td>object or null</td> <td>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.</td> </tr> <tr> <td><code>deploy_files</code> (required)</td> <td>object[]</td> <td>The same files in the form deploy_site takes: add them to files, without content.</td> </tr> <tr> <td><code>url</code></td> <td>string</td> <td>Only when the link is created: the page to give the user. It is not shown again.</td> </tr> <tr> <td><code>user_message</code></td> <td>string</td> <td>A sentence to give the user.</td> </tr> <tr> <td><code>next_step</code></td> <td>string</td> <td>What to do next.</td> </tr> </tbody></table></div> <h3 id="object-mediafile"><a class="anker" href="#object-mediafile">MediaFile</a></h3> <p>A file in the project's media library.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the file in the library.</td> </tr> <tr> <td><code>path</code> (required)</td> <td>string</td> <td>Where the file goes on a site when you add it with deploy_site media, like /media/portfolio/project-12/03.webp.</td> </tr> <tr> <td><code>folder</code> (required)</td> <td>string</td> <td>The folder under /media/, empty for /media/ itself.</td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td>The file name in the library.</td> </tr> <tr> <td><code>original_name</code> (required)</td> <td>string or null</td> <td>The name on the user's computer, when it differs.</td> </tr> <tr> <td><code>content_type</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>bytes</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>width</code> (required)</td> <td>integer or null</td> <td></td> </tr> <tr> <td><code>height</code> (required)</td> <td>integer or null</td> <td></td> </tr> <tr> <td><code>alt</code> (required)</td> <td>string or null</td> <td>The alt text, set with update_media.</td> </tr> <tr> <td><code>url</code> (required)</td> <td>string or null</td> <td>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.</td> </tr> <tr> <td><code>variants</code> (required)</td> <td>object</td> <td>For photos: 640, the small version for srcset and thumbnails.</td> </tr> <tr> <td><code>srcset</code> (required)</td> <td>string or null</td> <td>A ready srcset for an img tag, once both versions are on a site.</td> </tr> <tr> <td><code>original_kept</code> (required)</td> <td>boolean</td> <td>The full-size original is kept privately. It never goes on a site; the user downloads it in the portal.</td> </tr> <tr> <td><code>sites</code> (required)</td> <td>object[]</td> <td>The sites that use this file right now, with the path and url there.</td> </tr> <tr> <td><code>source</code> (required)</td> <td><code>"upload"</code>, <code>"portaal"</code>, <code>"site"</code>, <code>"import"</code></td> <td>upload: an upload link; portaal: the portal; site: it was already on a site; import: an import.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>updated_at</code> (required)</td> <td>string</td> <td></td> </tr> </tbody></table></div> <h3 id="object-medialist"><a class="anker" href="#object-medialist">MediaList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-mediafile">MediaFile</a>[]</td> <td></td> </tr> <tr> <td><code>folders</code> (required)</td> <td>object[]</td> <td>Every folder in the library with its number of files (empty is /media/ itself).</td> </tr> <tr> <td><code>next_cursor</code> (required)</td> <td>string or null</td> <td>Pass it as cursor for the next page; null when there is no more.</td> </tr> </tbody></table></div> <h3 id="object-mediaupdate"><a class="anker" href="#object-mediaupdate">MediaUpdate</a></h3> <p>Rename or move a file in the library, or set its alt text. Sites keep the file under the path they have until you deploy again.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>name</code></td> <td>string</td> <td>A new file name. The extension always stays that of the file.</td> </tr> <tr> <td><code>folder</code></td> <td>string or null</td> <td>Move the file to this folder under /media/; null or an empty string for /media/ itself.</td> </tr> <tr> <td><code>alt</code></td> <td>string or null</td> <td>The alt text for the image; null removes it.</td> </tr> </tbody></table></div> <h3 id="object-medialinkinput"><a class="anker" href="#object-medialinkinput">MediaLinkInput</a></h3> <p>Create an upload page for the media library.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>folder</code></td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>site_id</code></td> <td>string (uuid)</td> <td>Optional. With a site, Done also puts the files on that site as a new version.</td> </tr> <tr> <td><code>images_only</code></td> <td>boolean</td> <td>Optional. true: the page only takes images.</td> </tr> <tr> <td><code>strip</code></td> <td><code>"location"</code>, <code>"all"</code>, <code>"none"</code></td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-sitefile"><a class="anker" href="#object-sitefile">SiteFile</a></h3> <p>One file of a site, without its content.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>path</code> (required)</td> <td>string</td> <td>The path of the file on the site, like /index.html.</td> </tr> <tr> <td><code>content_type</code> (required)</td> <td>string</td> <td>The type it is served with, like text/html.</td> </tr> <tr> <td><code>bytes</code> (required)</td> <td>integer</td> <td>The size of the file in bytes.</td> </tr> <tr> <td><code>updated_at</code> (required)</td> <td>string (date-time)</td> <td>When the file was last written.</td> </tr> </tbody></table></div> <h3 id="object-sitelist"><a class="anker" href="#object-sitelist">SiteList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-site">Site</a>[]</td> <td>The sites of the project, newest first. Deleted sites are not listed.</td> </tr> </tbody></table></div> <h3 id="object-deletedsite"><a class="anker" href="#object-deletedsite">DeletedSite</a></h3> <p>A deleted site that can still be restored.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the site. Send it to POST /sites/{id}/restore.</td> </tr> <tr> <td><code>slug</code> (required)</td> <td>string</td> <td>The address it had and will get back: https://<slug>.hypamoon.app. It stays reserved for this project.</td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td>Your name for the site.</td> </tr> <tr> <td><code>deleted_at</code> (required)</td> <td>string (date-time)</td> <td>When the site was deleted.</td> </tr> <tr> <td><code>restore_until</code> (required)</td> <td>string (date-time)</td> <td>Until when the site can be restored. After that its files are erased for good.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true for a site of a test project.</td> </tr> </tbody></table></div> <h3 id="object-deletedsitelist"><a class="anker" href="#object-deletedsitelist">DeletedSiteList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-deletedsite">DeletedSite</a>[]</td> <td>The deleted sites that can still be restored, most recently deleted first.</td> </tr> </tbody></table></div> <h3 id="object-restoredsite"><a class="anker" href="#object-restoredsite">RestoredSite</a></h3> <p>The restored site, live again at its old address.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the site. Use it to update or delete the site.</td> </tr> <tr> <td><code>slug</code> (required)</td> <td>string</td> <td>The first part of the address: https://<slug>.hypamoon.app.</td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td>Your name for the site.</td> </tr> <tr> <td><code>url</code> (required)</td> <td>string</td> <td>The public address of the site, live right away.</td> </tr> <tr> <td><code>files</code> (required)</td> <td><a href="#object-sitefile">SiteFile</a>[]</td> <td>The files of the site, sorted by path, without their content.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the site was created.</td> </tr> <tr> <td><code>updated_at</code> (required)</td> <td>string (date-time)</td> <td>When the site or its files last changed.</td> </tr> <tr> <td><code>version</code> (required)</td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>password_protected</code> (required)</td> <td>boolean</td> <td>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.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true for a site of a test project. It is public all the same.</td> </tr> <tr> <td><code>taken_offline</code> (required)</td> <td>object or null</td> <td>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 <a href="mailto:hello@hypamoon.com">hello@hypamoon.com</a>.</td> </tr> <tr> <td><code>warnings</code></td> <td>one of[]</td> <td>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.</td> </tr> <tr> <td><code>visibility</code></td> <td><code>"public"</code>, <code>"unlisted"</code></td> <td>public or unlisted, as before the site was deleted.</td> </tr> <tr> <td><code>restore_notes</code> (required)</td> <td>string[]</td> <td>What did not come back with the site (an app on it, a custom domain), and how to set it up again.</td> </tr> </tbody></table></div> <h3 id="object-collectioninput"><a class="anker" href="#object-collectioninput">CollectionInput</a></h3> <p>A new collection.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>name</code> (required)</td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>description</code></td> <td>string</td> <td>What the collection is for, for your own overview.</td> </tr> <tr> <td><code>access</code></td> <td>object</td> <td>Who may use the collection besides your API key. Leave it out for private: all false.</td> </tr> <tr> <td><code>triggers</code></td> <td>object</td> <td>What happens when a document is added, through the API or a form.</td> </tr> <tr> <td><code>email_routing</code></td> <td>object</td> <td>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.</td> </tr> <tr> <td><code>schema</code></td> <td>object</td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-collectionupdate"><a class="anker" href="#object-collectionupdate">CollectionUpdate</a></h3> <p>Change a collection. Fields you leave out stay as they are.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>name</code></td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>description</code></td> <td>string or null</td> <td>What the collection is for, for your own overview.</td> </tr> <tr> <td><code>access</code></td> <td>object</td> <td>Who may use the collection besides your API key. Leave it out for private: all false.</td> </tr> <tr> <td><code>triggers</code></td> <td>object</td> <td>What happens when a document is added, through the API or a form.</td> </tr> <tr> <td><code>email_routing</code></td> <td>object or null</td> <td>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.</td> </tr> <tr> <td><code>schema</code></td> <td>object or null</td> <td>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.</td> </tr> </tbody></table></div> <h3 id="object-collection"><a class="anker" href="#object-collection">Collection</a></h3> <p>A collection of JSON documents: the data layer of a project.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the collection. Its name works in the path as well.</td> </tr> <tr> <td><code>name</code> (required)</td> <td>string</td> <td>The name, unique in the project. A form posts to it by this name.</td> </tr> <tr> <td><code>description</code> (required)</td> <td>string or null</td> <td>What the collection is for, or null.</td> </tr> <tr> <td><code>access</code> (required)</td> <td>object</td> <td>Who may use the collection besides your API key. All false is private.</td> </tr> <tr> <td><code>triggers</code> (required)</td> <td>object</td> <td></td> </tr> <tr> <td><code>email_routing</code> (required)</td> <td>object or null</td> <td>Where the email of email_on_insert goes. null: the billing email address.</td> </tr> <tr> <td><code>schema</code> (required)</td> <td>object or null</td> <td>The JSON Schema every document must match, or null.</td> </tr> <tr> <td><code>personal_data</code></td> <td>boolean</td> <td>true for a collection with personal data imported from WordPress (form entries, orders, customers, users, reviews). It always stays private.</td> </tr> <tr> <td><code>document_count</code> (required)</td> <td>integer</td> <td>How many documents the collection has now.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the collection was created.</td> </tr> <tr> <td><code>updated_at</code> (required)</td> <td>string (date-time)</td> <td>When the collection last changed.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true for a collection of a test project.</td> </tr> </tbody></table></div> <h3 id="object-collectionlist"><a class="anker" href="#object-collectionlist">CollectionList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-collection">Collection</a>[]</td> <td>The collections of the project, newest first. Deleted collections are not listed.</td> </tr> </tbody></table></div> <h3 id="object-documentinput"><a class="anker" href="#object-documentinput">DocumentInput</a></h3> <p>A new document.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td>object</td> <td>The document: any JSON object, at most 64 kB.</td> </tr> </tbody></table></div> <h3 id="object-documentupdate"><a class="anker" href="#object-documentupdate">DocumentUpdate</a></h3> <p>Change a document: send data to replace it, or merge to change some fields. Not both.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code></td> <td>object</td> <td>The document: any JSON object, at most 64 kB.</td> </tr> <tr> <td><code>merge</code></td> <td>object</td> <td>Merged into the document as a JSON Merge Patch (RFC 7396): a field you send replaces that field, null removes it, an object is merged field by field.</td> </tr> </tbody></table></div> <h3 id="object-document"><a class="anker" href="#object-document">Document</a></h3> <p>A document in a collection.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the document.</td> </tr> <tr> <td><code>collection_id</code> (required)</td> <td>string (uuid)</td> <td>The id of its collection.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td>The document itself: a JSON object.</td> </tr> <tr> <td><code>source</code> (required)</td> <td><code>"api"</code>, <code>"public"</code></td> <td>api: added with an API key. public: sent from a form on a website.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the document was added.</td> </tr> <tr> <td><code>updated_at</code> (required)</td> <td>string (date-time)</td> <td>When the document last changed.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true for a document of a test project. No email was sent for it.</td> </tr> </tbody></table></div> <h3 id="object-documentlist"><a class="anker" href="#object-documentlist">DocumentList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-document">Document</a>[]</td> <td>The documents, newest first.</td> </tr> <tr> <td><code>has_more</code> (required)</td> <td>boolean</td> <td>Whether there is another page.</td> </tr> <tr> <td><code>next_cursor</code> (required)</td> <td>string or null</td> <td>Pass this as cursor to get the next page.</td> </tr> </tbody></table></div> <h3 id="object-publicdocumentinput"><a class="anker" href="#object-publicdocumentinput">PublicDocumentInput</a></h3> <p>A document from a form on a website.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td>object</td> <td>The fields of the form as a JSON object, like { "name": "Anna", "email": "<a href="mailto:anna@example.com">anna@example.com</a>", "message": "..." }.</td> </tr> <tr> <td><code>turnstile_token</code></td> <td>string</td> <td>The bot-check token from the challenge frame (GET /public/challenge). Required; form.js does this for you.</td> </tr> <tr> <td><code>honeypot</code></td> <td>string</td> <td>The value of a hidden field that people leave empty. When it is filled in, the answer is 201 but nothing is stored.</td> </tr> </tbody></table></div> <h3 id="object-publicdocumentcreated"><a class="anker" href="#object-publicdocumentcreated">PublicDocumentCreated</a></h3> <p>A form entry was stored. The data is not sent back.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the new document.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When it was stored.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true when the public token is a test token: stored, but no email is sent.</td> </tr> </tbody></table></div> <h3 id="object-publicdocumentlist"><a class="anker" href="#object-publicdocumentlist">PublicDocumentList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td>object[]</td> <td>The newest documents, newest first: only data and created_at.</td> </tr> </tbody></table></div> <h3 id="object-publicchatconfig"><a class="anker" href="#object-publicchatconfig">PublicChatConfig</a></h3> <p>What the chat widget needs before a conversation starts.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>name</code> (required)</td> <td>string</td> <td>The name of the project, as the widget shows it.</td> </tr> <tr> <td><code>greeting</code> (required)</td> <td>string</td> <td>The fixed greeting, in the language the assistant opens in.</td> </tr> <tr> <td><code>language</code> (required)</td> <td>string</td> <td>The language the assistant opens in (nl, en, de, fr or es).</td> </tr> <tr> <td><code>languages</code> (required)</td> <td>string[]</td> <td>The languages of the assistant.</td> </tr> <tr> <td><code>mode</code> (required)</td> <td><code>"live"</code>, <code>"test"</code></td> <td>live or test, from the token.</td> </tr> <tr> <td><code>turnstile</code> (required)</td> <td>boolean</td> <td>Whether POST /public/conversations needs a turnstile_token.</td> </tr> <tr> <td><code>max_message_length</code> (required)</td> <td>integer</td> <td>The longest message a visitor can send, in characters.</td> </tr> <tr> <td><code>max_turns</code> (required)</td> <td>integer</td> <td>After this many turns the conversation closes.</td> </tr> </tbody></table></div> <h3 id="object-publicconversationinput"><a class="anker" href="#object-publicconversationinput">PublicConversationInput</a></h3> <p>Start a conversation from the chat widget.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>turnstile_token</code></td> <td>string</td> <td>The bot-check token from the challenge frame. Required when the public token has turnstile on (the default); the widget does this for you.</td> </tr> </tbody></table></div> <h3 id="object-publicconversation"><a class="anker" href="#object-publicconversation">PublicConversation</a></h3> <p>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}.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string (uuid)</td> <td>The id of the conversation.</td> </tr> <tr> <td><code>channel</code> (required)</td> <td><code>"widget"</code></td> <td>Always widget: started through a public chat token.</td> </tr> <tr> <td><code>status</code> (required)</td> <td><code>"open"</code>, <code>"closed"</code></td> <td>open: takes messages. closed: takes no more messages.</td> </tr> <tr> <td><code>language</code> (required)</td> <td>string</td> <td>The language the assistant opened in.</td> </tr> <tr> <td><code>turns</code> (required)</td> <td>integer</td> <td>Visitor messages answered so far.</td> </tr> <tr> <td><code>max_turns</code> (required)</td> <td>integer</td> <td>After this many turns the conversation closes (close_reason turn_limit).</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the conversation started.</td> </tr> <tr> <td><code>last_message_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the last message was sent.</td> </tr> <tr> <td><code>closed_at</code> (required)</td> <td>string or null (date-time)</td> <td>When the conversation closed.</td> </tr> <tr> <td><code>close_reason</code> (required)</td> <td><code>"closed"</code>, <code>"inactive"</code>, <code>"turn_limit"</code>, <code>null</code></td> <td>Why it closed: inactive after 30 minutes without a message, or turn_limit.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true for a conversation through a test token (hm_pubtest_).</td> </tr> </tbody></table></div> <h3 id="object-publicconversationstart"><a class="anker" href="#object-publicconversationstart">PublicConversationStart</a></h3> <p>A new conversation from the widget, with its secret.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>conversation</code> (required)</td> <td><a href="#object-publicconversation">PublicConversation</a></td> <td>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}.</td> </tr> <tr> <td><code>messages</code> (required)</td> <td><a href="#object-chatmessage">ChatMessage</a>[]</td> <td>The fixed greeting, as the first message.</td> </tr> <tr> <td><code>conversation_token</code> (required)</td> <td>string</td> <td>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.</td> </tr> <tr> <td><code>expires_at</code> (required)</td> <td>string (date-time)</td> <td>When conversation_token stops working (6 hours after the start).</td> </tr> </tbody></table></div> <h3 id="object-publicconversationdetail"><a class="anker" href="#object-publicconversationdetail">PublicConversationDetail</a></h3> <p>The visitor's own conversation with its messages.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>conversation</code> (required)</td> <td><a href="#object-publicconversation">PublicConversation</a></td> <td>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}.</td> </tr> <tr> <td><code>messages</code> (required)</td> <td><a href="#object-chatmessage">ChatMessage</a>[]</td> <td>All messages, oldest first, starting with the greeting.</td> </tr> </tbody></table></div> <h3 id="object-publicchatturn"><a class="anker" href="#object-publicchatturn">PublicChatTurn</a></h3> <p>One turn in the widget: the visitor's message and the assistant's reply.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>message</code> (required)</td> <td>object</td> <td>The visitor's message as stored.</td> </tr> <tr> <td><code>reply</code> (required)</td> <td>object</td> <td>The assistant's reply. Show this text.</td> </tr> <tr> <td><code>conversation</code> (required)</td> <td>object</td> <td>The conversation after this turn. status closed means the turn limit was reached.</td> </tr> </tbody></table></div> <h3 id="object-balance"><a class="anker" href="#object-balance">Balance</a></h3> <p>The prepaid balance with the latest transactions.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>balance_eur</code> (required)</td> <td>number</td> <td>The prepaid balance in euro, excluding VAT. Can be slightly below zero after a call that ran on.</td> </tr> <tr> <td><code>currency</code> (required)</td> <td><code>"EUR"</code></td> <td>Always EUR.</td> </tr> <tr> <td><code>spent_this_month_eur</code> (required)</td> <td>number</td> <td>What was deducted this calendar month (minus refunds). Counts toward the spending cap.</td> </tr> <tr> <td><code>spend_cap_eur</code> (required)</td> <td>number or null</td> <td>The spending cap per calendar month, or null without one.</td> </tr> <tr> <td><code>auto_reload</code> (required)</td> <td><a href="#object-autoreload">AutoReload</a></td> <td>Automatic top-up with a saved card.</td> </tr> <tr> <td><code>transactions</code> (required)</td> <td><a href="#object-transaction">Transaction</a>[]</td> <td>The latest 20 transactions, newest first.</td> </tr> <tr> <td><code>test</code> (required)</td> <td>boolean</td> <td>true with a test key: you see the balance of the live project, read-only. Test mode is free and never touches the balance.</td> </tr> </tbody></table></div> <h3 id="object-transaction"><a class="anker" href="#object-transaction">Transaction</a></h3> <p>One line in the ledger of the prepaid balance. The same shape in GET /balance and the balance webhooks.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the transaction.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"top_up"</code>, <code>"charge"</code>, <code>"refund"</code>, <code>"adjustment"</code></td> <td>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.</td> </tr> <tr> <td><code>source</code> (required)</td> <td><code>"payment"</code>, <code>"sms"</code>, <code>"call"</code>, <code>"number"</code>, <code>"chat"</code>, <code>"site"</code>, <code>"support"</code></td> <td>What caused it: a payment, an SMS, a call, a number, a chat reply of the assistant, site requests above what the plan includes, or Hypamoon support.</td> </tr> <tr> <td><code>amount_eur</code> (required)</td> <td>number</td> <td>The amount in euro excluding VAT, with a sign: positive for a top-up or refund, negative for a charge. Up to four decimals.</td> </tr> <tr> <td><code>balance_after_eur</code> (required)</td> <td>number</td> <td>The balance right after this transaction, in euro excluding VAT.</td> </tr> <tr> <td><code>description</code> (required)</td> <td>string</td> <td>What it was for, in a few words.</td> </tr> <tr> <td><code>quantity</code> (required)</td> <td>number or null</td> <td>How many units, like 2 SMS segments or 3 minutes. Null for a top-up.</td> </tr> <tr> <td><code>unit</code> (required)</td> <td>string or null</td> <td>The unit of quantity, like segment, minute or month. Null for a top-up.</td> </tr> <tr> <td><code>reference_id</code> (required)</td> <td>string or null</td> <td>The id of what caused it: a message, a call, a number or a payment.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the transaction was booked.</td> </tr> </tbody></table></div> <h3 id="object-autoreload"><a class="anker" href="#object-autoreload">AutoReload</a></h3> <p>Automatic top-up with a saved card.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>enabled</code> (required)</td> <td>boolean</td> <td>Whether automatic top-up is switched on. Change it in the portal.</td> </tr> <tr> <td><code>active</code> (required)</td> <td>boolean</td> <td>Whether it can run now: switched on, with a saved card that has not expired.</td> </tr> <tr> <td><code>threshold_eur</code> (required)</td> <td>number</td> <td>When the balance drops below this amount, a top-up starts.</td> </tr> <tr> <td><code>amount_eur</code> (required)</td> <td>number</td> <td>How much each automatic top-up adds, excluding VAT.</td> </tr> <tr> <td><code>max_per_month</code> (required)</td> <td>integer</td> <td>The most automatic top-ups per calendar month.</td> </tr> <tr> <td><code>used_this_month</code> (required)</td> <td>integer</td> <td>How many automatic top-ups ran this calendar month.</td> </tr> <tr> <td><code>disabled_reason</code> (required)</td> <td>string or null</td> <td>Why it is off or cannot run: no_card, card_expired, repeated_failures or turned_off. Null when it is active.</td> </tr> <tr> <td><code>card</code> (required)</td> <td>object or null</td> <td>The saved card used for automatic top-up, or null without one.</td> </tr> </tbody></table></div> <h3 id="object-reload"><a class="anker" href="#object-reload">Reload</a></h3> <p>An automatic top-up that failed.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the top-up payment.</td> </tr> <tr> <td><code>amount_eur</code> (required)</td> <td>number</td> <td>The amount it tried to add, excluding VAT.</td> </tr> <tr> <td><code>status</code> (required)</td> <td>string</td> <td>The status of the payment, like failed, canceled or expired.</td> </tr> <tr> <td><code>failure_reason</code> (required)</td> <td>string or null</td> <td>A short code for why it failed, or null.</td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the top-up started.</td> </tr> </tbody></table></div> <h3 id="object-balancelowevent"><a class="anker" href="#object-balancelowevent">BalanceLowEvent</a></h3> <p>Sent when a charge takes the balance below the automatic top-up threshold. Carries balance_eur and threshold_eur.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"balance.low"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-balancetoppedupevent"><a class="anker" href="#object-balancetoppedupevent">BalanceToppedUpEvent</a></h3> <p>Sent when a top-up was paid and added to the balance, by hand or automatically. Carries balance_eur and transaction.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"balance.topped_up"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-balancereloadfailedevent"><a class="anker" href="#object-balancereloadfailedevent">BalanceReloadFailedEvent</a></h3> <p>Sent when an automatic top-up failed. Carries balance_eur, threshold_eur and reload. After three failures in a row automatic top-up is switched off.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"balance.reload_failed"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-documentcreatedevent"><a class="anker" href="#object-documentcreatedevent">DocumentCreatedEvent</a></h3> <p>Sent for every new document in a collection with webhook_on_insert, from the API or a form. A document deleted before delivery is not sent.</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td>The id of the event, evt_ plus 32 hex characters. It stays the same on retries.</td> </tr> <tr> <td><code>type</code> (required)</td> <td><code>"document.created"</code></td> <td></td> </tr> <tr> <td><code>created_at</code> (required)</td> <td>string (date-time)</td> <td>When the event happened.</td> </tr> <tr> <td><code>data</code> (required)</td> <td>object</td> <td></td> </tr> </tbody></table></div> <h3 id="object-problem"><a class="anker" href="#object-problem">Problem</a></h3> <p>An error, as application/problem+json (RFC 9457).</p> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>type</code> (required)</td> <td>string (uri)</td> <td>A URL that explains the error and the fix: <a href="https://hypamoon.com/docs/errors/">https://hypamoon.com/docs/errors/</a><slug>.</td> </tr> <tr> <td><code>title</code> (required)</td> <td>string</td> <td>The HTTP status in words.</td> </tr> <tr> <td><code>status</code> (required)</td> <td>integer</td> <td>The HTTP status.</td> </tr> <tr> <td><code>detail</code> (required)</td> <td>string</td> <td>What went wrong in this request.</td> </tr> <tr> <td><code>request_id</code> (required)</td> <td>string</td> <td>The id of this request, also in the X-Request-Id header. Mention it when you contact support.</td> </tr> <tr> <td><code>user_message</code> (required)</td> <td>string</td> <td>One calm sentence in plain English for the end user, without technical terms. A chat or app can show it as it is.</td> </tr> <tr> <td><code>next_step</code></td> <td>string</td> <td>With some errors: what the chat or agent itself should do next.</td> </tr> <tr> <td><code>errors</code></td> <td><a href="#object-fielderror">FieldError</a>[]</td> <td>With validation_failed, schema_mismatch and team_targets_not_allowed: every problem.</td> </tr> <tr> <td><code>missing</code></td> <td>string[]</td> <td>With company_details_missing: the missing fields, as paths of PUT /project/company.</td> </tr> <tr> <td><code>current_version</code></td> <td>one of</td> <td>With version_conflict: the version that is there now (a number on a call plan, a string on a site; send it as if_match_version).</td> </tr> <tr> <td><code>spend_cap_eur</code></td> <td>number</td> <td>With billing_blocked: the spending cap per calendar month.</td> </tr> <tr> <td><code>spent_eur</code></td> <td>number</td> <td>With billing_blocked: what was spent this calendar month.</td> </tr> <tr> <td><code>balance_eur</code></td> <td>number</td> <td>With insufficient_balance: the prepaid balance now.</td> </tr> <tr> <td><code>required_eur</code></td> <td>number</td> <td>With insufficient_balance: what this request costs.</td> </tr> <tr> <td><code>auto_reload_started</code></td> <td>boolean</td> <td>With insufficient_balance: true when an automatic top-up just started; retry in a minute.</td> </tr> </tbody></table></div> <h3 id="object-fielderror"><a class="anker" href="#object-fielderror">FieldError</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>path</code> (required)</td> <td>string</td> <td>Where the problem is, with dots, like address.postal_code. body means the body as a whole.</td> </tr> <tr> <td><code>message</code> (required)</td> <td>string</td> <td>What is wrong, in English.</td> </tr> </tbody></table></div> <h3 id="object-recenterror"><a class="anker" href="#object-recenterror">RecentError</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>id</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>at</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>source</code> (required)</td> <td><code>"mcp"</code>, <code>"rest"</code></td> <td></td> </tr> <tr> <td><code>client</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>tool</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>route</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>error</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>status</code> (required)</td> <td>integer</td> <td></td> </tr> <tr> <td><code>detail</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>duration_ms</code> (required)</td> <td>integer or null</td> <td></td> </tr> <tr> <td><code>request_id</code> (required)</td> <td>string</td> <td></td> </tr> <tr> <td><code>mode</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>arguments</code> (required)</td> <td>object or null</td> <td></td> </tr> <tr> <td><code>field_errors</code> (required)</td> <td>object[]</td> <td></td> </tr> </tbody></table></div> <h3 id="object-recenterrorlist"><a class="anker" href="#object-recenterrorlist">RecentErrorList</a></h3> <div class="tabel"><table> <thead> <tr> <th>Field</th> <th>Type</th> <th></th> </tr> </thead> <tbody><tr> <td><code>data</code> (required)</td> <td><a href="#object-recenterror">RecentError</a>[]</td> <td></td> </tr> <tr> <td><code>next_before</code> (required)</td> <td>string or null</td> <td></td> </tr> <tr> <td><code>dropped_today</code> (required)</td> <td>integer</td> <td></td> </tr> </tbody></table></div> </article> <nav class="bladeren" aria-label="Pages"><a class="vorige" href="/docs/errors"><span>Previous</span>Errors</a><span></span></nav> <footer class="voet"><span>Hypamoon is the infrastructure your chat or agent builds on for a business. Hypamoon API, version 1.</span><a href="/">hypamoon.com</a></footer> </main> <nav class="toc" aria-label="On this page"><p class="toc-kop">On this page</p><ul><li class="d2"><a href="#endpoints">Endpoints</a></li><li class="d2"><a href="#errors-on-every-endpoint">Errors on every endpoint</a></li><li class="d2"><a href="#domains">Domains</a></li><li class="d3"><a href="#get-domains">GET /domains</a></li><li class="d3"><a href="#post-domains">POST /domains</a></li><li class="d3"><a href="#get-domains-health">GET /domains/health</a></li><li class="d3"><a href="#post-domains-watch">POST /domains/watch</a></li><li class="d3"><a href="#get-domains-id-dns-cleanup">GET /domains/{id}/dns-cleanup</a></li><li class="d3"><a href="#post-domains-id-dns-cleanup">POST /domains/{id}/dns-cleanup</a></li><li class="d3"><a href="#post-domains-id-dns-cleanup-restore">POST /domains/{id}/dns-cleanup/restore</a></li><li class="d3"><a href="#get-domains-check">GET /domains/check</a></li><li class="d3"><a href="#get-domains-suggestions">GET /domains/suggestions</a></li><li class="d3"><a href="#post-domains-register">POST /domains/register</a></li><li class="d3"><a href="#get-domains-id">GET /domains/{id}</a></li><li class="d3"><a href="#patch-domains-id">PATCH /domains/{id}</a></li><li class="d3"><a href="#delete-domains-id">DELETE /domains/{id}</a></li><li class="d3"><a href="#post-domains-id-activate">POST /domains/{id}/activate</a></li><li class="d3"><a href="#put-domains-id-site">PUT /domains/{id}/site</a></li><li class="d3"><a href="#post-domains-id-authcode">POST /domains/{id}/authcode</a></li><li class="d3"><a href="#get-domains-id-dns-records">GET /domains/{id}/dns-records</a></li><li class="d3"><a href="#post-domains-id-dns-records">POST /domains/{id}/dns-records</a></li><li class="d3"><a href="#post-domains-id-dns-records-undo">POST /domains/{id}/dns-records/undo</a></li><li class="d3"><a href="#get-domains-diagnose">GET /domains/diagnose</a></li><li class="d3"><a href="#post-domains-id-zone-file">POST /domains/{id}/zone-file</a></li><li class="d3"><a href="#get-domains-id-preflight">GET /domains/{id}/preflight</a></li><li class="d3"><a href="#post-domains-id-restore">POST /domains/{id}/restore</a></li><li class="d3"><a href="#post-domains-id-nameservers">POST /domains/{id}/nameservers</a></li><li class="d3"><a href="#post-domains-transfer">POST /domains/transfer</a></li><li class="d2"><a href="#project">Project</a></li><li class="d3"><a href="#get-project">GET /project</a></li><li class="d3"><a href="#put-project-company">PUT /project/company</a></li><li class="d3"><a href="#get-advice">GET /advice</a></li><li class="d3"><a href="#post-project-move-to-live">POST /project/move-to-live</a></li><li class="d3"><a href="#post-project-move-to-live-undo">POST /project/move-to-live/undo</a></li><li class="d3"><a href="#get-errors">GET /errors</a></li><li class="d2"><a href="#billing">Billing</a></li><li class="d3"><a href="#get-balance">GET /balance</a></li><li class="d2"><a href="#numbers">Numbers</a></li><li class="d3"><a href="#get-numbers">GET /numbers</a></li><li class="d3"><a href="#post-numbers">POST /numbers</a></li><li class="d3"><a href="#get-numbers-available">GET /numbers/available</a></li><li class="d3"><a href="#delete-numbers-id">DELETE /numbers/{id}</a></li><li class="d2"><a href="#assistant">Assistant</a></li><li class="d3"><a href="#get-assistant">GET /assistant</a></li><li class="d3"><a href="#put-assistant">PUT /assistant</a></li><li class="d2"><a href="#call-plans">Call plans</a></li><li class="d3"><a href="#get-numbers-id-call-plan">GET /numbers/{id}/call-plan</a></li><li class="d3"><a href="#put-numbers-id-call-plan">PUT /numbers/{id}/call-plan</a></li><li class="d2"><a href="#calls">Calls</a></li><li class="d3"><a href="#get-calls">GET /calls</a></li><li class="d3"><a href="#post-calls">POST /calls</a></li><li class="d3"><a href="#get-calls-id">GET /calls/{id}</a></li><li class="d3"><a href="#get-call-consents">GET /call-consents</a></li><li class="d3"><a href="#post-call-consents">POST /call-consents</a></li><li class="d3"><a href="#delete-call-consents-id">DELETE /call-consents/{id}</a></li><li class="d2"><a href="#webhooks">Webhooks</a></li><li class="d3"><a href="#get-webhooks">GET /webhooks</a></li><li class="d3"><a href="#post-webhooks">POST /webhooks</a></li><li class="d3"><a href="#delete-webhooks-id">DELETE /webhooks/{id}</a></li><li class="d3"><a href="#post-webhooks-id-enable">POST /webhooks/{id}/enable</a></li><li class="d3"><a href="#post-webhooks-id-test">POST /webhooks/{id}/test</a></li><li class="d2"><a href="#messages">Messages</a></li><li class="d3"><a href="#get-messages">GET /messages</a></li><li class="d3"><a href="#post-messages">POST /messages</a></li><li class="d3"><a href="#get-messages-id">GET /messages/{id}</a></li><li class="d2"><a href="#chat">Chat</a></li><li class="d3"><a href="#get-conversations">GET /conversations</a></li><li class="d3"><a href="#post-conversations">POST /conversations</a></li><li class="d3"><a href="#get-conversations-id">GET /conversations/{id}</a></li><li class="d3"><a href="#post-conversations-id-messages">POST /conversations/{id}/messages</a></li><li class="d3"><a href="#post-conversations-id-close">POST /conversations/{id}/close</a></li><li class="d2"><a href="#chat-widget">Chat widget</a></li><li class="d3"><a href="#get-chat-tokens">GET /chat-tokens</a></li><li class="d3"><a href="#post-chat-tokens">POST /chat-tokens</a></li><li class="d3"><a href="#get-chat-tokens-id">GET /chat-tokens/{id}</a></li><li class="d3"><a href="#patch-chat-tokens-id">PATCH /chat-tokens/{id}</a></li><li class="d3"><a href="#delete-chat-tokens-id">DELETE /chat-tokens/{id}</a></li><li class="d3"><a href="#get-public-config">GET /public/config</a></li><li class="d3"><a href="#post-public-conversations">POST /public/conversations</a></li><li class="d3"><a href="#get-public-conversations-id">GET /public/conversations/{id}</a></li><li class="d3"><a href="#post-public-conversations-id-messages">POST /public/conversations/{id}/messages</a></li><li class="d3"><a href="#get-public-challenge">GET /public/challenge</a></li><li class="d2"><a href="#sites">Sites</a></li><li class="d3"><a href="#get-sites">GET /sites</a></li><li class="d3"><a href="#post-sites">POST /sites</a></li><li class="d3"><a href="#post-sites-uploads">POST /sites/uploads</a></li><li class="d3"><a href="#post-sites-drops">POST /sites/drops</a></li><li class="d3"><a href="#get-sites-drops-id">GET /sites/drops/{id}</a></li><li class="d3"><a href="#get-sites-id">GET /sites/{id}</a></li><li class="d3"><a href="#put-sites-id">PUT /sites/{id}</a></li><li class="d3"><a href="#delete-sites-id">DELETE /sites/{id}</a></li><li class="d3"><a href="#get-sites-deleted">GET /sites/deleted</a></li><li class="d3"><a href="#post-sites-id-restore">POST /sites/{id}/restore</a></li><li class="d3"><a href="#get-sites-address-suggestions">GET /sites/address-suggestions</a></li><li class="d3"><a href="#put-sites-id-address">PUT /sites/{id}/address</a></li><li class="d3"><a href="#post-sites-id-rotate-secret-url">POST /sites/{id}/rotate-secret-url</a></li><li class="d3"><a href="#get-sites-id-versions">GET /sites/{id}/versions</a></li><li class="d3"><a href="#get-sites-id-diff">GET /sites/{id}/diff</a></li><li class="d3"><a href="#post-sites-id-rollback">POST /sites/{id}/rollback</a></li><li class="d3"><a href="#post-sites-id-restore-files">POST /sites/{id}/restore-files</a></li><li class="d3"><a href="#post-sites-id-versions-versionid-promote">POST /sites/{id}/versions/{versionId}/promote</a></li><li class="d3"><a href="#put-sites-id-versions-versionid-label">PUT /sites/{id}/versions/{versionId}/label</a></li><li class="d3"><a href="#get-sites-id-store-readiness">GET /sites/{id}/store-readiness</a></li><li class="d3"><a href="#get-sites-id-store-listing">GET /sites/{id}/store-listing</a></li><li class="d3"><a href="#get-sites-id-privacy-answers">GET /sites/{id}/privacy-answers</a></li><li class="d3"><a href="#post-sites-id-app-export">POST /sites/{id}/app-export</a></li><li class="d3"><a href="#get-sites-id-readiness">GET /sites/{id}/readiness</a></li><li class="d3"><a href="#post-search-console-verify">POST /search-console/verify</a></li><li class="d3"><a href="#get-sites-id-stats">GET /sites/{id}/stats</a></li><li class="d3"><a href="#get-sites-id-fetch">GET /sites/{id}/fetch</a></li><li class="d3"><a href="#post-sites-import">POST /sites/import</a></li><li class="d3"><a href="#post-sites-inspect-wordpress">POST /sites/inspect-wordpress</a></li><li class="d3"><a href="#post-sites-import-wordpress">POST /sites/import-wordpress</a></li><li class="d2"><a href="#media">Media</a></li><li class="d3"><a href="#get-media">GET /media</a></li><li class="d3"><a href="#post-media-links">POST /media/links</a></li><li class="d3"><a href="#get-media-id">GET /media/{id}</a></li><li class="d3"><a href="#patch-media-id">PATCH /media/{id}</a></li><li class="d3"><a href="#delete-media-id">DELETE /media/{id}</a></li><li class="d2"><a href="#data">Data</a></li><li class="d3"><a href="#get-collections">GET /collections</a></li><li class="d3"><a href="#post-collections">POST /collections</a></li><li class="d3"><a href="#get-collections-id">GET /collections/{id}</a></li><li class="d3"><a href="#patch-collections-id">PATCH /collections/{id}</a></li><li class="d3"><a href="#delete-collections-id">DELETE /collections/{id}</a></li><li class="d3"><a href="#get-collections-id-documents">GET /collections/{id}/documents</a></li><li class="d3"><a href="#post-collections-id-documents">POST /collections/{id}/documents</a></li><li class="d3"><a href="#get-collections-id-documents-docid">GET /collections/{id}/documents/{docId}</a></li><li class="d3"><a href="#patch-collections-id-documents-docid">PATCH /collections/{id}/documents/{docId}</a></li><li class="d3"><a href="#delete-collections-id-documents-docid">DELETE /collections/{id}/documents/{docId}</a></li><li class="d3"><a href="#get-public-collections-name-documents">GET /public/collections/{name}/documents</a></li><li class="d3"><a href="#post-public-collections-name-documents">POST /public/collections/{name}/documents</a></li><li class="d2"><a href="#test-mode">Test mode</a></li><li class="d3"><a href="#post-test-calls">POST /test/calls</a></li><li class="d2"><a href="#wordpress">WordPress</a></li><li class="d3"><a href="#post-wordpress-connect-codes">POST /wordpress/connect-codes</a></li><li class="d3"><a href="#get-wordpress-connections">GET /wordpress/connections</a></li><li class="d3"><a href="#get-wordpress-connections-id">GET /wordpress/connections/{id}</a></li><li class="d3"><a href="#delete-wordpress-connections-id">DELETE /wordpress/connections/{id}</a></li><li class="d3"><a href="#put-wordpress-connections-id-selection">PUT /wordpress/connections/{id}/selection</a></li><li class="d3"><a href="#get-wordpress-connections-id-suggestion">GET /wordpress/connections/{id}/suggestion</a></li><li class="d3"><a href="#put-wordpress-connections-id-suggestion">PUT /wordpress/connections/{id}/suggestion</a></li><li class="d3"><a href="#get-wordpress-connections-id-embeds">GET /wordpress/connections/{id}/embeds</a></li><li class="d3"><a href="#put-wordpress-connections-id-embeds">PUT /wordpress/connections/{id}/embeds</a></li><li class="d3"><a href="#post-wordpress-connect">POST /wordpress/connect</a></li><li class="d3"><a href="#get-wordpress-connection">GET /wordpress/connection</a></li><li class="d3"><a href="#delete-wordpress-connection">DELETE /wordpress/connection</a></li><li class="d3"><a href="#put-wordpress-connection-inventory">PUT /wordpress/connection/inventory</a></li><li class="d3"><a href="#get-wordpress-connection-selection">GET /wordpress/connection/selection</a></li><li class="d3"><a href="#put-wordpress-connection-selection">PUT /wordpress/connection/selection</a></li><li class="d3"><a href="#delete-wordpress-connection-suggestion">DELETE /wordpress/connection/suggestion</a></li><li class="d3"><a href="#get-wordpress-connection-embeds">GET /wordpress/connection/embeds</a></li><li class="d3"><a href="#put-wordpress-connection-embeds">PUT /wordpress/connection/embeds</a></li><li class="d3"><a href="#post-wordpress-exports">POST /wordpress/exports</a></li><li class="d3"><a href="#post-wordpress-exports-id-batches">POST /wordpress/exports/{id}/batches</a></li><li class="d3"><a href="#post-wordpress-exports-id-finish">POST /wordpress/exports/{id}/finish</a></li><li class="d2"><a href="#mcp">MCP</a></li><li class="d3"><a href="#post-mcp">POST /mcp</a></li><li class="d2"><a href="#meta">Meta</a></li><li class="d3"><a href="#get-openapi-json">GET /openapi.json</a></li><li class="d3"><a href="#get-status">GET /status</a></li><li class="d2"><a href="#agents">Agents</a></li><li class="d3"><a href="#get-agents">GET /agents</a></li><li class="d3"><a href="#post-agents">POST /agents</a></li><li class="d3"><a href="#get-agents-id">GET /agents/{id}</a></li><li class="d3"><a href="#patch-agents-id">PATCH /agents/{id}</a></li><li class="d3"><a href="#delete-agents-id">DELETE /agents/{id}</a></li><li class="d3"><a href="#get-agents-id-runs">GET /agents/{id}/runs</a></li><li class="d3"><a href="#post-agent-actions-id-decide">POST /agent-actions/{id}/decide</a></li><li class="d2"><a href="#apps">Apps</a></li><li class="d3"><a href="#get-apps">GET /apps</a></li><li class="d3"><a href="#post-apps">POST /apps</a></li><li class="d3"><a href="#get-apps-id">GET /apps/{id}</a></li><li class="d3"><a href="#delete-apps-id">DELETE /apps/{id}</a></li><li class="d3"><a href="#post-apps-id-rollback">POST /apps/{id}/rollback</a></li><li class="d3"><a href="#get-apps-id-logs">GET /apps/{id}/logs</a></li><li class="d3"><a href="#get-apps-id-users">GET /apps/{id}/users</a></li><li class="d3"><a href="#post-apps-id-users">POST /apps/{id}/users</a></li><li class="d3"><a href="#get-apps-id-mails">GET /apps/{id}/mails</a></li><li class="d3"><a href="#post-apps-id-users-import">POST /apps/{id}/users/import</a></li><li class="d3"><a href="#patch-apps-id-users-userid">PATCH /apps/{id}/users/{userId}</a></li><li class="d3"><a href="#delete-apps-id-users-userid">DELETE /apps/{id}/users/{userId}</a></li><li class="d3"><a href="#post-apps-id-users-userid-export">POST /apps/{id}/users/{userId}/export</a></li><li class="d3"><a href="#delete-apps-id-users-userid-data">DELETE /apps/{id}/users/{userId}/data</a></li><li class="d3"><a href="#put-apps-id-tables-table-owner">PUT /apps/{id}/tables/{table}/owner</a></li><li class="d3"><a href="#delete-apps-id-tables-table-owner">DELETE /apps/{id}/tables/{table}/owner</a></li><li class="d3"><a href="#get-apps-id-files">GET /apps/{id}/files</a></li><li class="d3"><a href="#delete-apps-id-files-fileid">DELETE /apps/{id}/files/{fileId}</a></li><li class="d3"><a href="#get-apps-id-migrations">GET /apps/{id}/migrations</a></li><li class="d3"><a href="#post-apps-id-migrations">POST /apps/{id}/migrations</a></li><li class="d3"><a href="#get-apps-id-schema">GET /apps/{id}/schema</a></li><li class="d3"><a href="#get-apps-id-code">GET /apps/{id}/code</a></li><li class="d3"><a href="#post-apps-id-query">POST /apps/{id}/query</a></li><li class="d3"><a href="#get-app-files">GET /app-files</a></li><li class="d3"><a href="#post-app-files">POST /app-files</a></li><li class="d3"><a href="#get-app-files-id">GET /app-files/{id}</a></li><li class="d3"><a href="#delete-app-files-id">DELETE /app-files/{id}</a></li><li class="d3"><a href="#get-app-users">GET /app-users</a></li><li class="d3"><a href="#post-app-users">POST /app-users</a></li><li class="d3"><a href="#get-app-users-id">GET /app-users/{id}</a></li><li class="d3"><a href="#patch-app-users-id">PATCH /app-users/{id}</a></li><li class="d3"><a href="#delete-app-users-id">DELETE /app-users/{id}</a></li><li class="d2"><a href="#email">Email</a></li><li class="d3"><a href="#get-emails">GET /emails</a></li><li class="d3"><a href="#post-emails">POST /emails</a></li><li class="d3"><a href="#get-app-emails">GET /app-emails</a></li><li class="d3"><a href="#post-app-emails">POST /app-emails</a></li><li class="d3"><a href="#post-emails-batch">POST /emails/batch</a></li><li class="d3"><a href="#post-app-emails-batch">POST /app-emails/batch</a></li><li class="d3"><a href="#get-emails-id">GET /emails/{id}</a></li><li class="d3"><a href="#get-app-emails-id">GET /app-emails/{id}</a></li><li class="d3"><a href="#get-email-domains">GET /email-domains</a></li><li class="d3"><a href="#post-email-domains">POST /email-domains</a></li><li class="d3"><a href="#get-email-domains-id">GET /email-domains/{id}</a></li><li class="d3"><a href="#delete-email-domains-id">DELETE /email-domains/{id}</a></li><li class="d3"><a href="#post-email-domains-id-verify">POST /email-domains/{id}/verify</a></li><li class="d3"><a href="#post-email-recipients">POST /email-recipients</a></li><li class="d3"><a href="#post-email-recipients-check">POST /email-recipients/check</a></li><li class="d3"><a href="#get-email-deliverability">GET /email-deliverability</a></li><li class="d3"><a href="#get-email-domains-id-bimi">GET /email-domains/{id}/bimi</a></li><li class="d3"><a href="#post-email-domains-id-bimi">POST /email-domains/{id}/bimi</a></li><li class="d3"><a href="#post-email-dedicated-ip">POST /email-dedicated-ip</a></li><li class="d3"><a href="#get-email-templates">GET /email-templates</a></li><li class="d3"><a href="#post-email-templates">POST /email-templates</a></li><li class="d3"><a href="#get-email-templates-id">GET /email-templates/{id}</a></li><li class="d3"><a href="#patch-email-templates-id">PATCH /email-templates/{id}</a></li><li class="d3"><a href="#delete-email-templates-id">DELETE /email-templates/{id}</a></li><li class="d3"><a href="#post-email-templates-id-preview">POST /email-templates/{id}/preview</a></li><li class="d3"><a href="#get-email-automations">GET /email-automations</a></li><li class="d3"><a href="#post-email-automations">POST /email-automations</a></li><li class="d3"><a href="#patch-email-automations-id">PATCH /email-automations/{id}</a></li><li class="d3"><a href="#delete-email-automations-id">DELETE /email-automations/{id}</a></li><li class="d3"><a href="#get-scheduled-emails">GET /scheduled-emails</a></li><li class="d3"><a href="#get-scheduled-emails-id">GET /scheduled-emails/{id}</a></li><li class="d3"><a href="#post-scheduled-emails-id-cancel">POST /scheduled-emails/{id}/cancel</a></li><li class="d3"><a href="#get-email-sequences">GET /email-sequences</a></li><li class="d3"><a href="#post-email-sequences">POST /email-sequences</a></li><li class="d3"><a href="#get-email-sequences-id">GET /email-sequences/{id}</a></li><li class="d3"><a href="#delete-email-sequences-id">DELETE /email-sequences/{id}</a></li><li class="d3"><a href="#post-email-sequences-id-enrollments">POST /email-sequences/{id}/enrollments</a></li><li class="d3"><a href="#post-email-sequences-id-stop">POST /email-sequences/{id}/stop</a></li><li class="d2"><a href="#agencies">Agencies</a></li><li class="d3"><a href="#get-clients">GET /clients</a></li><li class="d3"><a href="#post-clients">POST /clients</a></li><li class="d3"><a href="#post-clients-access-requests">POST /clients/access-requests</a></li><li class="d3"><a href="#post-clients-id-owner-invite">POST /clients/{id}/owner-invite</a></li><li class="d3"><a href="#delete-clients-id">DELETE /clients/{id}</a></li><li class="d3"><a href="#post-clients-id-transfer">POST /clients/{id}/transfer</a></li><li class="d3"><a href="#delete-clients-id-transfer">DELETE /clients/{id}/transfer</a></li><li class="d3"><a href="#post-clients-id-restore">POST /clients/{id}/restore</a></li><li class="d3"><a href="#get-clients-id-usage">GET /clients/{id}/usage</a></li><li class="d3"><a href="#put-clients-id-payer">PUT /clients/{id}/payer</a></li><li class="d3"><a href="#put-clients-id-budget">PUT /clients/{id}/budget</a></li><li class="d3"><a href="#get-clients-id-invoice-lines">GET /clients/{id}/invoice-lines</a></li><li class="d3"><a href="#post-clients-id-payment-links">POST /clients/{id}/payment-links</a></li><li class="d3"><a href="#post-clients-id-subscriptions">POST /clients/{id}/subscriptions</a></li><li class="d3"><a href="#get-clients-id-payments">GET /clients/{id}/payments</a></li><li class="d3"><a href="#delete-clients-id-subscriptions-subscriptionid">DELETE /clients/{id}/subscriptions/{subscriptionId}</a></li><li class="d3"><a href="#delete-clients-id-payment-links-linkid">DELETE /clients/{id}/payment-links/{linkId}</a></li><li class="d3"><a href="#get-clients-id-activity">GET /clients/{id}/activity</a></li><li class="d3"><a href="#get-agencies">GET /agencies</a></li><li class="d3"><a href="#delete-agencies-id">DELETE /agencies/{id}</a></li><li class="d2"><a href="#webhook-events">Webhook events</a></li><li class="d3"><a href="#call-completed">call.completed</a></li><li class="d3"><a href="#call-summary-ready">call.summary.ready</a></li><li class="d3"><a href="#message-delivered">message.delivered</a></li><li class="d3"><a href="#message-failed">message.failed</a></li><li class="d3"><a href="#conversation-summary-ready">conversation.summary.ready</a></li><li class="d3"><a href="#balance-low">balance.low</a></li><li class="d3"><a href="#balance-toppedup">balance.topped_up</a></li><li class="d3"><a href="#balance-reloadfailed">balance.reload_failed</a></li><li class="d3"><a href="#document-created">document.created</a></li><li class="d3"><a href="#email-sent">email.sent</a></li><li class="d3"><a href="#email-delivered">email.delivered</a></li><li class="d3"><a href="#email-delayed">email.delayed</a></li><li class="d3"><a href="#email-bounced">email.bounced</a></li><li class="d3"><a href="#email-complained">email.complained</a></li><li class="d3"><a href="#webhook-test">webhook.test</a></li><li class="d2"><a href="#objects">Objects</a></li><li class="d3"><a href="#object-mcprequest">McpRequest</a></li><li class="d3"><a href="#object-mcpresponse">McpResponse</a></li><li class="d3"><a href="#object-project">Project</a></li><li class="d3"><a href="#object-advice">Advice</a></li><li class="d3"><a href="#object-status">Status</a></li><li class="d3"><a href="#object-movetoliveinput">MoveToLiveInput</a></li><li class="d3"><a href="#object-movetoliveundoinput">MoveToLiveUndoInput</a></li><li class="d3"><a href="#object-movetolive">MoveToLive</a></li><li class="d3"><a href="#object-movetoliveundo">MoveToLiveUndo</a></li><li class="d3"><a href="#object-sitereadiness">SiteReadiness</a></li><li class="d3"><a href="#object-searchconsoleverifyinput">SearchConsoleVerifyInput</a></li><li class="d3"><a href="#object-searchconsoleverification">SearchConsoleVerification</a></li><li class="d3"><a href="#object-sitestats">SiteStats</a></li><li class="d3"><a href="#object-sitepage">SitePage</a></li><li class="d3"><a href="#object-companyinput">CompanyInput</a></li><li class="d3"><a href="#object-company">Company</a></li><li class="d3"><a href="#object-number">Number</a></li><li class="d3"><a href="#object-orderednumber">OrderedNumber</a></li><li class="d3"><a href="#object-numberlist">NumberList</a></li><li class="d3"><a href="#object-releasednumber">ReleasedNumber</a></li><li class="d3"><a href="#object-availablenumber">AvailableNumber</a></li><li class="d3"><a href="#object-availablenumberlist">AvailableNumberList</a></li><li class="d3"><a href="#object-numberorder">NumberOrder</a></li><li class="d3"><a href="#object-assistantinput">AssistantInput</a></li><li class="d3"><a href="#object-assistant">Assistant</a></li><li class="d3"><a href="#object-assistantsaved">AssistantSaved</a></li><li class="d3"><a href="#object-callplan">CallPlan</a></li><li class="d3"><a href="#object-callplanresponse">CallPlanResponse</a></li><li class="d3"><a href="#object-callplansaved">CallPlanSaved</a></li><li class="d3"><a href="#object-call">Call</a></li><li class="d3"><a href="#object-calldetail">CallDetail</a></li><li class="d3"><a href="#object-calllist">CallList</a></li><li class="d3"><a href="#object-summary">Summary</a></li><li class="d3"><a href="#object-testcallinput">TestCallInput</a></li><li class="d3"><a href="#object-webhookendpointinput">WebhookEndpointInput</a></li><li class="d3"><a href="#object-webhookendpoint">WebhookEndpoint</a></li><li class="d3"><a href="#object-webhookendpointcreated">WebhookEndpointCreated</a></li><li class="d3"><a href="#object-webhookendpointlist">WebhookEndpointList</a></li><li class="d3"><a href="#object-emailsentevent">EmailSentEvent</a></li><li class="d3"><a href="#object-emaildeliveredevent">EmailDeliveredEvent</a></li><li class="d3"><a href="#object-emaildelayedevent">EmailDelayedEvent</a></li><li class="d3"><a href="#object-emailbouncedevent">EmailBouncedEvent</a></li><li class="d3"><a href="#object-emailcomplainedevent">EmailComplainedEvent</a></li><li class="d3"><a href="#object-webhooktestresult">WebhookTestResult</a></li><li class="d3"><a href="#object-callcompletedevent">CallCompletedEvent</a></li><li class="d3"><a href="#object-callsummaryreadyevent">CallSummaryReadyEvent</a></li><li class="d3"><a href="#object-webhooktestevent">WebhookTestEvent</a></li><li class="d3"><a href="#object-callinput">CallInput</a></li><li class="d3"><a href="#object-callconsentinput">CallConsentInput</a></li><li class="d3"><a href="#object-callconsent">CallConsent</a></li><li class="d3"><a href="#object-callconsentlist">CallConsentList</a></li><li class="d3"><a href="#object-messageinput">MessageInput</a></li><li class="d3"><a href="#object-message">Message</a></li><li class="d3"><a href="#object-messagelist">MessageList</a></li><li class="d3"><a href="#object-messagedeliveredevent">MessageDeliveredEvent</a></li><li class="d3"><a href="#object-messagefailedevent">MessageFailedEvent</a></li><li class="d3"><a href="#object-conversationinput">ConversationInput</a></li><li class="d3"><a href="#object-conversation">Conversation</a></li><li class="d3"><a href="#object-conversationdetail">ConversationDetail</a></li><li class="d3"><a href="#object-conversationlist">ConversationList</a></li><li class="d3"><a href="#object-chatmessage">ChatMessage</a></li><li class="d3"><a href="#object-chatmessageinput">ChatMessageInput</a></li><li class="d3"><a href="#object-chatturn">ChatTurn</a></li><li class="d3"><a href="#object-conversationsummaryreadyevent">ConversationSummaryReadyEvent</a></li><li class="d3"><a href="#object-chattokeninput">ChatTokenInput</a></li><li class="d3"><a href="#object-chattokenupdate">ChatTokenUpdate</a></li><li class="d3"><a href="#object-chattoken">ChatToken</a></li><li class="d3"><a href="#object-chattokenlist">ChatTokenList</a></li><li class="d3"><a href="#object-siteinput">SiteInput</a></li><li class="d3"><a href="#object-siteupdate">SiteUpdate</a></li><li class="d3"><a href="#object-site">Site</a></li><li class="d3"><a href="#object-domainconnectinput">DomainConnectInput</a></li><li class="d3"><a href="#object-domainregisterinput">DomainRegisterInput</a></li><li class="d3"><a href="#object-domainsiteinput">DomainSiteInput</a></li><li class="d3"><a href="#object-domainupdateinput">DomainUpdateInput</a></li><li class="d3"><a href="#object-dnsrecordinput">DnsRecordInput</a></li><li class="d3"><a href="#object-deployapproval">DeployApproval</a></li><li class="d3"><a href="#object-zonefileinput">ZoneFileInput</a></li><li class="d3"><a href="#object-dnsrestoreinput">DnsRestoreInput</a></li><li class="d3"><a href="#object-domaintransferinput">DomainTransferInput</a></li><li class="d3"><a href="#object-domainnameserversinput">DomainNameserversInput</a></li><li class="d3"><a href="#object-siteimportinput">SiteImportInput</a></li><li class="d3"><a href="#object-wordpressinspectinput">WordpressInspectInput</a></li><li class="d3"><a href="#object-wordpressimportinput">WordpressImportInput</a></li><li class="d3"><a href="#object-siteuploadinput">SiteUploadInput</a></li><li class="d3"><a href="#object-siteuploadlist">SiteUploadList</a></li><li class="d3"><a href="#object-sitedropinput">SiteDropInput</a></li><li class="d3"><a href="#object-sitedrop">SiteDrop</a></li><li class="d3"><a href="#object-mediafile">MediaFile</a></li><li class="d3"><a href="#object-medialist">MediaList</a></li><li class="d3"><a href="#object-mediaupdate">MediaUpdate</a></li><li class="d3"><a href="#object-medialinkinput">MediaLinkInput</a></li><li class="d3"><a href="#object-sitefile">SiteFile</a></li><li class="d3"><a href="#object-sitelist">SiteList</a></li><li class="d3"><a href="#object-deletedsite">DeletedSite</a></li><li class="d3"><a href="#object-deletedsitelist">DeletedSiteList</a></li><li class="d3"><a href="#object-restoredsite">RestoredSite</a></li><li class="d3"><a href="#object-collectioninput">CollectionInput</a></li><li class="d3"><a href="#object-collectionupdate">CollectionUpdate</a></li><li class="d3"><a href="#object-collection">Collection</a></li><li class="d3"><a href="#object-collectionlist">CollectionList</a></li><li class="d3"><a href="#object-documentinput">DocumentInput</a></li><li class="d3"><a href="#object-documentupdate">DocumentUpdate</a></li><li class="d3"><a href="#object-document">Document</a></li><li class="d3"><a href="#object-documentlist">DocumentList</a></li><li class="d3"><a href="#object-publicdocumentinput">PublicDocumentInput</a></li><li class="d3"><a href="#object-publicdocumentcreated">PublicDocumentCreated</a></li><li class="d3"><a href="#object-publicdocumentlist">PublicDocumentList</a></li><li class="d3"><a href="#object-publicchatconfig">PublicChatConfig</a></li><li class="d3"><a href="#object-publicconversationinput">PublicConversationInput</a></li><li class="d3"><a href="#object-publicconversation">PublicConversation</a></li><li class="d3"><a href="#object-publicconversationstart">PublicConversationStart</a></li><li class="d3"><a href="#object-publicconversationdetail">PublicConversationDetail</a></li><li class="d3"><a href="#object-publicchatturn">PublicChatTurn</a></li><li class="d3"><a href="#object-balance">Balance</a></li><li class="d3"><a href="#object-transaction">Transaction</a></li><li class="d3"><a href="#object-autoreload">AutoReload</a></li><li class="d3"><a href="#object-reload">Reload</a></li><li class="d3"><a href="#object-balancelowevent">BalanceLowEvent</a></li><li class="d3"><a href="#object-balancetoppedupevent">BalanceToppedUpEvent</a></li><li class="d3"><a href="#object-balancereloadfailedevent">BalanceReloadFailedEvent</a></li><li class="d3"><a href="#object-documentcreatedevent">DocumentCreatedEvent</a></li><li class="d3"><a href="#object-problem">Problem</a></li><li class="d3"><a href="#object-fielderror">FieldError</a></li><li class="d3"><a href="#object-recenterror">RecentError</a></li><li class="d3"><a href="#object-recenterrorlist">RecentErrorList</a></li></ul></nav> </div> <!-- hm-voet:begin (gegenereerd door scripts/paginas.mjs; niet met de hand bewerken) --> <footer class="hm-voet"> <div class="hm-wrap"> <div class="hm-voet-raster"> <div class="hm-voet-merk"><p class="hm-voet-kop"><a class="hm-merk" href="/">Hypamoon</a><span class="hm-merkzin"><span aria-hidden="true">·</span> Where AI lives</span></p><p>Your chat builds it. It lives on Hypamoon.</p></div> <div><h2>Product</h2><ul><li><a href="/product">Overview</a></li><li><a href="/product/sites">Sites</a></li><li><a href="/product/data">Data</a></li><li><a href="/product/phone">Phone, SMS and chat</a></li><li><a href="/product/payments">Payments</a></li><li><a href="/product/domains">Domains</a></li><li><a href="/product/agents">Agents</a></li><li><a href="/product/apps">Apps</a></li><li><a href="/product/mail">Email from your domain</a></li></ul></div> <div><h2>Solutions</h2><ul><li><a href="/solutions/move-your-site">Move your site</a></li><li><a href="/solutions/ai-receptionist">AI receptionist</a></li><li><a href="/solutions/online-store">Online store</a></li><li><a href="/solutions/sell-access-to-your-tool">Sell access to your tool</a></li><li><a href="/solutions/ship-to-the-app-stores">Ship to the app stores</a></li><li><a href="/solutions/internal-apps">Internal apps</a></li></ul></div> <div><h2>For</h2><ul><li><a href="/for/personal">For you</a></li><li><a href="/for/small-business">Small business</a></li><li><a href="/for/operations">Operations</a></li><li><a href="/for/teams">Teams</a></li><li><a href="/for/enterprise">Enterprise</a></li><li><a href="/for/builders">Builders</a></li></ul></div> <div><h2>Hypamoon</h2><ul><li><a href="/say-this">Say this</a></li><li><a href="/pricing">Pricing</a></li><li><a href="/examples">Examples</a></li><li><a href="/docs">Docs</a></li><li><a href="/connect">Connect your chat</a></li><li><a href="/trust">Trust</a></li><li><a href="https://status.hypamoon.com">Status</a></li><li><a href="/security">Security</a></li><li><a href="/support">Support</a></li><li><a href="/abuse">Report abuse</a></li><li><a href="/#contact">Contact</a></li></ul></div> </div> <div class="hm-voet-onder"> <span>©︎ 2026 Hypamoon</span> <span><a href="/privacy">Privacy</a> · <a href="/terms">Terms</a> · <a href="/abuse">Report abuse</a> · <a href="https://app.hypamoon.com/?utm_source=docs&utm_campaign=reference">Log in</a></span> </div> </div> </footer> <!-- hm-voet:end --> <script src="/site.js" defer></script> <script src="/docs/docs.js"></script> </body> </html>