# Hypamoon API (version 1.0.0) 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/`). 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. Base URL: https://api.hypamoon.com/api/v1 All paths below are relative to the base URL. Request and response bodies are JSON. ## Authentication Your API key as a bearer token: `Authorization: Bearer hm_live_...`. Live keys start with hm_live_; test keys start with hm_test_, belong to a test project, never reach the carrier and cost nothing. A key has scopes (project:read, project:write, numbers:read, calls:read, numbers:write, assistant:write, webhooks:write, messages:read, messages:write, calls:write, chat:read, chat:write, sites:read, sites:write, data:read, data:write); each operation names the one it needs in x-hypamoon-scope. Keys are shown only once; store them as a secret, never in a frontend. An OAuth access token (hm_oat_..., see OAuth2) is sent the same way and works on every /api/v1 route. ## Endpoints - GET /openapi.json: This OpenAPI document - GET /status: Check whether Hypamoon is working - GET /project: Get the project - GET /balance: Get the prepaid balance - PUT /project/company: Set the company or personal details - GET /numbers: List the numbers - POST /numbers: Order a number - GET /numbers/available: Search for numbers to order - DELETE /numbers/{id}: Release a test number - GET /numbers/{id}/call-plan: Get the call plan of a number - PUT /numbers/{id}/call-plan: Set the call plan of a number - GET /assistant: Get the assistant - PUT /assistant: Set the assistant - GET /calls: List the calls - POST /calls: Call someone with your assistant - GET /calls/{id}: Get a call with transcript and summary - GET /call-consents: List recorded consent for marketing calls - POST /call-consents: Record consent for marketing calls - DELETE /call-consents/{id}: Revoke consent for marketing calls - GET /webhooks: List the webhook endpoints - POST /webhooks: Create a webhook endpoint - DELETE /webhooks/{id}: Delete a webhook endpoint - POST /webhooks/{id}/enable: Switch a webhook endpoint back on - POST /webhooks/{id}/test: Send a test event - POST /test/calls: Simulate a call - POST /messages: Send an SMS - GET /messages: List the messages - GET /messages/{id}: Get a message - POST /conversations: Start a chat conversation - GET /conversations: List the conversations - GET /conversations/{id}: Get a conversation with its messages and summary - POST /conversations/{id}/messages: Send a customer message and get the reply - POST /conversations/{id}/close: Close a conversation - GET /chat-tokens: List the public chat tokens - POST /chat-tokens: Create a public chat token for the chat widget - GET /chat-tokens/{id}: Get a public chat token - PATCH /chat-tokens/{id}: Change a public chat token - DELETE /chat-tokens/{id}: Revoke a public chat token - GET /sites: List the sites - POST /sites: Publish a static site - POST /sites/uploads: Get upload links for large files - POST /sites/drops: Create a drop link where the user uploads files in a browser - GET /sites/drops/{id}: Get a drop link and the files the user uploaded - GET /media: List the media library - POST /media/links: Create an upload page for the media library - GET /media/{id}: Get a file from the media library - PATCH /media/{id}: Rename or move a file, or set its alt text - DELETE /media/{id}: Delete a file from the media library - GET /sites/{id}: Get a site - PUT /sites/{id}: Change the files, the name or the password of a site - DELETE /sites/{id}: Delete a site - GET /sites/deleted: List the deleted sites that can be restored - POST /sites/{id}/restore: Restore a deleted site - GET /sites/address-suggestions: Suggest an address for a site - PUT /sites/{id}/address: Change the address or visibility of a site - POST /sites/{id}/rotate-secret-url: Make a new secret link for an unlisted site - GET /sites/{id}/versions: List the versions of a site - GET /sites/{id}/diff: Show what changed between two versions - POST /sites/{id}/rollback: Roll a site back to an earlier version - POST /sites/{id}/restore-files: Restore a few files from an earlier version - POST /sites/{id}/versions/{versionId}/promote: Publish a preview - PUT /sites/{id}/versions/{versionId}/label: Label a version - GET /sites/{id}/store-readiness: Check whether a site is ready for the App Store and Google Play - GET /sites/{id}/store-listing: Draft the store listing for a site's app - GET /sites/{id}/privacy-answers: Answers for Apple App Privacy and Google Play Data safety - POST /sites/{id}/app-export: Export a Capacitor app project for a site - GET /advice: Get the blueprint for a kind of application - GET /sites/{id}/readiness: Check what works on a site and what is missing before going live - POST /project/move-to-live: Move sites from the test project to the live project - POST /project/move-to-live/undo: Undo a move to live - POST /search-console/verify: Verify a site or domain in Google Search Console - GET /sites/{id}/stats: Get the visits of a site - GET /sites/{id}/fetch: Fetch a page of a site or app, live - GET /domains: List the domains - POST /domains: Connect a domain the user owns - GET /domains/health: List watched domains and their health - POST /domains/watch: Watch a domain, or park it - GET /domains/{id}/dns-cleanup: Suggest a cleanup of left-over DNS records - POST /domains/{id}/dns-cleanup: Remove left-over DNS records in one step - POST /domains/{id}/dns-cleanup/restore: Put the records of a cleanup back - GET /domains/check: Check whether a domain is available - GET /domains/suggestions: Suggest available domains - POST /domains/register: Register a domain - GET /domains/{id}: Get a domain - DELETE /domains/{id}: Disconnect a domain - PATCH /domains/{id}: Update the settings of a domain - POST /domains/{id}/activate: Check a domain and attach its site - PUT /domains/{id}/site: Set the site of a domain - POST /domains/{id}/authcode: Get the transfer code of a domain - GET /domains/{id}/dns-records: List the DNS records - POST /domains/{id}/dns-records: Add, change or delete a DNS record - POST /domains/{id}/dns-records/undo: Undo the last DNS change - GET /domains/diagnose: Check a domain and suggest fixes - POST /domains/{id}/zone-file: Import a zone file - GET /domains/{id}/preflight: Compare the old and the new DNS - POST /domains/{id}/restore: Restore the DNS records - POST /domains/{id}/nameservers: Switch the nameservers - POST /domains/transfer: Transfer a domain to Hypamoon - POST /sites/import: Import an existing site - POST /sites/inspect-wordpress: Inspect a WordPress site - POST /sites/import-wordpress: Import a WordPress site - POST /wordpress/connect-codes: Create a code to connect a WordPress site - GET /wordpress/connections: List connected WordPress sites - GET /wordpress/connections/{id}: Get the inventory of a connected WordPress site - DELETE /wordpress/connections/{id}: Revoke a WordPress connection - PUT /wordpress/connections/{id}/selection: Choose what moves from a WordPress site - GET /wordpress/connections/{id}/suggestion: Read the compact inventory and the open suggestion - PUT /wordpress/connections/{id}/suggestion: Suggest a selection for a WordPress site - GET /wordpress/connections/{id}/embeds: Read the chat and forms on a WordPress site - PUT /wordpress/connections/{id}/embeds: Put the chat and forms on a WordPress site - POST /wordpress/connect: 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) - GET /collections: List the collections - POST /collections: Create a collection - GET /collections/{id}: Get a collection - PATCH /collections/{id}: Change a collection - DELETE /collections/{id}: Delete a collection - GET /collections/{id}/documents: List the documents - POST /collections/{id}/documents: Add a document - GET /collections/{id}/documents/{docId}: Get a document - PATCH /collections/{id}/documents/{docId}: Change a document - DELETE /collections/{id}/documents/{docId}: Delete a document - 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) - POST /public/collections/{name}/documents: Send a form entry (public) - GET /public/collections/{name}/documents: Read the newest documents (public) - POST /mcp: Model Context Protocol endpoint - GET /agents: List the agents - POST /agents: Create an agent - GET /agents/{id}: Get an agent - PATCH /agents/{id}: Change an agent - DELETE /agents/{id}: Delete an agent - GET /agents/{id}/runs: List the runs of an agent - POST /agent-actions/{id}/decide: Approve or reject an agent action - GET /apps: List the apps - POST /apps: Deploy an app - GET /apps/{id}: Get an app - DELETE /apps/{id}: Delete an app - POST /apps/{id}/rollback: Roll an app back - GET /apps/{id}/logs: Read the log of an app - GET /apps/{id}/users: List the users of an app - POST /apps/{id}/users: Invite someone to an app - GET /apps/{id}/mails: List the emails an app sent - POST /apps/{id}/users/import: Import users from a collection - PATCH /apps/{id}/users/{userId}: Change the role of a user - DELETE /apps/{id}/users/{userId}: Remove a user from an app - POST /apps/{id}/users/{userId}/export: Export everything of one user (GDPR) - DELETE /apps/{id}/users/{userId}/data: Delete everything of one user (GDPR) - PUT /apps/{id}/tables/{table}/owner: Make a table private per user - DELETE /apps/{id}/tables/{table}/owner: Make a per-user table shared again - GET /apps/{id}/files: List the files of an app - DELETE /apps/{id}/files/{fileId}: Delete a file from an app - GET /apps/{id}/migrations: List the migrations of an app - POST /apps/{id}/migrations: Apply migrations to the database of an app - GET /apps/{id}/schema: Read the schema of an app's database - GET /apps/{id}/code: Read the server code of an app - POST /apps/{id}/query: Run one read-only query on an app's database - POST /app-files: Get an upload link (from an app) - GET /app-files: List the files (from an app) - GET /app-files/{id}: Get a download link (from an app) - DELETE /app-files/{id}: Delete a file (from an app) - GET /app-users: List the users (from an app, as admin) - POST /app-users: Invite someone (from an app, as admin) - GET /app-users/{id}: Get one user (from an app, as admin) - PATCH /app-users/{id}: Change a user's role (from an app, as admin) - DELETE /app-users/{id}: Remove a user (from an app, as admin) - GET /emails: List sent emails - POST /emails: Send one email - POST /app-emails: Send one email (from an app) - GET /app-emails: List the emails of an app (from the app) - POST /emails/batch: Send a batch of emails - POST /app-emails/batch: Send a batch of emails (from an app) - GET /emails/{id}: Get one email - GET /app-emails/{id}: Get one email of an app (from the app) - GET /email-domains: List sending domains - POST /email-domains: Add a sending domain - GET /email-domains/{id}: Get a sending domain - DELETE /email-domains/{id}: Remove a sending domain - POST /email-domains/{id}/verify: Check a sending domain now - POST /email-recipients: Confirm a recipient (owner only) - POST /email-recipients/check: Check email addresses - GET /email-deliverability: Get delivery per sending domain - GET /email-domains/{id}/bimi: Get the inbox logo (BIMI) of a sending domain - POST /email-domains/{id}/bimi: Set the inbox logo (BIMI) of a sending domain - POST /email-dedicated-ip: Request a dedicated sending IP - GET /email-templates: List email templates - POST /email-templates: Create an email template - GET /email-templates/{id}: Get an email template - PATCH /email-templates/{id}: Update an email template - DELETE /email-templates/{id}: Delete an email template - POST /email-templates/{id}/preview: Preview an email template - GET /email-automations: List email automations - POST /email-automations: Create an email automation - PATCH /email-automations/{id}: Update an email automation - DELETE /email-automations/{id}: Delete an email automation - GET /scheduled-emails: List scheduled emails - GET /scheduled-emails/{id}: Get a scheduled email - POST /scheduled-emails/{id}/cancel: Cancel a scheduled email - GET /email-sequences: List email sequences - POST /email-sequences: Create an email sequence - GET /email-sequences/{id}: Get an email sequence - DELETE /email-sequences/{id}: Delete an email sequence - POST /email-sequences/{id}/enrollments: Add someone to an email sequence - POST /email-sequences/{id}/stop: Stop an email sequence for someone - GET /clients: List your client projects - POST /clients: Set up a project for a client - POST /clients/access-requests: Ask a client for access to their project - POST /clients/{id}/owner-invite: Invite the client to own the project - DELETE /clients/{id}: Stop working on a client project - POST /clients/{id}/transfer: Hand over a client project - DELETE /clients/{id}/transfer: Cancel a handover - POST /clients/{id}/restore: Restore an ended client project - GET /clients/{id}/usage: Get the usage of a client project - PUT /clients/{id}/payer: Change who pays for a client project - PUT /clients/{id}/budget: Set the monthly budget for a client project - GET /clients/{id}/invoice-lines: Export invoice lines for a client project - POST /clients/{id}/payment-links: Create a payment link for a client - POST /clients/{id}/subscriptions: Create a subscription for a client - GET /clients/{id}/payments: List payment links and subscriptions for a client - DELETE /clients/{id}/subscriptions/{subscriptionId}: Cancel a client subscription - DELETE /clients/{id}/payment-links/{linkId}: Withdraw an open payment link - GET /clients/{id}/activity: Read the activity log of a client project - GET /agencies: List the agencies on this project - DELETE /agencies/{id}: Revoke an agency's access - GET /errors: List recent errors ## GET /openapi.json This OpenAPI document. The OpenAPI 3.1 document of the API. Public: no key needed. No API key needed. Response 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. Response 200: The status. Body: Status. Example: ```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." } ``` ## 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 Response 200: The project. Body: Project. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Response 200: The balance with the latest transactions. Body: Balance. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (CompanyInput): 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. - 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 (length 0..200) 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 (length 0..40) 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 (object, required) - street (string (length 1..200), required): The street, with the house number if the address has no separate one. - house_number (string (length 0..20) or null): The house number, starting with a digit, like 40 or 12A. Required where the country's addresses use one (like the Netherlands), optional elsewhere. - postal_code (string (length 1..16), required): The postal code. It is normalized for the country where we know the format (in the Netherlands 1000aa is saved as 1000 AA). - city (string (length 1..100), required): The city. - country (string (default "NL")): ISO 3166-1 alpha-2, like GB, DE or NL. Defaults to NL when left out. A country we cannot serve returns country_not_supported. - contact (object, required): A person the regulator can reach today. For a personal account: the user. - name (string (length 1..200), required): The contact person's first and last name. For a business it can also be a role, like Administration or Webmaster; this name also goes on the domains registered or transferred through Hypamoon, unless registrar_contact_name is set. Ordering a phone number still needs a person with a first and last name. For a personal account: the user's own first and last name. - phone (string, required): A phone number where the contact can be reached, in E.164. - registrar_contact_name (string (length 0..200) 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. Example request body: ```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" } } ``` Response 200: The company details as saved. Body: Company. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 422 validation_failed - 422 vat_number_invalid - 422 country_not_supported - 422 account_type_change_blocked - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /numbers List the numbers. The phone numbers of the project, oldest first. Released numbers are left out. Scope: numbers:read Response 200: The numbers. Body: NumberList. Example: ```json { "data": [ { "id": "5f0c9a2e-8b1d-4c3f-a6e7-2d9b0c1e4f53", "e164": "+•••••••••42", "type": "national", "status": "active", "created_at": "2026-10-01T09:00:00.000Z", "test": false } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - Idempotency-Key (in header, string (length 1..255, pattern ^[\x21-\x7E]+$), required): 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 (NumberOrder): Send either phone_number or area, not both. - 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. Example request body: ```json { "area": "020" } ``` Response 201: The number was ordered. status is active (it works now) or pending (the registration is with the carrier). Body: OrderedNumber. Example: ```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 } ``` Headers: Idempotent-Replayed. Errors (status and type slug; see Errors below): - 400 idempotency_key_missing - 400 idempotency_key_invalid - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 402 payment_mandate_missing - 402 billing_blocked - 402 insufficient_balance - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 phone_verification_required - 403 number_limit_reached - 409 plan_limit_reached - 409 idempotency_key_reused - 409 idempotency_request_in_progress - 409 number_unavailable - 409 number_already_yours - 422 validation_failed - 422 company_details_missing - 422 business_required - 422 spend_cap_missing - 422 regulatory_documents_missing - 422 no_numbers_available - 422 test_number_limit_reached - 429 rate_limited - 500 internal_error - 502 order_status_unknown - 502 upstream_error - 503 service_unavailable - 503 telephony_unavailable ## 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 Parameters: - type (in query, "national" | "geographic"): national or geographic. Without it, the type follows from area: the national prefix or nothing is national. - area (in query, string): The area code. Required for geographic; leave it out for national. Response 200: The available numbers. Body: AvailableNumberList. Example: ```json { "data": [ { "e164": "+•••••••••42", "type": "national", "from_stock": true, "test": false } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 422 validation_failed - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable - 503 telephony_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the number. Response 200: The test number was released. Body: ReleasedNumber. Example: ```json { "id": "0d1e2f3a-4b5c-4d6e-8f70-8192a3b4c5d6", "e164": "+447700900042", "released": true, "test": true } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 number_release_not_supported - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the number. Response 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). Body: CallPlanResponse. Example: ```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" } } } } ``` Headers: ETag. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the number. - If-Match (in header, string): The version from the ETag of GET, like "3". An outdated version returns 412 version_conflict. Request body (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. - entry (string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The id of the first node. - nodes (map of object by type: "announcement" | "hours" | "forward" | "menu" | "voicemail" | "ai_agent" | "hangup", required): The nodes by id. The key must be the same as the node's id. - when type is "announcement": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("announcement", required) - text (string (length 1..1000), required): The text to read out. - next (string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The node to go to after the text. - when type is "hours": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("hours", required) - schedule (object, required): Opening hours per day in a time zone, like Europe/London. - timezone (string, required) - days (object, required) - mon (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - tue (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - wed (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - thu (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - fri (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - sat (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - sun (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - closedDates (array of string (pattern ^\d{4}-\d{2}-\d{2}$) (0..366 items)) - open (string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The node to go to when open. - closed (string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The node to go to when closed. - when type is "forward": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("forward", required) - strategy ("sequential" | "parallel", required): sequential rings the targets one after another, parallel rings them all at once. - targets (array of object (1..3 items), required): Up to three phone numbers. Team members of the Hypamoon apps (kind user) are not available in the API. - kind ("external", required) - e164 (string (pattern ^\+[1-9]\d{7,14}$), required) - ringSeconds (integer (5..120), required): How long to ring before onNoAnswer, 5 to 120 seconds. - onNoAnswer (string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The node to go to when nobody answers. - when type is "menu": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("menu", required) - prompt (object, required): The text to read out, like "Press 1 for sales". - text (string (length 1..1000), required) - options (map of string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The node per key, 0 to 9. - onNoInput (string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The node to go to when the caller presses nothing. - timeoutSeconds (integer (1..30)): How long to wait for a key, 1 to 30 seconds. - when type is "voicemail": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("voicemail", required) - greeting (object): Optional text before the beep. - text (string (length 1..1000), required) - transcribe (boolean (default true)): Whether to transcribe the message. Default true. - when type is "ai_agent": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("ai_agent", required) - onDone (string (pattern ^[A-Za-z0-9_-]{1,64}$)): Optional: the node to go to when the assistant is done. The assistant itself is set with PUT /assistant. - when type is "hangup": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("hangup", required) Example request body: ```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" } } } ``` Response 200: The call plan as saved, with its new version. Body: CallPlanSaved. Example: ```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" } ``` Headers: ETag. Errors (status and type slug; see Errors below): - 400 invalid_parameter - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 voice_ai_not_enabled - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 version_conflict - 412 version_conflict - 422 team_targets_not_allowed - 422 forward_target_not_allowed - 422 validation_failed - 422 assistant_not_configured - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /assistant Get the assistant. The assistant of the project: its voice, languages, instructions, website and fixed greeting. Scope: assistant:write Response 200: The assistant. Body: Assistant. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (AssistantInput): PUT replaces all fields: a field you leave out goes back to its default. - 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 (array of "nl" | "en" | "de" | "fr" | "es" (1..5 items)): 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 (length 0..4000)): 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. Example request body: ```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" } ``` Response 200: The assistant as saved. Body: AssistantSaved. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 409 version_conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - limit (in query, integer (1.., default 25)): Calls per page, default 25. A value above 100 is capped at 100. - cursor (in query, string): The next_cursor of the previous page. - direction (in query, "inbound" | "outbound"): Only inbound calls, or only outbound calls (the ones your assistant made with POST /calls). Without it, both. Response 200: A page of calls. Body: CallList. Example: ```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 (status and type slug; see Errors below): - 400 invalid_parameter - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - Idempotency-Key (in header, string (length 1..255, pattern ^[\x21-\x7E]+$), required): 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 (CallInput): An outbound call by your project's assistant to one person who expects it. - to (string (pattern ^\+[1-9]\d{6,14}$), required): 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 (pattern ^\+[1-9]\d{6,14}$)): 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 (length 1..2000, pattern ^[^\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F]*$)): 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 (length 1..300, pattern ^[^\u0000-\u001F\u007F]+$)): 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 (30..1200)): Optional: the call ends after this many seconds of talking. Default 600, at most 1200. - reference (string (length 1..255, pattern ^[^\u0000-\u001F\u007F]+$)): Optional: your own id for this call, like an appointment id. Comes back on the call and in its webhooks. - metadata (map of string (length 0..500)): 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. - start (string (pattern ^([01]\d|2[0-3]):([0-5]\d)$), required) - end (string (pattern ^([01]\d|2[0-3]):([0-5]\d)$), required) - days (array of "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun" (1..7 items)) - 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. Example request body: ```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" } } ``` Response 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}. Body: CallDetail. Example: ```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 } ``` Headers: Idempotent-Replayed. Errors (status and type slug; see Errors below): - 400 idempotency_key_missing - 400 idempotency_key_invalid - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 payment_mandate_missing - 402 insufficient_balance - 402 billing_blocked - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 phone_verification_required - 403 consent_required - 409 idempotency_key_reused - 409 idempotency_request_in_progress - 422 validation_failed - 422 destination_not_allowed - 422 outside_calling_hours - 422 company_details_missing - 422 spend_cap_missing - 422 assistant_not_configured - 422 number_required - 429 rate_limited - 429 call_limit_reached - 429 concurrent_call_limit - 500 internal_error - 502 upstream_error - 503 service_unavailable - 503 telephony_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the call. Response 200: The call. Body: CallDetail. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - phone_number (in query, string (pattern ^\+[1-9]\d{6,14}$)): Optional: only the consents for this number, in E.164. Response 200: The consents. Body: CallConsentList. Example: ```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 (status and type slug; see Errors below): - 400 invalid_parameter - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (CallConsentInput): Consent from one person to receive marketing calls from this project. - phone_number (string (pattern ^\+[1-9]\d{6,14}$), required): The number of the person who gave consent, in E.164. Stored only as a keyed hash plus its last two digits. - source ("form" | "call" | "import" | "manual", required): 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 (string (length 1..500, pattern ^[^\u0000-\u001F\u007F]+$), required): 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. Example request body: ```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" } ``` Response 200: Valid consent for this number already existed; it is returned unchanged. Body: CallConsent. Example: ```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" } ``` Response 201: Recorded. Body: CallConsent. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the consent. Response 200: The consent, now revoked. Body: CallConsent. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /webhooks List the webhook endpoints. The project's webhook endpoints, oldest first. The secret is never included. Scope: webhooks:write Response 200: The endpoints. Body: WebhookEndpointList. Example: ```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 } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (WebhookEndpointInput): A new webhook endpoint. - url (string (length 0..2048), required): An https URL with a domain name on the default port, without credentials, that resolves to a public address. - events (array of "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" (1.. items), required): 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. Example request body: ```json { "url": "https://your-app.example/hooks/hypamoon", "events": [ "call.completed", "call.summary.ready" ] } ``` Response 201: The endpoint, with its secret. Body: WebhookEndpointCreated. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 400 invalid_webhook_endpoint - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 409 webhook_limit_reached - 429 rate_limited - 500 internal_error - 503 service_unavailable ## DELETE /webhooks/{id} Delete a webhook endpoint. Deletes the endpoint, its pending deliveries and its delivery log. Scope: webhooks:write Parameters: - id (in path, string (uuid), required): The id of the webhook endpoint. Response 204: Deleted. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the webhook endpoint. Response 200: The endpoint. Body: WebhookEndpoint. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the webhook endpoint. Response 200: The outcome of the first delivery. Body: WebhookTestResult. Example: ```json { "delivery_id": "3b2a1908-f7e6-4d5c-8b4a-392817160514", "event_id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3", "status": "delivered", "response_status": 204, "error": null } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 webhook_endpoint_disabled - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (TestCallInput): - number_id (string (uuid), required): The id of one of your test numbers, from GET /numbers. - scenario ("reservation" | "callback_request" | "question" | "missed", required): 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 (length 1..300)) Example request body: ```json { "number_id": "0d1e2f3a-4b5c-4d6e-8f70-8192a3b4c5d6", "scenario": "question", "caller_goal": "Ask whether you are open on Sunday." } ``` Response 201: The simulated call, as GET /calls/{id} returns it. Body: CallDetail. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 test_free_replies_used - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 test_key_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - Idempotency-Key (in header, string (length 1..255, pattern ^[\x21-\x7E]+$)): 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 (MessageInput): A transactional SMS to one recipient. - to (string (pattern ^\+[1-9]\d{6,14}$), required): 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 (string (length 1..1600), required): 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 (length 1..32)): 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 (length 1..255, pattern ^[^\u0000-\u001F\u007F]+$)): Optional: your own id for this message, like an order number. Comes back in the message and its webhooks. Example request body: ```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" } ``` Response 201: The message was accepted (or, in test mode, simulated). Body: Message. Example: ```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 } ``` Headers: Idempotent-Replayed. Errors (status and type slug; see Errors below): - 400 idempotency_key_invalid - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 payment_mandate_missing - 402 billing_blocked - 402 insufficient_balance - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 phone_verification_required - 409 idempotency_key_reused - 409 idempotency_request_in_progress - 422 validation_failed - 422 destination_not_allowed - 422 message_too_long - 422 sender_not_allowed - 422 company_details_missing - 422 spend_cap_missing - 429 rate_limited - 429 message_limit_reached - 500 internal_error - 502 upstream_error - 503 service_unavailable - 503 telephony_unavailable ## 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 Parameters: - limit (in query, integer (1.., default 25)): Messages per page, default 25. A value above 100 is capped at 100. - cursor (in query, string): The next_cursor of the previous page. Response 200: A page of messages. Body: MessageList. Example: ```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 (status and type slug; see Errors below): - 400 invalid_parameter - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /messages/{id} Get a message. One SMS message with its current status: queued, sent, delivered or failed. Scope: messages:read Parameters: - id (in path, string (uuid), required): The id of the message. Response 200: The message. Body: Message. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - Idempotency-Key (in header, string (length 1..255, pattern ^[\x21-\x7E]+$)): Optional but recommended: 1 to 255 visible ASCII characters without spaces. The same key within 24 hours returns the same conversation. Request body (ConversationInput): A new chat conversation with the assistant of the project. - channel ("chat"): The channel. Only chat for now; it is also the default. - reference (string (length 1..255, pattern ^[^\u0000-\u001F\u007F]*$)): Optional: your own id for this conversation, like a session or ticket id. Comes back in the conversation and its webhook. - metadata (map of string (length 0..500) or number or boolean): 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. - name (string (length 1..100, pattern ^[^\u0000-\u001F\u007F]*$)) - phone (string (length 1..32, pattern ^[0-9+()\s.-]+$)) - email (string (email, length 0..254)) Example request body: ```json { "reference": "session-8841", "metadata": { "page": "/pricing", "plan": "pro" }, "customer": { "name": "Sam Jones", "email": "sam@example.com" } } ``` Response 201: The conversation, with the greeting as its first message. Body: ConversationDetail. Example: ```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 } ``` Headers: Idempotent-Replayed. Errors (status and type slug; see Errors below): - 400 idempotency_key_invalid - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 payment_mandate_missing - 402 billing_blocked - 402 insufficient_balance - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 409 idempotency_key_reused - 409 idempotency_request_in_progress - 422 validation_failed - 422 company_details_missing - 422 spend_cap_missing - 422 assistant_not_configured - 429 rate_limited - 429 conversation_limit_reached - 500 internal_error - 503 service_unavailable ## 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 Parameters: - limit (in query, integer (1.., default 25)): Conversations per page, default 25. A value above 100 is capped at 100. - cursor (in query, string): The next_cursor of the previous page. - status (in query, "open" | "closed"): Only open or only closed conversations. Response 200: A page of conversations. Body: ConversationList. Example: ```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 (status and type slug; see Errors below): - 400 invalid_parameter - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the conversation. Response 200: The conversation. Body: ConversationDetail. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the conversation. - Idempotency-Key (in header, string (length 1..255, pattern ^[\x21-\x7E]+$)): 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 (in 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 (ChatMessageInput): One message from the customer. - text (string (length 1..2000), required): What the customer typed, at most 2000 characters. Send it as is; it is never treated as an instruction to the assistant. Example request body: ```json { "text": "My kitchen tap is leaking. Can someone come today?" } ``` Response 200: The customer's message and the assistant's reply. Body: ChatTurn. Example: ```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 } } ``` Headers: Idempotent-Replayed. Errors (status and type slug; see Errors below): - 400 idempotency_key_invalid - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 payment_mandate_missing - 402 billing_blocked - 402 insufficient_balance - 402 test_free_replies_used - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conversation_closed - 409 conversation_turn_limit_reached - 409 conversation_busy - 409 idempotency_key_reused - 409 idempotency_request_in_progress - 422 validation_failed - 422 company_details_missing - 422 spend_cap_missing - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the conversation. Response 200: The conversation, closed. Body: Conversation. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Response 200: The tokens and the daily spending limit. Body: ChatTokenList. Example: ```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": "", "kind": "chat" } ], "daily_spend_limit_eur": 5 } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (ChatTokenInput): A new public chat token for the website widget. - name (string (length 1..80), required): A name for your own overview, like the website it is for. - display_name (string (length 0..80) 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 (array of string (length 0..200) (0..10 items), required): 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 (default true)): Ask for a bot check before a conversation starts. Default true. - max_conversations_per_day (integer (1..1000, default 200)): New conversations per day through this token. Default 200, at most 1000. Example request body: ```json { "name": "acme-plumbing.example", "allowed_origins": [ "https://www.acme-plumbing.example", "https://acme-plumbing.example" ] } ``` Response 201: The token, with embed_code. Body: ChatToken. Example: ```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": "", "kind": "chat" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /chat-tokens/{id} Get a public chat token. One public chat token with its embed_code. Scope: chat:read Parameters: - id (in path, string (uuid), required): The id of the public chat token. Response 200: The token. Body: ChatToken. Example: ```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": "", "kind": "chat" } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the public chat token. Request body (ChatTokenUpdate): Change a public chat token. Fields you leave out stay as they are. - name (string (length 1..80)) - display_name (string (length 0..80) 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 (array of string (length 0..200) (0..10 items)) - turnstile (boolean) - max_conversations_per_day (integer (1..1000)) Example request body: ```json { "allowed_origins": [ "https://www.acme-plumbing.example", "https://acme-plumbing.example", "https://shop.acme-plumbing.example" ] } ``` Response 200: The token as changed. Body: ChatToken. Example: ```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": "", "kind": "chat" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 chat_token_revoked - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the public chat token. Response 200: The token, revoked. Body: ChatToken. Example: ```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": "", "kind": "chat" } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Response 200: The sites. Body: SiteList. Example: ```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 } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (SiteInput): A new static site. - slug (string (length 0..63)): 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 (string (length 1..80), required): A name for your own overview, like the business or the page. - files (array of object (1.. items), required): 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. - path (string (length 1..200), required): Where the file lives on the site, like /index.html, /style.css or /img/logo.png. Relative paths (index.html) get a leading /. - content (string): The file itself: plain text for html, css, js, json, txt, xml, svg, webmanifest and md; base64 for png, jpg, gif, webp, ico, woff, woff2, pdf, mp4, webm, mp3 and m4a only when there is no URL for it (prefer source_url; a video or audio file via source_url or an upload, never base64). Leave it out for a file with source_url. - source_url (string (length 9..2048)): Instead of content: a public https URL of the file (port 443), for images, fonts, PDFs, video and audio above all. Hypamoon fetches it once (at most 3 redirects, within the size limits of the plan) and checks it like a file sent with content. Its type must match the path (a .jpg path needs a JPEG, a .mp4 path an MP4). Leave encoding out. Use it instead of base64 whenever the file has a URL; never send a video as base64. - encoding ("utf8" | "base64"): utf8 for text, base64 for images, fonts, PDF, video and audio. Leave it out: it follows from the file type. - content_type (string (length 0..100)): Leave it out: it follows from the extension (.html is text/html). Only for a path without a known extension, and only an allowed type. - password (string (length 0..72) 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 (array of string (length 1..200) (1..200 items)): 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 (length 0..500)): 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". Example request body: ```json { "slug": "acme-plumbing", "name": "Acme Plumbing", "files": [ { "path": "/index.html", "content": "Acme Plumbing

Acme Plumbing

Leaking tap? Call us.

" }, { "path": "/style.css", "content": "body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }" } ] } ``` Response 201: The site, live at url. Body: Site. Example: ```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 } ``` Response 202: Four-eyes approval is on: the deploy waits for another owner or builder. Nothing has changed on the live site yet. Body: DeployApproval. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 phone_verification_required - 409 slug_taken - 409 plan_limit_reached - 422 validation_failed - 422 site_too_large - 422 upload_not_received - 422 slug_reserved - 422 site_limit_reached - 422 site_blocked - 429 rate_limited - 429 deploy_limit_reached - 500 internal_error - 503 service_unavailable ## 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 ''); 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 (SiteUploadInput): Ask for upload links for files that are too large to send inline. - 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 (array of object (1..200 items), required): The files to upload, at most 200 per request. - path (string (length 1..200), required): Where the file will live on the site, like /img/hero.jpg. - sha256 (string (length 0..64), required): The SHA-256 of the file's bytes, as 64 lowercase hex characters (sha256sum, shasum -a 256). - bytes (integer (0..), required): The exact size of the file in bytes. - content_type (string (length 0..100)): Leave it out: it follows from the extension. Only for a path without a known extension, and only an allowed type. Example request body: ```json { "files": [ { "path": "/img/hero.jpg", "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "bytes": 2483112 } ] } ``` Response 201: The upload links. Body: SiteUploadList. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 422 validation_failed - 422 site_too_large - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (SiteDropInput): Create a drop link: a page where the user uploads files and whole folders in a browser. - 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 (length 1..120)): 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//. Example request body: ```json {} ``` Response 201: The drop link. Body: SiteDrop. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the drop link. Response 200: The drop link and its files. Body: SiteDrop. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - folder (in query, string): Only this folder under /media/, with its subfolders, like portfolio. - path (in query, string): Only the file with this path, like /media/logo.webp. - site_id (in query, string (uuid)): Only the files this site uses. - limit (in query, integer (1..500)): At most this many files (default 100). - cursor (in query, string): next_cursor from the previous page. Response 200: The files and the folders. Body: MediaList. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (MediaLinkInput): Create an upload page for the media library. - folder (string (length 1..120)): 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. Example request body: ```json { "folder": "portfolio" } ``` Response 201: The upload page. Body: SiteDrop. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the file. Response 200: The file. Body: MediaFile. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the file. Request body (MediaUpdate): 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. - name (string (length 1..120)): A new file name. The extension always stays that of the file. - folder (string (length 0..120) or null): Move the file to this folder under /media/; null or an empty string for /media/ itself. - alt (string (length 0..500) or null): The alt text for the image; null removes it. Example request body: ```json { "alt": "Apple pie on a wooden board" } ``` Response 200: The file after the change. Body: MediaFile. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the file. Response 204: Deleted. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /sites/{id} Get a site. One site with its url and its files, without their content. Scope: sites:read Parameters: - id (in path, string (uuid), required): The id of the site. Response 200: The site. Body: Site. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. Request body (SiteUpdate): 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. - name (string (length 1..80)): A name for your own overview, like the business or the page. - files (array of 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. - path (string (length 1..200), required): Where the file lives on the site, like /index.html, /style.css or /img/logo.png. Relative paths (index.html) get a leading /. - content (string): The file itself: plain text for html, css, js, json, txt, xml, svg, webmanifest and md; base64 for png, jpg, gif, webp, ico, woff, woff2, pdf, mp4, webm, mp3 and m4a only when there is no URL for it (prefer source_url; a video or audio file via source_url or an upload, never base64). Leave it out for a file with source_url. - source_url (string (length 9..2048)): Instead of content: a public https URL of the file (port 443), for images, fonts, PDFs, video and audio above all. Hypamoon fetches it once (at most 3 redirects, within the size limits of the plan) and checks it like a file sent with content. Its type must match the path (a .jpg path needs a JPEG, a .mp4 path an MP4). Leave encoding out. Use it instead of base64 whenever the file has a URL; never send a video as base64. - encoding ("utf8" | "base64"): utf8 for text, base64 for images, fonts, PDF, video and audio. Leave it out: it follows from the file type. - content_type (string (length 0..100)): Leave it out: it follows from the extension (.html is text/html). Only for a path without a known extension, and only an allowed type. - 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 (array of string (length 1..200) (0..50 items)): 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 (array of string (length 1..200) (1..200 items)): 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 (length 0..64)): 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 (length 0..72) 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 (length 0..500)): 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. Example request body: ```json { "files": [ { "path": "/index.html", "content": "Acme Plumbing

Acme Plumbing

Leaking tap? Call us.

" }, { "path": "/style.css", "content": "body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }" } ], "password": "spring-tour-2027" } ``` Response 200: The site as it is now. Body: Site. Example: ```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 } ``` Response 202: Four-eyes approval is on: the update waits for another owner or builder. Nothing has changed on the live site yet. Body: DeployApproval. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 403 phone_verification_required - 404 not_found - 409 version_conflict - 422 validation_failed - 422 site_too_large - 422 upload_not_received - 422 site_blocked - 429 rate_limited - 429 deploy_limit_reached - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. - permanent (in 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. Response 202: Four-eyes approval is on: the deletion waits for another owner or builder. Nothing has changed on the live site yet. Body: DeployApproval. Example: ```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 } ``` Response 204: Deleted. Without permanent the site can be restored for 30 days. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Response 200: The deleted sites. Body: DeletedSiteList. Example: ```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 } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. Response 200: The site, live again. Body: RestoredSite. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 409 conflict - 422 site_limit_reached - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - name (in query, string (length 0..80)): The name of the site or business, like Acme Plumbing. - slug (in query, string (length 0..63)): The address the user would like, like acme-plumbing. Response 200: The requested address, checked, and available suggestions. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. Request body: Change the address of a site, its visibility, or both. - slug (string (length 0..63)): 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. Example request body: ```json { "slug": "acme-plumbing-rotterdam" } ``` Response 200: The site with its new address. Example: ```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" } ``` Response 202: Four-eyes approval is on: the change waits for another owner or builder. Nothing has changed yet. Body: DeployApproval. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 403 phone_verification_required - 404 not_found - 409 slug_taken - 409 conflict - 422 validation_failed - 422 slug_reserved - 422 site_blocked - 429 rate_limited - 429 deploy_limit_reached - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. Response 200: The site with its new secret link in url. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 409 conflict - 429 rate_limited - 429 deploy_limit_reached - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. - limit (in query, integer (1..100)): How many versions per page (default 20). - cursor (in query, integer (1..)): The next_cursor of the previous page. Response 200: The versions, newest first. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. - from (in query, string): The id or number of the older version. - to (in query, string): The id or number of the newer version. Response 200: What changed. Example: ```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-

Open 9 to 5

\n+

Open 8 to 6

\n" } ], "truncated": false } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. Request body: Which version to put back live: version_id or number. - version_id (string (uuid)): The id of the version to go back to, from list_site_versions. - number (integer (1..)): Instead of version_id: the number of the version. - if_match_version (string (length 0..64)): 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 (length 0..500)): Optional. A short description for the version history, like "Back to the old menu". Example request body: ```json { "version_id": "0b6c7e2a-1f3d-4e5a-9c8b-7d6e5f4a3b21", "message": "Back to the old opening hours" } ``` Response 200: The site as it is now live, with the new version. Example: ```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 } ``` Response 202: Four-eyes approval is on: the rollback waits for another owner or builder. Nothing has changed yet. Body: DeployApproval. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 409 conflict - 409 version_conflict - 422 validation_failed - 422 site_blocked - 429 rate_limited - 429 deploy_limit_reached - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. Request body: Which files to restore, and from which version. - version_id (string (uuid)): The id of the version to take the files from, from list_site_versions. - number (integer (1..)): Instead of version_id: the number of the version. - paths (array of string (length 1..200) (1..100 items), required): The paths to restore, like ["/app.js", "/style.css"]. Every other file stays as it is now. - if_match_version (string (length 0..64)): 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 (length 0..500)): Optional. A short description for the version history, like "Back to the old menu". Example request body: ```json { "number": 4, "paths": [ "/app.js", "/style.css" ], "message": "Back to the working app.js and style.css" } ``` Response 200: The site as it is now live, with the new version. Example: ```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 } ``` Response 202: Four-eyes approval is on: the restore waits for another owner or builder. Nothing has changed yet. Body: DeployApproval. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 409 conflict - 409 version_conflict - 422 validation_failed - 422 site_blocked - 429 rate_limited - 429 deploy_limit_reached - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. - versionId (in path, string (uuid), required): The id of the version, from list_site_versions. Request body: Optional settings for putting the preview live. - if_match_version (string (length 0..64)): 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 (length 0..500)): Optional. A short description for the version history, like "Back to the old menu". Example request body: ```json { "message": "Spring menu" } ``` Response 200: The site as it is now live, with the new version. Example: ```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 } ``` Response 202: Four-eyes approval is on: publishing the preview waits for another owner or builder. Body: DeployApproval. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 409 conflict - 409 version_conflict - 422 validation_failed - 422 site_blocked - 429 rate_limited - 429 deploy_limit_reached - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. - versionId (in path, string (uuid), required): The id of the version, from list_site_versions. Request body: The label of a version. - label (string (length 0..60) or null, required): A name for this version, like "before launch" (1 to 60 characters, unique per site). null or an empty string removes it. Example request body: ```json { "label": "before launch" } ``` Response 200: The version with its label. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. Response 200: The verdict per store, every check and the next steps. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. - app_name (in query, string (length 0..30)): The name of the app, if the user chose one. Leave it out to use the site's name. Response 200: The draft listing. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. Response 200: The answers per store, with reasons and console steps. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. Request body: - app_id (string (length 0..155)): 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 (length 1..30)): The name under the icon. Leave it out to use the site's name. - icon_path (string (length 0..300)): 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 (length 0..7)): Background of the icon and splash screen as a hex color, like #ffffff. Leave it out to use the site's theme color. Example request body: ```json { "app_id": "com.acmeplumbing.app", "icon_path": "/icon-1024.png" } ``` Response 201: The project is ready to download. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - type (in 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 (in query, string (length 0..500)): Only with type app: what the app should do, in the user's words (one or two sentences). - topic (in 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 (in query, string (length 0..60)): Where the business is: an ISO country code like NL, or a name. Leave it out for the Netherlands. - audience (in query, "consumers" | "businesses" | "both"): Who the user sells to; consumers (the default) adds the consumer rules. Response 200: The blueprint, with a short markdown summary. With topic: that one known problem (id, symptom, what_to_do, tell_user, other_topics). Body: Advice. Example: ```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": "", "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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. - type (in query, "webshop" | "bookings" | "restaurant" | "course_membership" | "wordpress_portal"): The kind of application; leave it out to detect it from the site. Response 200: What works, what is missing, and what to check with the owner. Body: SiteReadiness. Example: ```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 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 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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (MoveToLiveInput): Move sites from the test project to the live project. - sites (array of string (length 1..100) (0..100 items)): Optional. The sites to move, by id or slug. Leave it out to move every site of the test project. - collections (array of string (length 1..100) (0..100 items)): 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. Example request body: ```json { "sites": [ "bakkerij-jansen" ] } ``` Response 200: What moved, what stayed, and the next step. Body: MoveToLive. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 409 plan_limit_reached - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (MoveToLiveUndoInput): Undo a move to live within 24 hours. - move_id (string (uuid)): Optional. The id from move_to_live. Leave it out to undo the latest move of this project. Example request body: ```json { "move_id": "0f6c2a8e-7d3b-4c1e-9a55-2b8f6d4e1c90" } ``` Response 200: What went back. Body: MoveToLiveUndo. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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=<token> 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 <slug>.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 (SearchConsoleVerifyInput): - 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 (length 3..253)): A domain connected in this project (connect_domain), like example.com. For method dns; for meta, the site on this domain. - method ("dns" | "meta", required): 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 (string (length 10..500), required): The verification token from Search Console: the value alone, google-site-verification=..., or the whole meta tag. Example request body: ```json { "domain": "bakkerijjansen.nl", "method": "dns", "token": "Abc123dEf456gHi789jKl012mNo345pQr678sTu901v" } ``` Response 200: What was added and what the user does next. Body: SearchConsoleVerification. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. - days (in query, integer (1..90, default 30)): How many days back, today included (default 30). Response 200: Page views per day, top pages and origins. Body: SiteStats. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the site. - path (in query, string (length 0..2000, default "/")): The path on the site's address, like / or /contact or /api/health, with a query if needed. Give this or url. - url (in query, string (uri, length 0..2048)): A full https URL on an address of this site, like a connected domain. Give this or path. - method (in query, "GET" | "HEAD"): GET (default) or HEAD. Response 200: What the site answered. Body: SitePage. Example: ```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", "body_bytes": 4210, "truncated": false, "elapsed_ms": 182 } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## GET /domains List the domains. The domains of the project, newest first, each with its status, nameservers, site and next_step. Scope: sites:read Response 200: The domains. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (DomainConnectInput): - domain (string (length 3..300), required): The domain, like example.com. - site_id (string (uuid) or null): 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 (array of string (pattern ^[A-Za-z0-9_-]{1,63}$) (0..10 items)): 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. Example request body: ```json { "domain": "example.com", "site_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f" } ``` Response 200: The domain was already connected; its site is updated. Response 201: The domain, with nameservers, mail_check and next_step. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 phone_verification_required - 404 not_found - 409 conflict - 409 confirmation_required - 422 validation_failed - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Response 200: The watched domains. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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: - domain (string (length 3..300), required): The domain, like example.com. It can be registered anywhere. - expected_hosts (array of "vercel" | "netlify" | "github" | "heroku" | "aws" | "azure" | "shopify" | "fly" | "render" (0..10 items)): 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 (pattern ^[a-z]{2}$) 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 (length 0..300)): 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. Example request body: ```json { "domain": "example.com", "expected_hosts": [ "netlify" ], "park": "page" } ``` Response 200: The watched domain, with its health. Response 202: Watched, waiting for the ownership proof in ownership.challenge. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 403 phone_verification_required - 404 not_found - 409 conflict - 409 plan_limit_reached - 422 validation_failed - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Response 200: The cleanup suggestion. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Request body: - include_record_ids (array of string (length 1..64) (0..500 items)): 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 (array of string (length 1..64) (0..500 items)): Optional. Records labeled safe_to_remove that the user wants to keep this time. Example request body: ```json { "include_record_ids": [ "372e67954025e0ba6aaa6d586b9e0b59" ] } ``` Response 200: What was removed, what failed, the zone file and until when it can be restored. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Request body: - cleanup_id (string (uuid), required): The id of the cleanup to undo (recent_cleanups in get_dns_cleanup). Example request body: ```json { "cleanup_id": "0b6f0e0e-2a7c-4d39-9a8e-1d3c5b7a9e21" } ``` Response 200: What was added again, what was already there and what failed. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - domain (in query, string, required): The domain, like example.com. Response 200: The check. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 422 validation_failed - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - query (in query, string): A few words, like 'corner bakery'. Required without names. - tlds (in query, string): Optional. Comma-separated extensions, like nl,com. - names (in query, string): Optional. Up to ten comma-separated names to check, with or without extension, like getcrumb,crumbhq.com. Response 200: The available domains. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 422 validation_failed - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - Idempotency-Key (in header, string (length 0..255), required): A unique key for this purchase, like a UUID. Request body (DomainRegisterInput): - domain (string (length 3..300), required): The domain, like example.com. - years (integer (1..10, default 1)): Registration period in years. Most extensions only allow 1. - accept_registry_terms (boolean, required): Must be true: the user (the registrant) explicitly accepted the terms of the registry at registry_terms_url from check_domain. - max_price_eur (number (..10000), required): 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 (..10000)): 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 (uuid) or null): Optional. A site of this project to serve on the domain once it is active. Example request body: ```json { "domain": "example.nl", "years": 1, "accept_registry_terms": true, "max_price_eur": 7.32 } ``` Response 201: The registered domain. Headers: Idempotent-Replayed. Errors (status and type slug; see Errors below): - 400 invalid_json - 400 idempotency_key_missing - 400 idempotency_key_invalid - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 payment_mandate_missing - 402 billing_blocked - 402 insufficient_balance - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 403 phone_verification_required - 404 not_found - 409 idempotency_key_reused - 409 idempotency_request_in_progress - 409 conflict - 422 validation_failed - 422 company_details_missing - 422 spend_cap_missing - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Response 200: The domain. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Response 200: Disconnected, with the previous nameservers to restore. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Request body (DomainUpdateInput): - 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. Example request body: ```json { "hsts": "on" } ``` Response 200: The domain, with hsts, primary and registration.auto_renew. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Response 200: The domain. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Request body (DomainSiteInput): - site_id (string (uuid) or null, required): The site to serve on the domain, or null for none. Example request body: ```json { "site_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f" } ``` Response 200: The domain. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 phone_verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Response 200: The transfer code. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Response 200: The records. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Request body (DnsRecordInput): - record_id (string (length 1..64)): Optional. The id from list_dns_records of the record to change or delete. Without it, a new record is added. - type ("A" | "AAAA" | "CNAME" | "MX" | "TXT" | "SRV" | "CAA" | "NS", required): The record type. - name (string (length 1..253), required): The name: @ for the domain itself, or a subdomain like www, mail or _dmarc. - content (string (length 0..4096)): 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 (1..86400)): Seconds; 1 means automatic. - priority (integer (0..65535)): 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. Example request body: ```json { "type": "TXT", "name": "@", "content": "google-site-verification=abc123" } ``` Response 200: The record (or deleted: true), what it replaced, and how to undo it. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 409 confirmation_required - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Request body: - 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. Example request body: ```json { "confirm": true } ``` Response 200: What was undone: removed, restored and skipped records. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 409 confirmation_required - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - domain (in query, string): The domain or subdomain, like example.com or portal.example.com. - domain_id (in query, string (uuid)): Instead of domain: the id of a domain of this project. Response 200: The diagnosis: nameservers, names, mail, records, problems with a fix each, and user_message. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Request body (ZoneFileInput): - zone_file (string (length 1..262144), required): 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. Example request body: ```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 } ``` Response 200: What was added, removed, skipped and failed, with the restore point to undo it. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Response 200: The checks, the blocking ones first, and ready. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Request body (DnsRestoreInput): - 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). Example request body: ```json {} ``` Response 200: What changed, the zone file and the way back. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the domain. Request body (DomainNameserversInput): - use ("hypamoon" | "previous", required): 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). Example request body: ```json { "use": "hypamoon" } ``` Response 200: The domain, with the nameservers now set at the registry. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 409 confirmation_required - 422 validation_failed - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - Idempotency-Key (in header, string (length 0..255), required): A unique key for this transfer, like a UUID. Request body (DomainTransferInput): - domain (string (length 3..300), required): The domain to move to Hypamoon, like example.com. - authcode (string (length 1..255)): 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 (boolean, required): Must be true: the user (the registrant) explicitly accepted the terms of the registry at registry_terms_url from check_domain. - max_price_eur (number (0..10000), required): 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 (uuid) or null): Optional. A site of this project to serve on the domain once it is active. - dkim_selectors (array of string (pattern ^[A-Za-z0-9_-]{1,63}$) (0..10 items)): 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. Example request body: ```json { "domain": "example.nl", "authcode": "AbC123xyz!", "accept_registry_terms": true, "max_price_eur": 7.32, "dns": "hypamoon" } ``` Response 201: The transfer completed. Headers: Idempotent-Replayed. Response 202: The transfer was requested and is in progress; transfer.status shows where it stands. Errors (status and type slug; see Errors below): - 400 invalid_json - 400 idempotency_key_missing - 400 idempotency_key_invalid - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 payment_mandate_missing - 402 billing_blocked - 402 insufficient_balance - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 idempotency_key_reused - 409 idempotency_request_in_progress - 409 conflict - 409 confirmation_required - 422 validation_failed - 422 company_details_missing - 422 spend_cap_missing - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 (SiteImportInput): - url (string (length 8..2000), required): 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 (length 1..80)): Optional. A name for the new site; default the host name. - slug (string (length 0..63)): Optional. The address of a new site, like deploy_site's slug. - max_pages (integer (1..50)): Optional. At most this many pages (default 25, at most 50). Example request body: ```json { "url": "https://www.example.com" } ``` Response 200: The replaced site, with import. Response 201: The new site, with import. Response 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. Body: DeployApproval. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 403 site_offline - 404 not_found - 409 plan_limit_reached - 409 slug_taken - 422 validation_failed - 422 site_limit_reached - 422 slug_reserved - 422 site_too_large - 422 site_blocked - 429 rate_limited - 429 deploy_limit_reached - 500 internal_error - 503 service_unavailable ## 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 (WordpressInspectInput): - url (string (length 8..2000), required): The https address of the WordPress site, like https://www.example.com. Any public site: nothing is changed and no login is used. Example request body: ```json { "url": "https://www.example.com" } ``` Response 200: What was found, what can move (can_move) and what cannot (cannot_move), with next_step. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (WordpressImportInput): - url (string (length 8..2000), required): 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 (length 0..64)): With keep_existing: the version of the site from get_site or list_sites. If the site changed since, nothing changes (409). - name (string (length 1..80)): Optional. A name for the new site; default the site's own name. - slug (string (length 0..63)): Optional. The address of a new site, like deploy_site's slug. - max_posts (integer (0..500)): Optional. Read at most this many posts, newest first (default all, at most 500). Example request body: ```json { "url": "https://www.example.com" } ``` Response 200: The updated site, with import and data. Response 201: The new site, with import and data. Response 202: Four-eyes approval is on: the imported site waits for another owner or builder. Nothing has changed on the live site yet. Body: DeployApproval. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 403 site_offline - 404 not_found - 409 version_conflict - 409 plan_limit_reached - 409 slug_taken - 412 version_conflict - 422 validation_failed - 422 site_limit_reached - 422 slug_reserved - 422 site_too_large - 422 site_blocked - 429 rate_limited - 429 deploy_limit_reached - 500 internal_error - 503 service_unavailable ## 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: the code is bound to the project of your key. Example request body: ```json {} ``` Response 201: The code, when it expires, and next_step for the agent. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Response 200: data: the connections. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the WordPress connection. Response 200: The connection with inventory, selection and next_step. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the WordPress connection. Response 204: Revoked. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the WordPress connection. Request body: - items (map of object): Per inventory key (like content:post, orders or form_entries:wpforms:12) what moves. Keys you leave out keep their current choice. - enabled (boolean, required): true: this kind of data moves in the next round. - since (string (pattern ^\d{4}-\d{2}-\d{2}$) or null): Only records dated on or after this day (like 2024-10-12). null: no limit. - until (string (pattern ^\d{4}-\d{2}-\d{2}$) or null): Only records dated on or before this day. null: no limit. - statuses (array of "publish" | "draft" | "pending" | "private" | "future" (0..5 items) or null): Only for content (pages, posts, custom post types): which statuses move. null: all of publish, draft, pending, private and future. - omit_fields (array of string (length 1..100) (0..50 items) or null): Field names to leave out everywhere in the record, like phone or ip. null: none. - include_meta (boolean or null): Only for content (pages, posts, custom post types) and users: true also sends all other meta of each item (for content also the hidden _ keys; never passwords, keys or tokens), and turns fields that point to a user or a post (like opdrachtgever_id) into references (relations). Needs Hypamoon Connect 1.2.0. null: only the usual fields. - names (array of string (length 1..191) (0..200 items) or null): Only for options: the option names to send, from the fields of the options item in the inventory. Names that look like a password, key, token, secret, salt or auth setting are refused. Required to turn options on. - version (integer (0..) 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 (length 0..500) 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). Example request body: ```json { "items": { "orders": { "enabled": true, "since": "2024-10-12", "omit_fields": [ "phone" ] } }, "version": 2 } ``` Response 200: The new selection, with its version. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the WordPress connection. Response 200: inventory (compact), suggestion and selection_version. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the WordPress connection. Request body: - goal (string (length 3..300), required): What the user wants, in their words, like: move my hours portal, or only my pages and blog. - items (map of object, required): 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. - enabled (boolean, required): true: this kind of data moves in the next round. - since (string (pattern ^\d{4}-\d{2}-\d{2}$) or null): Only records dated on or after this day (like 2024-10-12). null: no limit. - until (string (pattern ^\d{4}-\d{2}-\d{2}$) or null): Only records dated on or before this day. null: no limit. - statuses (array of "publish" | "draft" | "pending" | "private" | "future" (0..5 items) or null): Only for content (pages, posts, custom post types): which statuses move. null: all of publish, draft, pending, private and future. - omit_fields (array of string (length 1..100) (0..50 items) or null): Field names to leave out everywhere in the record, like phone or ip. null: none. - include_meta (boolean or null): Only for content (pages, posts, custom post types) and users: true also sends all other meta of each item (for content also the hidden _ keys; never passwords, keys or tokens), and turns fields that point to a user or a post (like opdrachtgever_id) into references (relations). Needs Hypamoon Connect 1.2.0. null: only the usual fields. - names (array of string (length 1..191) (0..200 items) or null): Only for options: the option names to send, from the fields of the options item in the inventory. Names that look like a password, key, token, secret, salt or auth setting are refused. Required to turn options on. - reason (string (length 3..200), required): Why, in a few words for the user, like: needed for the hour history, linked to users via opdrachtgever_id. Example request body: ```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." } } } ``` Response 201: The suggestion with its differences from the current selection, a summary and next_step. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the WordPress connection. Response 200: The chat, the forms, the version and problems. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the WordPress connection. Request body: - chat (object or null): The chat window on the site. Leave it out to keep it as it is. - forms (array of object (0..20 items) or null): Forms to offer or change. Forms you leave out keep their settings. - version (integer (0..) or null): The version you showed the user. If it changed meanwhile, nothing changes (409). Leave it out to change it anyway. Example request body: ```json { "chat": { "enabled": true, "color": "#0f766e" }, "forms": [ { "collection": "contact", "title": "Contact us" } ], "version": 0 } ``` Response 200: The new settings, as GET returns them. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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: the one-time connect code is in the body. Request body: - code (string (length 8..20), required): The one-time code of 8 characters, like ABCD-EF23 (dashes, spaces and case do not matter). - site_url (string (length 8..400), required): The address of the WordPress site (home_url), like https://www.example.com. - site_name (string (length 0..200) or null): The name of the WordPress site. - wordpress_version (string (length 0..40) or null): The WordPress version, like 6.6.2. - plugin_version (string (length 0..40) or null): The version of Hypamoon Connect, like 1.0.0. Example request body: ```json { "code": "ABCD-EF23", "site_url": "https://www.example.com", "site_name": "Example", "wordpress_version": "6.6.2", "plugin_version": "1.0.0" } ``` Response 200: The token, the connection, the project and the limits. Errors (status and type slug; see Errors below): - 400 invalid_json - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error ## 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. Auth: the token of the Hypamoon Connect plugin (hm_wp_...) with X-Hypamoon-Site, not an API key. Response 200: The connection. Errors (status and type slug; see Errors below): - 401 unauthorized - 403 forbidden - 429 rate_limited - 500 internal_error ## DELETE /wordpress/connection Disconnect (plugin). Called by the plugin when the admin disconnects: the token stops working. The collections stay. Auth: the token of the Hypamoon Connect plugin (hm_wp_...) with X-Hypamoon-Site, not an API key. Response 204: Disconnected. Errors (status and type slug; see Errors below): - 401 unauthorized - 403 forbidden - 429 rate_limited - 500 internal_error ## 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). Auth: the token of the Hypamoon Connect plugin (hm_wp_...) with X-Hypamoon-Site, not an API key. Request body: - items (array of object (0..500 items), required): One item per kind of data: counts, period and field names, never content. - key (string (pattern ^[a-z_]{2,30}(:[A-Za-z0-9_.-]{1,80}){0,2}$), required) - type ("content" | "media" | "menus" | "terms" | "acf_field_groups" | "acf_options" | "form_entries" | "products" | "orders" | "customers" | "users" | "coupons" | "shipping" | "tax" | "reviews" | "subscriptions" | "table" | "options", required) - label (string (length 1..200), required) - count (integer (0..100000000), required) - oldest (string (length 0..40) or null) - newest (string (length 0..40) or null) - statuses (map of integer (0..) or null) - personal_data (boolean or null) - fields (array of object (0..500 items) or null) - form (object or null) - post_type (string (length 0..40) or null) - public (boolean or null) - primary_key (string (length 0..64) or null) - movable (boolean or null) - note (string (length 0..500) or null) Example request body: ```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" } ] } ``` Response 200: inventory_at and the selection. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 403 forbidden - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error ## 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. Auth: the token of the Hypamoon Connect plugin (hm_wp_...) with X-Hypamoon-Site, not an API key. Response 200: The selection. Errors (status and type slug; see Errors below): - 401 unauthorized - 403 forbidden - 429 rate_limited - 500 internal_error ## 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. Auth: the token of the Hypamoon Connect plugin (hm_wp_...) with X-Hypamoon-Site, not an API key. Request body: - items (map of object): Per inventory key (like content:post, orders or form_entries:wpforms:12) what moves. Keys you leave out keep their current choice. - enabled (boolean, required): true: this kind of data moves in the next round. - since (string (pattern ^\d{4}-\d{2}-\d{2}$) or null): Only records dated on or after this day (like 2024-10-12). null: no limit. - until (string (pattern ^\d{4}-\d{2}-\d{2}$) or null): Only records dated on or before this day. null: no limit. - statuses (array of "publish" | "draft" | "pending" | "private" | "future" (0..5 items) or null): Only for content (pages, posts, custom post types): which statuses move. null: all of publish, draft, pending, private and future. - omit_fields (array of string (length 1..100) (0..50 items) or null): Field names to leave out everywhere in the record, like phone or ip. null: none. - include_meta (boolean or null): Only for content (pages, posts, custom post types) and users: true also sends all other meta of each item (for content also the hidden _ keys; never passwords, keys or tokens), and turns fields that point to a user or a post (like opdrachtgever_id) into references (relations). Needs Hypamoon Connect 1.2.0. null: only the usual fields. - names (array of string (length 1..191) (0..200 items) or null): Only for options: the option names to send, from the fields of the options item in the inventory. Names that look like a password, key, token, secret, salt or auth setting are refused. Required to turn options on. - version (integer (0..) 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 (length 0..500) 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). Example request body: ```json { "items": { "users": { "enabled": true, "omit_fields": null } }, "version": 2 } ``` Response 200: The new selection. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 403 forbidden - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error ## 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. Auth: the token of the Hypamoon Connect plugin (hm_wp_...) with X-Hypamoon-Site, not an API key. Parameters: - outcome (in query, "applied" | "dismissed"): applied or dismissed. Response 200: The status of the suggestion. Errors (status and type slug; see Errors below): - 401 unauthorized - 403 forbidden - 429 rate_limited - 500 internal_error ## 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. Auth: the token of the Hypamoon Connect plugin (hm_wp_...) with X-Hypamoon-Site, not an API key. Response 200: The chat, the forms, the version and problems. Errors (status and type slug; see Errors below): - 401 unauthorized - 403 forbidden - 429 rate_limited - 500 internal_error ## 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. Auth: the token of the Hypamoon Connect plugin (hm_wp_...) with X-Hypamoon-Site, not an API key. Request body: - chat (object or null) - version (integer (0..) or null) Example request body: ```json { "chat": { "enabled": true }, "version": 1 } ``` Response 200: The new settings. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 403 forbidden - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error ## 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. Auth: the token of the Hypamoon Connect plugin (hm_wp_...) with X-Hypamoon-Site, not an API key. Request body: - selection_version (integer (0..), required): The selection version the plugin pulled and shows. A newer one gives 409: pull again. Example request body: ```json { "selection_version": 2 } ``` Response 201: The round: export_id, status and the selection. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 403 forbidden - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error ## 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. Auth: the token of the Hypamoon Connect plugin (hm_wp_...) with X-Hypamoon-Site, not an API key. Parameters: - id (in path, string (uuid), required): The id of the export round. Request body: - batch (string (length 1..120), required): A key for this batch, unique and deterministic within the round, like content:post:after:0. The same key again changes nothing. - key (string (length 2..120), required): The inventory key, like content:post, orders or form_entries:wpforms:12. - type ("content" | "media" | "menus" | "terms" | "acf_field_groups" | "acf_options" | "form_entries" | "products" | "orders" | "customers" | "users" | "coupons" | "shipping" | "tax" | "reviews" | "subscriptions" | "table" | "options", required): The kind of data, the first part of key. - form (object or null): Only for form_entries: the form. - records (array of any (0..200 items), required): At most 200 records, each with wp_id. Example request body: ```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": "

...

" } ] } ``` Response 200: What was stored: received, created, updated, skipped (with reasons), the collections and the totals of the round. Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 402 plan_limit_reached - 403 forbidden - 404 not_found - 409 conflict - 409 plan_limit_reached - 422 validation_failed - 422 collection_limit_reached - 422 document_limit_reached - 429 rate_limited - 500 internal_error ## 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. Auth: the token of the Hypamoon Connect plugin (hm_wp_...) with X-Hypamoon-Site, not an API key. Parameters: - id (in path, string (uuid), required): The id of the export round. Response 200: The round's result. Errors (status and type slug; see Errors below): - 401 unauthorized - 403 forbidden - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error ## 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 Response 200: The collections. Body: CollectionList. Example: ```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 } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 (CollectionInput): A new collection. - name (string (pattern ^[a-z][a-z0-9_]{1,39}$), required): 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 (length 0..500)): 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. - public_write (boolean): Anyone with the project's public token may add documents from an allowed origin, after a bot check and within limits: a form on a website. Default false. - public_read (boolean): Anyone with the project's public token may read the newest documents (data and created_at only) from an allowed origin. Default false. - assistant_read (boolean): The assistant gets the newest documents (added through the API, never those from a form) in its chat instructions, 8 kB at most. Default false. - triggers (object): What happens when a document is added, through the API or a form. - email_on_insert (boolean): Email every new document to the project's billing email address (live projects only, not in test mode). Default false. - webhook_on_insert (boolean): Send the webhook document.created for every new document, to endpoints subscribed to it. Default false. - 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. - to (array of string (length 0..254) (0..5 items)): Where the email goes when no rule matches. Empty or left out: the billing email address. - rules (array of object (0..10 items)): Up to 10 rules on a field of the new document; the first that matches decides where the email goes. - field (string (length 1..64), required): A top-level field of the document, like vacancy or subject. - equals (string (length 0..200) or number or boolean, required): The value that field must have. Text matches without regard to case or surrounding spaces; for a list (checkboxes) one of its values must match. - to (array of string (length 0..254) (1..5 items), required): Where the email goes when this rule matches. - schema (map of any): 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. Example request body: ```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" ] } } ``` Response 201: The new collection. Body: Collection. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 409 collection_name_taken - 422 validation_failed - 422 collection_limit_reached - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string, required): The id of the collection, or its name. Response 200: The collection. Body: Collection. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string, required): The id of the collection, or its name. Request body (CollectionUpdate): Change a collection. Fields you leave out stay as they are. - name (string (pattern ^[a-z][a-z0-9_]{1,39}$)): 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 (length 0..500) 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. - public_write (boolean): Anyone with the project's public token may add documents from an allowed origin, after a bot check and within limits: a form on a website. Default false. - public_read (boolean): Anyone with the project's public token may read the newest documents (data and created_at only) from an allowed origin. Default false. - assistant_read (boolean): The assistant gets the newest documents (added through the API, never those from a form) in its chat instructions, 8 kB at most. Default false. - triggers (object): What happens when a document is added, through the API or a form. - email_on_insert (boolean): Email every new document to the project's billing email address (live projects only, not in test mode). Default false. - webhook_on_insert (boolean): Send the webhook document.created for every new document, to endpoints subscribed to it. Default false. - 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 (map of any 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. Example request body: ```json { "access": { "public_write": true }, "triggers": { "webhook_on_insert": true } } ``` Response 200: The collection as it is now. Body: Collection. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 collection_name_taken - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string, required): The id of the collection, or its name. Response 204: Deleted. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string, required): The id of the collection, or its name. - limit (in query, integer (1.., default 25)): Documents per page, default 25. A value above 100 is capped at 100. - cursor (in query, string): The next_cursor of the previous page. - before (in query, string (date-time)): Only documents added before this moment, in ISO 8601. - filter (in 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. Response 200: A page of documents. Body: DocumentList. Example: ```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 (status and type slug; see Errors below): - 400 invalid_parameter - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string, required): The id of the collection, or its name. Request body (DocumentInput): A new document. - data (map of any, required): The document: any JSON object, at most 64 kB. Example request body: ```json { "data": { "name": "Anna de Vries", "email": "anna@example.com", "message": "Can you come and look at a leaking tap on Friday?" } } ``` Response 201: The new document. Body: Document. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 plan_limit_reached - 422 validation_failed - 422 schema_mismatch - 422 document_too_large - 422 document_limit_reached - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /collections/{id}/documents/{docId} Get a document. One document from the collection. Scope: data:read Parameters: - id (in path, string, required): The id of the collection, or its name. - docId (in path, string (uuid), required): The id of the document. Response 200: The document. Body: Document. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string, required): The id of the collection, or its name. - docId (in path, string (uuid), required): The id of the document. Request body (DocumentUpdate): Change a document: send data to replace it, or merge to change some fields. Not both. - data (map of any): The document: any JSON object, at most 64 kB. - merge (map of any): 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. Example request body: ```json { "merge": { "status": "answered" } } ``` Response 200: The document as it is now. Body: Document. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 422 schema_mismatch - 422 document_too_large - 429 rate_limited - 500 internal_error - 503 service_unavailable ## DELETE /collections/{id}/documents/{docId} Delete a document. Deletes the document immediately and permanently. A second delete returns 404. Scope: data:write Parameters: - id (in path, string, required): The id of the collection, or its name. - docId (in path, string (uuid), required): The id of the document. Response 204: Deleted. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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. No API key needed. Response 200: The settings of the widget. Body: PublicChatConfig. Example: ```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 (status and type slug; see Errors below): - 401 invalid_chat_token - 401 chat_token_revoked - 403 origin_not_allowed - 429 rate_limited - 500 internal_error - 503 chat_unavailable ## 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. No API key needed. Request body (PublicConversationInput): Start a conversation from the chat widget. - turnstile_token (string (length 0..2048)): The bot-check token from the challenge frame. Required when the public token has turnstile on (the default); the widget does this for you. Example request body: ```json { "turnstile_token": "0.zXnA8v1rT3...from-the-bot-check" } ``` Response 201: The conversation with the greeting and its secret. Body: PublicConversationStart. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 invalid_chat_token - 401 chat_token_revoked - 402 test_free_replies_used - 403 origin_not_allowed - 403 turnstile_failed - 422 validation_failed - 429 rate_limited - 429 public_chat_limit_reached - 500 internal_error - 503 chat_unavailable ## 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. No API key needed. Parameters: - id (in path, string (uuid), required): The id of the conversation. - X-Conversation-Token (in header, string (length 0..300), required): The conversation_token from POST /public/conversations. Only the visitor who started the conversation has it. Response 200: The conversation with its messages. Body: PublicConversationDetail. Example: ```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 (status and type slug; see Errors below): - 401 invalid_chat_token - 401 chat_token_revoked - 401 invalid_conversation_token - 403 origin_not_allowed - 404 not_found - 429 rate_limited - 500 internal_error - 503 chat_unavailable ## 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. No API key needed. Parameters: - id (in path, string (uuid), required): The id of the conversation. - X-Conversation-Token (in header, string (length 0..300), required): The conversation_token from POST /public/conversations. Only the visitor who started the conversation has it. - Accept (in 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 (ChatMessageInput): One message from the customer. - text (string (length 1..2000), required): What the customer typed, at most 2000 characters. Send it as is; it is never treated as an instruction to the assistant. Example request body: ```json { "text": "My kitchen tap is leaking. Can someone come today?" } ``` Response 200: The visitor's message and the assistant's reply. Body: PublicChatTurn. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 invalid_chat_token - 401 chat_token_revoked - 401 invalid_conversation_token - 402 test_free_replies_used - 403 origin_not_allowed - 404 not_found - 409 conversation_closed - 409 conversation_busy - 409 conversation_turn_limit_reached - 422 validation_failed - 429 rate_limited - 429 public_chat_limit_reached - 500 internal_error - 502 upstream_error - 503 chat_unavailable ## 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. No API key needed. Parameters: - origin (in query, string, required): The origin of the page with the widget, like https://www.example.com. - lang (in query, "en" | "nl" | "de" | "fr" | "es"): The language of the check. Without it, the browser language. Response 200: The page with the check. ## 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. No API key needed. Parameters: - name (in path, string, required): The name of the collection, like contact. Request body (PublicDocumentInput): A document from a form on a website. - data (map of any, required): The fields of the form as a JSON object, like { "name": "Anna", "email": "anna@example.com", "message": "..." }. - turnstile_token (string (length 0..2048)): The bot-check token from the challenge frame (GET /public/challenge). Required; form.js does this for you. - honeypot (string (length 0..1000)): The value of a hidden field that people leave empty. When it is filled in, the answer is 201 but nothing is stored. Example request body: ```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" } ``` Response 201: Stored. Body: PublicDocumentCreated. Example: ```json { "id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918", "created_at": "2026-10-01T09:30:00.000Z", "test": false } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 invalid_chat_token - 401 chat_token_revoked - 402 plan_limit_reached - 403 origin_not_allowed - 403 collection_not_public - 403 turnstile_failed - 404 not_found - 409 plan_limit_reached - 422 validation_failed - 422 schema_mismatch - 422 document_too_large - 422 document_limit_reached - 429 rate_limited - 500 internal_error - 503 chat_unavailable - 503 service_unavailable ## 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. No API key needed. Parameters: - name (in path, string, required): The name of the collection. - limit (in query, integer (1..100, default 25)): How many, default 25, at most 100. Response 200: The newest documents. Body: PublicDocumentList. Example: ```json { "data": [ { "data": { "dish": "Soup of the day", "price_eur": 6.5 }, "created_at": "2026-10-01T09:00:00.000Z" } ] } ``` Errors (status and type slug; see Errors below): - 400 invalid_parameter - 401 invalid_chat_token - 401 chat_token_revoked - 403 origin_not_allowed - 403 collection_not_public - 404 not_found - 429 rate_limited - 500 internal_error - 503 chat_unavailable ## 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 (McpRequest): - jsonrpc ("2.0", required) - id (string or number) - method (string, required) - params (object) Example request body: ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/list" } ``` Response 200: The JSON-RPC response. Body: McpResponse. Example: ```json { "jsonrpc": "2.0", "id": 1, "result": { "tools": [] } } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Response 200: The agents. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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. - name (string (length 1..80, pattern ^[^\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]*$), required): A short name, like New contact request follow-up. - instructions (string (length 1..4000, pattern ^[^\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]*$), required): 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 (object by type: "document.created" | "call.completed" | "conversation.closed" | "schedule", required): 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, required): The actions this agent may take. Anything not listed is denied. - read_document (object): Read a document of the trigger's collection, or of the collections you list. Always without asking. - collections (array of string (length 1..64) (1..5 items)): The collections (names or ids) this action may use. Default: only the collection of a document.created trigger. - update_document (object): Change fields of a document (a merge) in the trigger's collection, or in the collections you list. Default mode auto. - mode ("auto" | "approve"): auto: the agent does it without asking. approve: the action waits until the owner approves it (in the portal or with approve_agent_action). - collections (array of string (length 1..64) (1..5 items)): The collections (names or ids) this action may use. Default: only the collection of a document.created trigger. - email_owner (object): Email the project owner (the billing email of the project). Nobody else. Default mode auto. - mode ("auto" | "approve"): auto: the agent does it without asking. approve: the action waits until the owner approves it (in the portal or with approve_agent_action). - email_contact (object): Only with the Mail module. Email the person of the event: the address in trigger.email_field of the document, or the customer of the chat. No other address. Default mode approve; always approve for a public form or a chat. At most one per address per day. Simulated in test mode. - mode ("auto" | "approve"): auto: the agent does it without asking. approve: the action waits until the owner approves it (in the portal or with approve_agent_action). - from (string (length 3..320), required): The sender, on a verified sending domain of this project, like "Acme Plumbing ". - reply_to (string (length 0..254)): Optional. Where replies go, like the owner's mailbox. - send_sms (object): Send an SMS 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). Default mode approve; always approve for a public form, a call or a chat. At most one per number per day. Simulated in test mode. - mode ("auto" | "approve"): auto: the agent does it without asking. approve: the action waits until the owner approves it (in the portal or with approve_agent_action). - start_call (object): Let the project's assistant call the phone number of the event (as for send_sms). Default mode approve; always approve for a public form, a call or a chat. At most one per number per day. Simulated in test mode. - mode ("auto" | "approve"): auto: the agent does it without asking. approve: the action waits until the owner approves it (in the portal or with approve_agent_action). - send_webhook (object): POST a signed agent.action event to one of the project's own webhook endpoints (list_webhooks). Default mode auto. - mode ("auto" | "approve"): auto: the agent does it without asking. approve: the action waits until the owner approves it (in the portal or with approve_agent_action). - endpoint_id (string (uuid), required) - limits (object): The limits of the agent. - runs_per_day (integer (1..200)): Runs in any 24 hours; above it a run is skipped and logged. Default 20. - actions_per_run (integer (1..10)): Actions the agent may take in one run, refused ones included. Default 3. - status ("active" | "paused"): active (default) or paused. Example request body: ```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 } } ``` Response 201: The new agent. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 insufficient_balance - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the agent. Response 200: The agent. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the agent. Request body: Changes to an agent. Fields you leave out stay as they are. - name (string (length 1..80, pattern ^[^\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]*$)): A short name, like New contact request follow-up. - instructions (string (length 1..4000, pattern ^[^\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]*$)): 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 (object by type: "document.created" | "call.completed" | "conversation.closed" | "schedule"): 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. - read_document (object): Read a document of the trigger's collection, or of the collections you list. Always without asking. - collections (array of string (length 1..64) (1..5 items)): The collections (names or ids) this action may use. Default: only the collection of a document.created trigger. - update_document (object): Change fields of a document (a merge) in the trigger's collection, or in the collections you list. Default mode auto. - mode ("auto" | "approve"): auto: the agent does it without asking. approve: the action waits until the owner approves it (in the portal or with approve_agent_action). - collections (array of string (length 1..64) (1..5 items)): The collections (names or ids) this action may use. Default: only the collection of a document.created trigger. - email_owner (object): Email the project owner (the billing email of the project). Nobody else. Default mode auto. - mode ("auto" | "approve"): auto: the agent does it without asking. approve: the action waits until the owner approves it (in the portal or with approve_agent_action). - email_contact (object): Only with the Mail module. Email the person of the event: the address in trigger.email_field of the document, or the customer of the chat. No other address. Default mode approve; always approve for a public form or a chat. At most one per address per day. Simulated in test mode. - mode ("auto" | "approve"): auto: the agent does it without asking. approve: the action waits until the owner approves it (in the portal or with approve_agent_action). - from (string (length 3..320), required): The sender, on a verified sending domain of this project, like "Acme Plumbing ". - reply_to (string (length 0..254)): Optional. Where replies go, like the owner's mailbox. - send_sms (object): Send an SMS 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). Default mode approve; always approve for a public form, a call or a chat. At most one per number per day. Simulated in test mode. - mode ("auto" | "approve"): auto: the agent does it without asking. approve: the action waits until the owner approves it (in the portal or with approve_agent_action). - start_call (object): Let the project's assistant call the phone number of the event (as for send_sms). Default mode approve; always approve for a public form, a call or a chat. At most one per number per day. Simulated in test mode. - mode ("auto" | "approve"): auto: the agent does it without asking. approve: the action waits until the owner approves it (in the portal or with approve_agent_action). - send_webhook (object): POST a signed agent.action event to one of the project's own webhook endpoints (list_webhooks). Default mode auto. - mode ("auto" | "approve"): auto: the agent does it without asking. approve: the action waits until the owner approves it (in the portal or with approve_agent_action). - endpoint_id (string (uuid), required) - limits (object): The limits of the agent. - runs_per_day (integer (1..200)): Runs in any 24 hours; above it a run is skipped and logged. Default 20. - actions_per_run (integer (1..10)): Actions the agent may take in one run, refused ones included. Default 3. - status ("active" | "paused"): paused stops new runs; active starts them again. Example request body: ```json { "actions": { "email_owner": { "mode": "auto" }, "start_call": { "mode": "approve" }, "send_sms": { "mode": "approve" } } } ``` Response 200: The agent as it is now. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 insufficient_balance - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the agent. Response 204: Deleted. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the agent. - limit (in query, integer (1..50, default 20)): Runs per page, 1 to 50. - before (in query, string (date-time)): Only runs created before this moment. Response 200: The runs. Example: ```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 (status and type slug; see Errors below): - 400 invalid_parameter - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the agent action. Request body: The owner's decision on an action that waits for approval. - decision ("approve" | "reject", required): approve runs the action now; reject drops it. Example request body: ```json { "decision": "approve" } ``` Response 200: The action after the decision. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /apps List the apps. The project's apps, newest first. Scope: sites:read Response 200: The apps. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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: - site_id (string (uuid), required): 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 (length 1..80)): A name for your own overview. Defaults to the site's name. - main_module (string (length 1..100)): The module with export default { async fetch(request, env, ctx) { ... } } (and optionally scheduled). Usually index.js. - modules (array of object (1..20 items)): 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. - name (string (length 1..100), required): The module's file name, like index.js, lib/util.js, data.json or template.html. - content (string, required): The module as plain text: bundled JavaScript (an ES module), JSON or text. No build step runs on Hypamoon. - type ("esm" | "json" | "text"): Leave it out: it follows from the extension (.js and .mjs are esm, .json is json, .txt, .html, .md, .css, .svg and .csv are text). - allowed_hosts (array of string (length 1..253) (0..20 items)): 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 (array of string (pattern ^[a-z][a-z0-9_]{1,39}$) (0..10 items)): 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 (array of object (0..3 items)): When the app's scheduled(event, env, ctx) handler runs. At most 3. - cron (string (length 9..100), required): Five fields in UTC, like "*/15 * * * *" or "0 7 * * 1-5". Minutes in steps of 5 (0, 5, ... 55): schedules run on a five-minute clock. - vars (map of string (length 0..1024)): 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). Example request body: ```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" } } ``` Response 200: A new version is live. Example: ```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" } ``` Response 201: The app is live. Example: ```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" } ``` Response 202: Four-eyes approval is on: the deploy waits for another owner or builder. The running code has not changed yet. Body: DeployApproval. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 409 plan_limit_reached - 409 conflict - 413 app_too_large - 422 validation_failed - 422 app_blocked - 429 rate_limited - 429 deploy_limit_reached - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. Response 200: The app. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. Response 204: Deleted. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. Request body: - version (integer (1..)): The version to go back to. Leave it out for the version before the current one. Example request body: ```json { "version": 1 } ``` Response 200: The app as it runs now. Example: ```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" } ``` Response 202: Four-eyes approval is on: the rollback waits for another owner or builder. Nothing has changed yet. Body: DeployApproval. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. - limit (in query, integer (1..100, default 50)): Lines, 1 to 100. - errors_only (in query, boolean (default false)): Only errors. Response 200: The log. Example: ```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 (status and type slug; see Errors below): - 400 invalid_parameter - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. Response 200: The users. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. Request body: - email (string (email, length 0..254), required): Their email address. - name (string (length 1..100)): Their name, shown to the app. - role (string (pattern ^[a-z][a-z0-9_-]{0,31}$)): One of the app's roles. Defaults to default_role. - send_email (boolean): Send them a sign-in link now (default true). Example request body: ```json { "email": "sam@example.com", "name": "Sam", "role": "member" } ``` Response 201: Invited. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. - limit (in query, integer (1..50, default 20)): Emails, 1 to 50. Response 200: The emails. Example: ```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 (status and type slug; see Errors below): - 400 invalid_parameter - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. Request body: - collection (string (length 1..64)): 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 (map of string (pattern ^[a-z][a-z0-9_-]{0,31}$)): 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 (pattern ^[a-z][a-z0-9_-]{0,31}$) 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. Example request body: ```json { "collection": "wp_users", "role_map": { "administrator": "admin", "opdrachtgever": "opdrachtgever", "kandidaat": "kandidaat" }, "send_email": false } ``` Response 200: What happened per person. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. - userId (in path, string (uuid), required): The id of the user (sub in x-hypamoon-user). Request body: - role (string (pattern ^[a-z][a-z0-9_-]{0,31}$), required): One of the app's roles. Example request body: ```json { "role": "admin" } ``` Response 200: The user with the new role. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. - userId (in path, string (uuid), required): The id of the user (sub in x-hypamoon-user). - delete_data (in query, boolean (default false)): Also delete their rows in per-user tables, their files and their passkeys. Without it, their data stays. Response 200: Removed, with delete_data=true: what was deleted. Example: ```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 } ``` Response 204: Removed; their data stays. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. - userId (in path, string (uuid), required): The id of the user (sub in x-hypamoon-user). Response 200: The export. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. - userId (in path, string (uuid), required): The id of the user (sub in x-hypamoon-user). Response 200: What was deleted. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. - table (in path, string (pattern ^[A-Za-z_][A-Za-z0-9_]{0,63}$), required): The name of a table in the app's database. Request body: - column (string (pattern ^[A-Za-z_][A-Za-z0-9_]{0,63}$)): 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 (pattern ^[a-z][a-z0-9_-]{0,31}$) 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. Example request body: ```json { "column": "user_id", "admin_role": "admin" } ``` Response 200: The app, with database.per_user_tables. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. - table (in path, string (pattern ^[A-Za-z_][A-Za-z0-9_]{0,63}$), required): The name of a table in the app's database. Response 200: The app. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. - limit (in query, integer (1..500, default 100)): Files, 1 to 500. Response 200: The files. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## DELETE /apps/{id}/files/{fileId} Delete a file from an app. Deletes the file permanently, including from storage. Scope: sites:write Parameters: - id (in path, string (uuid), required): The id of the app. - fileId (in path, string (uuid), required): The id of the file. Response 204: Deleted. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. Response 200: The migrations. Example: ```json { "applied": [], "skipped": [], "data": [ { "name": "0001_init.sql", "sha256": "9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f", "statements": 2, "applied_at": "2026-10-06T10:00:00.000Z" } ], "database_bytes": 24576 } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. Request body: - migrations (array of object (1..20 items), required): New migrations in order. Ones already applied (same name and content) are skipped. - name (string (pattern ^[A-Za-z0-9_.-]{1,96}\.sql$), required): A file name that sorts in order, like 0001_init.sql. - sql (string (length 1..524288), required): SQLite DDL and DML: CREATE TABLE, CREATE INDEX, ALTER TABLE, INSERT, UPDATE, DELETE. No ATTACH, PRAGMA, VACUUM, triggers or transactions. Example request body: ```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);" } ] } ``` Response 200: Applied (or already applied). Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 site_offline - 404 not_found - 409 conflict - 409 plan_limit_reached - 422 validation_failed - 422 migration_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. Response 200: The schema. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. - version (in query, integer (1..)): A kept version (get_app lists them). Leave it out for the running version. - module (in query, string (length 1..100)): Only this module, like index.js. Response 200: The code. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the app. Request body: - sql (string (length 1..4000), required): One SELECT (or WITH ... SELECT). Nothing that writes or changes the schema. - limit (integer (1..50)): Rows to return, 1 to 50 (default 20). Example request body: ```json { "sql": "SELECT id, title, servings FROM recipes ORDER BY created_at DESC", "limit": 20 } ``` Response 200: The rows. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 422 query_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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: - name (string (length 1..200), required): The file name, like receipt.pdf. - content_type ("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", required): 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 (integer (1..), required): 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. Example request body: ```json { "name": "receipt.pdf", "content_type": "application/pdf", "size": 48213 } ``` Response 201: The upload link. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 plan_limit_reached - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 403 site_offline - 409 plan_limit_reached - 409 conflict - 413 file_too_large - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - user_id (in query, string (uuid)): Only the files of this user (sub from x-hypamoon-user). - limit (in query, integer (1..500, default 100)): Files, 1 to 500. - all (in query, boolean (default false)): With the signed headers of a user with the admin role (the first of auth.roles): the files of every user. Response 200: The files. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the file. - public (in 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. Response 200: The download link. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the file. Response 204: Deleted. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Response 200: The users. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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: - email (string (email, length 0..254), required): Their email address. Ask the admin for it; never guess one. - name (string (length 1..100)): Their name, shown in the app and in the list. - role (string (pattern ^[a-z][a-z0-9_-]{0,31}$)): One of the app's roles. Defaults to default_role. Example request body: ```json { "email": "sam@example.com", "name": "Sam", "role": "member" } ``` Response 201: Invited, and the email is on its way. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the user (their sub in x-hypamoon-user). Response 200: The user. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the user (their sub in x-hypamoon-user). Request body: - role (string (pattern ^[a-z][a-z0-9_-]{0,31}$), required): One of the app's roles. Example request body: ```json { "role": "admin" } ``` Response 200: The user with the new role. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the user (their sub in x-hypamoon-user). Response 204: Removed; their data stays. Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - limit (in query, integer (1..100, default 25)): Emails per page, 1 to 100. - cursor (in query, string): The next_cursor of the previous page. - app_id (in query, string (uuid)): Only emails this app sent with env.HYPAMOON.sendEmail (an id from list_apps). - to (in query, string): Only emails with this address in to, cc or bcc. - status (in query, "queued" | "sent" | "delivered" | "bounced" | "complained" | "failed" | "simulated"): Only emails with this status. - since (in query, string (date-time)): Only emails sent at or after this moment, like 2026-10-01 or 2026-10-15T09:00:00Z. Response 200: The emails and the state of the module. Example: ```json { "data": [ { "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": null, "from": "Acme Plumbing ", "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 (status and type slug; see Errors below): - 400 invalid_parameter - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - Idempotency-Key (in header, string (length 0..255)): Optional. The same key returns the same email instead of sending it again. Request body: - from (string (length 3..320), required): The sender: an address on a verified sending domain of this project, optionally with a name, like "Acme Plumbing ". - to (string (length 3..254) or array of string (length 3..254) (1..50 items), required): 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 (array of string (length 3..254) (0..50 items)): Optional. Copy recipients; they see the to and cc addresses. The same rules as to. - bcc (array of string (length 3..254) (0..50 items)): Optional. Hidden recipients; nobody sees them. The same rules as to. - subject (string (length 1..200)): The subject line. Required without template_id; with template_id it comes from the template. - text (string (length 0..100000)): The plain text body. Send text, html or both. - html (string (length 0..200000)): The HTML body. No scripts, no forms; links must show where they go. - reply_to (string (length 0..254)): Optional. Where replies go, like the user's own mailbox. - attachments (array of 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. - filename (string (length 3..120)): The file name with its extension, like invoice.pdf. Required with content and path; with media_id it defaults to the name in Media. Allowed: pdf, png, jpg, jpeg, gif, webp, ics, txt, csv. - content (string): The file as base64. Counts toward the request size (keep the whole request under about 4 MB); for a larger file use media_id or path. - media_id (string (uuid)): A file from this project's Media library (its id from list_media). - path (string (length 0..2048)): The https address of a file on one of this project's sites, like https://acme.hypamoon.app/menu.pdf. Other hosts are refused. - content_type (string (length 0..100)): Optional and only informative: the type is always taken from the file itself. - 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 (pattern ^[\x21-\x7E]{1,255}$)): 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 (map of string (length 0..2000) or number or boolean or null): With template_id: the values for its variables, like { "name": "Sam" }. - send_at (string (length 0..40)): 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. Example request body: ```json { "from": "Acme Plumbing ", "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..." } ] } ``` Response 200: The same Idempotency-Key as before: the original email, and nothing is sent again. Example: ```json { "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": null, "from": "Acme Plumbing ", "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 } ``` Response 201: Accepted for delivery (or simulated with a test key). Example: ```json { "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": null, "from": "Acme Plumbing ", "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 } ``` Response 202: With send_at: the scheduled email. get_scheduled_email shows it; once sent, email_id is the email. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 400 idempotency_key_invalid - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 insufficient_balance - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 mail_not_enabled - 403 email_paused - 403 recipient_not_allowed - 404 not_found - 409 idempotency_key_reused - 409 idempotency_request_in_progress - 409 email_domain_not_verified - 413 attachment_too_large - 422 validation_failed - 422 email_address_invalid - 422 company_details_missing - 422 email_blocked - 422 attachment_not_allowed - 429 rate_limited - 429 email_limit_reached - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - Idempotency-Key (in header, string (length 0..255)): Optional. The same key returns the same email instead of sending it again. Request body: - from (string (length 3..320)): 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 (string (length 3..254) or array of string (length 3..254) (1..50 items), required): 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 (array of string (length 3..254) (0..50 items)): Optional. Copy recipients; they see the to and cc addresses. The same rules as to. - bcc (array of string (length 3..254) (0..50 items)): Optional. Hidden recipients; nobody sees them. The same rules as to. - subject (string (length 1..200), required): The subject line. - text (string (length 0..100000)): The plain text body. Send text, html or both. - html (string (length 0..200000)): The HTML body. No scripts, no forms; links must show where they go. - reply_to (string (length 0..254)): Optional. Where replies go, like the user's own mailbox. - attachments (array of 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. - filename (string (length 3..120)): The file name with its extension, like invoice.pdf. Required with content and path; with media_id it defaults to the name in Media. Allowed: pdf, png, jpg, jpeg, gif, webp, ics, txt, csv. - content (string): The file as base64. Counts toward the request size (keep the whole request under about 4 MB); for a larger file use media_id or path. - media_id (string (uuid)): A file from this project's Media library (its id from list_media). - path (string (length 0..2048)): The https address of a file on one of this project's sites, like https://acme.hypamoon.app/menu.pdf. Other hosts are refused. - content_type (string (length 0..100)): Optional and only informative: the type is always taken from the file itself. - 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 (pattern ^[\x21-\x7E]{1,255}$)): Optional. The same key returns the same email instead of sending a second one (also as the Idempotency-Key header). Example request body: ```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" } ``` Response 200: The same Idempotency-Key as before: the original email, and nothing is sent again. Example: ```json { "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": null, "from": "Acme Plumbing ", "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 } ``` Response 201: Accepted for delivery (or simulated with a test key). Example: ```json { "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": null, "from": "Acme Plumbing ", "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 (status and type slug; see Errors below): - 400 invalid_json - 400 idempotency_key_invalid - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 insufficient_balance - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 403 mail_not_enabled - 403 email_paused - 403 recipient_not_allowed - 409 idempotency_key_reused - 409 idempotency_request_in_progress - 409 email_domain_not_verified - 413 attachment_too_large - 422 validation_failed - 422 email_address_invalid - 422 company_details_missing - 422 email_blocked - 422 attachment_not_allowed - 429 rate_limited - 429 email_limit_reached - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - limit (in query, integer (1..100, default 25)): Emails per page, 1 to 100. - cursor (in query, string): The next_cursor of the previous page. - to (in query, string): Only emails with this address in to, cc or bcc. - status (in query, "queued" | "sent" | "delivered" | "bounced" | "complained" | "failed" | "simulated"): Only emails with this status. - since (in query, string (date-time)): Only emails sent at or after this moment, like 2026-10-01 or 2026-10-15T09:00:00Z. Response 200: The app's emails. Example: ```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 (status and type slug; see Errors below): - 400 invalid_parameter - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - Idempotency-Key (in header, string (length 0..255)): Optional. The same key returns the same emails instead of sending them again. Request body: - emails (array of object (1..100 items), required): 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. - from (string (length 3..320), required): The sender: an address on a verified sending domain of this project, optionally with a name, like "Acme Plumbing ". - to (string (length 3..254) or array of string (length 3..254) (1..50 items), required): 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 (array of string (length 3..254) (0..50 items)): Optional. Copy recipients; they see the to and cc addresses. The same rules as to. - bcc (array of string (length 3..254) (0..50 items)): Optional. Hidden recipients; nobody sees them. The same rules as to. - subject (string (length 1..200), required): The subject line. - text (string (length 0..100000)): The plain text body. Send text, html or both. - html (string (length 0..200000)): The HTML body. No scripts, no forms; links must show where they go. - reply_to (string (length 0..254)): Optional. Where replies go, like the user's own mailbox. - 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 (pattern ^[\x21-\x7E]{1,255}$)): Optional. The same key returns the same emails instead of sending them again (also as the Idempotency-Key header). Example request body: ```json { "emails": [ { "from": "Acme Plumbing ", "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 ", "to": "kim@example.org", "subject": "Your invitation", "text": "Hi Kim, you are invited to our open day on 1 November.", "category": "transactional" } ] } ``` Response 200: The same Idempotency-Key as before: the original emails, and nothing is sent again. Example: ```json { "data": [ { "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": null, "from": "Acme Plumbing ", "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 } ] } ``` Response 201: Accepted for delivery (or simulated with a test key), in the order of the request. Example: ```json { "data": [ { "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": null, "from": "Acme Plumbing ", "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 (status and type slug; see Errors below): - 400 invalid_json - 400 idempotency_key_invalid - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 402 insufficient_balance - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 mail_not_enabled - 403 email_paused - 403 recipient_not_allowed - 409 idempotency_key_reused - 409 idempotency_request_in_progress - 409 email_domain_not_verified - 422 validation_failed - 422 email_address_invalid - 422 company_details_missing - 422 email_blocked - 422 attachment_not_allowed - 429 rate_limited - 429 email_limit_reached - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - Idempotency-Key (in header, string (length 0..255)): Optional. The same key returns the same emails instead of sending them again. Request body: - emails (array of object (1..100 items), required) - from (string (length 3..320)): Optional, as in sendEmail. - to (string (length 3..254) or array of string (length 3..254) (1..50 items), required): 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 (array of string (length 3..254) (0..50 items)): Optional. Copy recipients; they see the to and cc addresses. The same rules as to. - bcc (array of string (length 3..254) (0..50 items)): Optional. Hidden recipients; nobody sees them. The same rules as to. - subject (string (length 1..200), required): The subject line. - text (string (length 0..100000)): The plain text body. Send text, html or both. - html (string (length 0..200000)): The HTML body. No scripts, no forms; links must show where they go. - reply_to (string (length 0..254)): Optional. Where replies go, like the user's own mailbox. - 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 (length 0..255)) Example request body: ```json { "emails": [ { "to": "sam@example.org", "subject": "Your invitation", "text": "Hi Sam, see you on 1 November.", "category": "transactional" } ] } ``` Response 200: The same Idempotency-Key as before: the original emails. Example: ```json { "data": [ { "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": null, "from": "Acme Plumbing ", "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 } ] } ``` Response 201: Accepted for delivery (or simulated with a test key). Example: ```json { "data": [ { "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": null, "from": "Acme Plumbing ", "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 (status and type slug; see Errors below): - 400 invalid_json - 400 idempotency_key_invalid - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 403 mail_not_enabled - 403 email_paused - 403 recipient_not_allowed - 409 idempotency_key_reused - 409 email_domain_not_verified - 422 validation_failed - 422 email_address_invalid - 422 company_details_missing - 422 email_blocked - 422 attachment_not_allowed - 429 rate_limited - 429 email_limit_reached - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the email. Response 200: The email. Example: ```json { "id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "app_id": null, "from": "Acme Plumbing ", "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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the email. Response 200: The email. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /email-domains List sending domains. The project's sending domains with their status and DNS records. Scope: sites:read Response 200: The sending domains. Example: ```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" } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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: - domain (string (length 3..253), required): A domain of this project (connected with connect_domain or bought with register_domain), like example.com. Example request body: ```json { "domain": "acmeplumbing.com" } ``` Response 200: Already a sending domain of this project. Example: ```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" } ``` Response 201: The sending domain, with its records. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 mail_not_enabled - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the sending domain. Response 200: The sending domain. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the sending domain. Response 200: Removed. Example: ```json { "id": "8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a", "deleted": true } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the sending domain. Response 200: The sending domain after the check. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## 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: - email (string (length 3..254), required): The address the owner confirmed they want to email. Example request body: ```json { "email": "sam@example.org" } ``` Response 201: Confirmed. Example: ```json { "email": "sam@example.org", "allowed": true } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 422 validation_failed - 429 rate_limited - 429 email_limit_reached - 500 internal_error - 503 service_unavailable ## 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: - email (string (length 0..320)): One address to check. - emails (array of string (length 0..320) (1..50 items)): Or a list of 1 to 50 addresses. Example request body: ```json { "emails": [ "sam@example.org", "kim@gmial.com" ] } ``` Response 200: One result per address, in the order sent. Example: ```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 (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Response 200: Delivery per sending domain. Example: ```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 } } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - id (in path, string (uuid), required): The id of the sending domain. Response 200: The logo settings of the domain. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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. Scope: sites:write Parameters: - id (in path, string (uuid), required): The id of the sending domain. Request body: - logo_url (string (length 0..500), required): 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. - certificate_url (string (length 0..500)): Optional: the https address of your mark certificate (VMC or CMC, a .pem file). Gmail needs one; Apple Mail needs a VMC. Example request body: ```json { "logo_url": "https://acmeplumbing.com/logo.svg" } ``` Response 200: The record is set (or record_to_add says what to add). Example: ```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 } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 bimi_not_ready - 422 validation_failed - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## POST /email-dedicated-ip Request a dedicated sending IP. 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. Scope: messages:write Request body: - note (string (length 0..1000)): Optional: anything we should know, like expected volume. Example request body: ```json { "note": "We send about 150,000 order emails a month." } ``` Response 200: Already requested: the open request. Example: ```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 } ``` Response 201: Requested. Example: ```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 } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 dedicated_ip_not_available - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /email-templates List email templates. The project's email templates, newest first, with their variables. Scope: messages:read Response 200: The templates. Example: ```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" } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /email-templates Create an email template. 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. Scope: messages:write Request body: - name (string (length 1..100), required): A name for the template, unique in the project, like "Welcome" or "Order shipped". - subject (string (length 1..300), required): The subject, with variables like {{name}}. - html (string (length 0..200000)): The HTML body with variables, like <p>Hi {{name|there}},</p>. Values are escaped. No scripts or forms. - text (string (length 0..100000)): The plain text body with variables. Without it, the text comes from the html. - style ("site" | "none"): 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. - site_id (string (uuid)): Optional. The site whose style to use; without it the project's first site. Example request body: ```json { "name": "Welcome", "subject": "Welcome, {{name|there}}", "html": "<p>Hi {{name|there}},</p><p>Thanks for signing up.</p>", "style": "site" } ``` Response 201: The template. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /email-templates/{id} Get an email template. Scope: messages:read Parameters: - id (in path, string (uuid), required): The id of the template. Response 200: The template. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## PATCH /email-templates/{id} Update an email template. Changes only the fields you send. Emails that are scheduled with this template use the new version when they go out. Scope: messages:write Parameters: - id (in path, string (uuid), required): The id of the template. Request body: - name (string (length 1..100)) - subject (string (length 1..300)) - html (string (length 0..200000) or null) - text (string (length 0..100000) or null) - style ("site" | "none") - site_id (string (uuid) or null) Example request body: ```json { "subject": "Welcome to Acme, {{name|there}}" } ``` Response 200: The updated template. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## DELETE /email-templates/{id} Delete an email template. Not possible while an automation, a sequence or a list uses it (conflict); change those first. Scope: messages:write Parameters: - id (in path, string (uuid), required): The id of the template. Response 200: Deleted. Example: ```json { "id": "3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "deleted": true } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /email-templates/{id}/preview Preview an email template. The template filled with data, in the style of the site, without sending anything. missing_variables lists what has no value. Scope: messages:read Parameters: - id (in path, string (uuid), required): The id of the template. Request body: - data (map of string (length 0..2000) or number or boolean or null): The values for the variables, like { "name": "Sam", "order_number": "1042" }. Text, numbers or true/false. Example request body: ```json { "data": { "name": "Sam" } } ``` Response 200: The subject, html and text as they would be sent. Example: ```json { "subject": "Welcome, Sam", "html": "<!doctype html>...", "text": "Hi Sam,\n\nThanks for signing up.", "missing_variables": [] } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /email-automations List email automations. Scope: messages:read Response 200: The automations. Example: ```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" } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /email-automations Create an email automation. 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. Scope: messages:write Request body: - event ("form.submitted" | "booking.created" | "order.paid" | "order.payment_failed" | "app_user.created" | "list.subscribed", required): What happens. - template_id (string (uuid)): The template to send. Give template_id or sequence_id. - sequence_id (string (uuid)): Or: start this sequence for the person. - to (string (length 3..254, default "contact")): contact (default): the person of the event (who sent the form, the buyer, the new app user). Or one fixed address that appears in the project, like the owner's, for a notice. - from (string (length 3..320), required): The sender: an address on a verified sending domain of this project, optionally with a name, like "Acme <hello@acme.com>". - reply_to (string (length 0..254)): Optional. Where replies go, like the owner's own mailbox. - category ("transactional" | "other"): transactional (default): it answers what the person did. other: gets an unsubscribe link. - delay_minutes (integer (0..43200, default 0)): Wait this long after the event, like 60 for one hour. 0 sends right away. - filter (object) - collection (string (length 1..100)): form.submitted and booking.created: only submissions to this collection (the form). - app_id (string (uuid)): app_user.created: only users of this app. - list_id (string (uuid)): list.subscribed: only this list. - enabled (boolean (default true)) Example request body: ```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" } } ``` Response 201: The automation. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 recipient_not_allowed - 404 not_found - 409 email_domain_not_verified - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## PATCH /email-automations/{id} Update an email automation. Changes only the fields you send, like enabled: false to pause it. Scope: messages:write Parameters: - id (in path, string (uuid), required): The id of the automation. Request body: - template_id (string (uuid)) - to (string (length 3..254)) - from (string (length 3..320)): The sender: an address on a verified sending domain of this project, optionally with a name, like "Acme <hello@acme.com>". - reply_to (string (length 0..254) or null) - category ("transactional" | "other") - delay_minutes (integer (0..43200)) - filter (object) - collection (string (length 1..100)): form.submitted and booking.created: only submissions to this collection (the form). - app_id (string (uuid)): app_user.created: only users of this app. - list_id (string (uuid)): list.subscribed: only this list. - enabled (boolean) Example request body: ```json { "enabled": false } ``` Response 200: The updated automation. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 recipient_not_allowed - 404 not_found - 409 email_domain_not_verified - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## DELETE /email-automations/{id} Delete an email automation. Emails it already scheduled still go; cancel those with POST /scheduled-emails/{id}/cancel. Scope: messages:write Parameters: - id (in path, string (uuid), required): The id of the automation. Response 200: Deleted. Example: ```json { "id": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e", "deleted": true } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /scheduled-emails List scheduled emails. Emails that go later or went: send_at, automations, sequence steps, list sends and confirmation emails, newest first, with their status. Scope: messages:read Parameters: - limit (in query, integer (1..100, default 25)): Per page, 1 to 100. - cursor (in query, string): The next_cursor of the previous page. - status (in query, "scheduled" | "sending" | "sent" | "canceled" | "skipped" | "failed"): Only this status. - source (in query, "api" | "automation" | "sequence" | "list" | "confirmation"): Only this source. Response 200: The scheduled emails. Example: ```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 } ``` Errors (status and type slug; see Errors below): - 400 invalid_parameter - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /scheduled-emails/{id} Get a scheduled email. Scope: messages:read Parameters: - id (in path, string (uuid), required): The id of the scheduled email. Response 200: The scheduled email. Example: ```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 (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /scheduled-emails/{id}/cancel Cancel a scheduled email. An email that is still scheduled will not be sent. Once it is sent (or sending) it cannot be canceled (conflict). Scope: messages:write Parameters: - id (in path, string (uuid), required): The id of the scheduled email. Response 200: Canceled. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /email-sequences List email sequences. Scope: messages:read Response 200: The sequences, with how many people are in each now. Example: ```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" } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /email-sequences Create an email sequence. 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. Scope: messages:write Request body: - name (string (length 1..100), required): A name, like "Onboarding". - steps (array of object (1..20 items), required): 1 to 20 emails, each a template with a wait before it. - template_id (string (uuid), required) - wait_minutes (integer (0..43200), required): Minutes to wait before this step: after joining for the first step, after the previous email for the next. - stop_on (array of "form.submitted" | "booking.created" | "order.paid" | "order.payment_failed" | "app_user.created" | "list.subscribed" (0..6 items, default [])): Stop for a person when one of these happens for their address, like order.paid. Unsubscribing always stops. - from (string (length 3..320), required): The sender: an address on a verified sending domain of this project, optionally with a name, like "Acme <hello@acme.com>". - reply_to (string (length 0..254)): Optional. Where replies go, like the owner's own mailbox. - category ("transactional" | "other"): other (default): every email has an unsubscribe link. transactional only for steps that follow up on something the person did. Example request body: ```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>" } ``` Response 201: The sequence. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 409 email_domain_not_verified - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /email-sequences/{id} Get an email sequence. The sequence with its last 100 enrollments. Scope: messages:read Parameters: - id (in path, string (uuid), required): The id of the sequence. Response 200: The sequence. Example: ```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" } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## DELETE /email-sequences/{id} Delete an email sequence. Everyone in it stops; steps that were scheduled are not sent. Scope: messages:write Parameters: - id (in path, string (uuid), required): The id of the sequence. Response 200: Deleted. Example: ```json { "id": "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a", "deleted": true } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /email-sequences/{id}/enrollments Add someone to an email sequence. 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). Scope: messages:write Parameters: - id (in path, string (uuid), required): The id of the sequence. Request body: - email (string (length 3..254), required): The person's address. It must appear in the project, like any recipient. - data (map of string (length 0..2000) or number or boolean or null): The values for the variables, like { "name": "Sam", "order_number": "1042" }. Text, numbers or true/false. - in_reply_to (string (uuid)): The form submission or chat conversation this person came from, when that is the only place the address appears. Example request body: ```json { "email": "sam@example.org", "data": { "name": "Sam" } } ``` Response 200: Already in the sequence. Example: ```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" } ``` Response 201: Added; the first step goes after its wait. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 recipient_not_allowed - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /email-sequences/{id}/stop Stop an email sequence for someone. For example when the person replied. Steps that were scheduled for them are not sent. Scope: messages:write Parameters: - id (in path, string (uuid), required): The id of the sequence. Request body: - email (string (length 3..254), required) Example request body: ```json { "email": "sam@example.org" } ``` Response 200: Stopped. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /clients List your client projects. The client projects of your agency: pending access requests and active relations, newest first. With all=true also declined and ended ones. Scope: clients:read Parameters: - all (in query, boolean (default false)): Also include declined and ended relations. Response 200: The client relations. Example: ```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" } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /clients Set up a project for a client. 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. Scope: clients:write Request body: - name (string (length 1..100), required) - payer ("client" | "agency") Example request body: ```json { "name": "Bakery Jansen", "payer": "agency" } ``` Response 201: The new client project and its relation. Example: ```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" } } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /clients/access-requests Ask a client for access to their project. 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). Scope: clients:write Request body: - project_id (string (uuid), required) - message (string (length 0..500)) - payer ("client" | "agency") Example request body: ```json { "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "message": "As discussed on the phone.", "payer": "agency" } ``` Response 200: The open request or active relation that already existed. Example: ```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" } ``` Response 201: The new request. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /clients/{id}/owner-invite Invite the client to own the project. 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. Scope: clients:write Parameters: - id (in path, string (uuid), required): The id of the client project. Request body: - email (string (email), required) Example request body: ```json { "email": "owner@example.org" } ``` Response 200: The relation with the open invitation. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## DELETE /clients/{id} Stop working on a client project. 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. Scope: clients:write Parameters: - id (in path, string (uuid), required): The id of the client project. Response 200: The ended relation. Example: ```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": [] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /clients/{id}/transfer Hand over a client project. 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. Scope: clients:write Parameters: - id (in path, string (uuid), required): The id of the client project. Request body: - to_email (string (email)) - agency_keeps_access (boolean) - to_agency_project_id (string (uuid)) - message (string (length 0..500)) Example request body: ```json { "to_agency_project_id": "5c4b3a29-1807-4f6e-8d5c-4b3a29180706", "message": "As agreed, you take over the build." } ``` Response 202: The handover waits for the receiving side. Example: ```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": [] } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## DELETE /clients/{id}/transfer Cancel a handover. 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. Scope: clients:write Parameters: - id (in path, string (uuid), required): The id of the client project. Response 200: The relation, without the open handover. Example: ```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 } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /clients/{id}/restore Restore an ended client project. 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. Scope: clients:write Parameters: - id (in path, string (uuid), required): The id of the client project. Response 200: The new relation. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /clients/{id}/usage Get the usage of a client project. Costs and usage per calendar month (UTC), oldest first: cost_eur with cost_by_source and cost_by_payer (client: the project's own credit, agency: paid by your agency), top_ups_eur and the app meters, plus the current payer and budget_eur. For invoicing your client with your own markup, or your own client portal. Scope: clients:read Parameters: - id (in path, string (uuid), required): The id of the client project. - months (in query, integer (1..12, default 3)): How many months back, the current month included (at most 12). Response 200: Usage per month. Example: ```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 } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## PUT /clients/{id}/payer Change who pays for a client project. 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. Scope: clients:write Parameters: - id (in path, string (uuid), required): The id of the client project. Request body: - payer ("client" | "agency", required) Example request body: ```json { "payer": "agency" } ``` Response 200: The relation, with payer_change while it waits for the client. Example: ```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 } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## PUT /clients/{id}/budget Set the monthly budget for a client project. 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. Scope: clients:write Parameters: - id (in path, string (uuid), required): The id of the client project. Request body: - monthly_budget_eur (number (0..10000) or null, required) Example request body: ```json { "monthly_budget_eur": 50 } ``` Response 200: The relation with the budget. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /clients/{id}/invoice-lines Export invoice lines for a client project. The usage of a client project as invoice lines per month, payer and source, from the same numbers as GET /clients/{id}/usage, so you invoice your client in your own accounting with your own markup. format=csv returns text/csv. Hypamoon does not send the invoice. Scope: clients:read Parameters: - id (in path, string (uuid), required): The id of the client project. - months (in query, integer (1..12, default 1)): How many months back, the current month included. - payer (in query, "agency" | "client" | "all"): Only the lines your agency paid, only those the client paid, or all. - format (in query, "json" | "csv"): json, or csv for a spreadsheet or import. Response 200: The invoice lines. Example: ```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" } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## POST /clients/{id}/payment-links Create a payment link for a client. 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. Scope: clients:write Parameters: - id (in path, string (uuid), required): The id of the client project. Request body: - amount_cents (integer (1..), required) - currency (string (length 3..3)) - description (string (length 1..200), required) - redirect_url (string (uri), required) - idempotency_key (string (length 8..64)) Example request body: ```json { "amount_cents": 12100, "currency": "EUR", "description": "Website maintenance October", "redirect_url": "https://portal.studionorth.example/paid", "idempotency_key": "inv-2026-10-bakery" } ``` Response 200: The payment link made earlier with this idempotency_key. Example: ```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" } ``` Response 201: The payment link. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## POST /clients/{id}/subscriptions Create a subscription for a client. 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. Scope: clients:write Parameters: - id (in path, string (uuid), required): The id of the client project. Request body: - amount_cents (integer (1..), required) - currency (string (length 3..3)) - description (string (length 1..200), required) - redirect_url (string (uri), required) - idempotency_key (string (length 8..64)) - interval ("month" | "year", required) - email (string (email), required) - name (string (length 0..120)) Example request body: ```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" } ``` Response 200: The subscription made earlier with this idempotency_key. Example: ```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 } ``` Response 201: The subscription, open until the first payment. Example: ```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 } ``` Errors (status and type slug; see Errors below): - 400 invalid_json - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 422 validation_failed - 429 rate_limited - 500 internal_error - 502 upstream_error - 503 service_unavailable ## GET /clients/{id}/payments List payment links and subscriptions for a client. The payment links and subscriptions your agency made for this client project, newest first. Without the payer's email or name. Scope: clients:read Parameters: - id (in path, string (uuid), required): The id of the client project. - limit (in query, integer (1..100, default 50)): How many. Response 200: The payments. Example: ```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" } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## DELETE /clients/{id}/subscriptions/{subscriptionId} Cancel a client subscription. Stops a subscription at your payment provider right away: no more charges. What was paid stays paid; nothing is refunded. Scope: clients:write Parameters: - id (in path, string (uuid), required): The id of the client project. - subscriptionId (in path, string (uuid), required): The id of the subscription. Response 200: The canceled subscription. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 409 conflict - 429 rate_limited - 500 internal_error - 503 service_unavailable ## DELETE /clients/{id}/payment-links/{linkId} Withdraw an open payment link. 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. Scope: clients:write Parameters: - id (in path, string (uuid), required): The id of the client project. - linkId (in path, string (uuid), required): The id of the payment link. Response 200: The withdrawn payment link. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /clients/{id}/activity Read the activity log of a client project. 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. Scope: clients:read Parameters: - id (in path, string (uuid), required): The id of the client project. - limit (in query, integer (1..200, default 50)): How many entries. - before (in query, string): next_before from the previous page. Response 200: The log entries. Example: ```json { "object": "list", "project_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "data": [], "next_before": null } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 404 not_found - 422 validation_failed - 429 rate_limited - 500 internal_error - 503 service_unavailable ## GET /agencies List the agencies on this project. 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. Scope: project:read Response 200: The agency relations of this project. Example: ```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" } ] } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## DELETE /agencies/{id} Revoke an agency's access. For the owner of a client project: ends an agency's access right away, or declines its open request. What the agency built stays. Scope: project:write Parameters: - id (in path, string (uuid), required): The id of the agency relation. Response 200: The ended relation. Example: ```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" } ``` Errors (status and type slug; see Errors below): - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 403 forbidden - 404 not_found - 429 rate_limited - 500 internal_error - 503 service_unavailable ## 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 Parameters: - limit (in query, integer (1..100, default 20)): Errors per page, 1 to 100. - before (in query, string (length 0..20)): next_before from the previous page, for older errors. - request_id (in query, string (length 0..64)): Only the errors of this request (the request id from an error message or the X-Request-Id header). - tool (in query, string (length 0..64)): Only the errors of this MCP tool, like deploy_site. Response 200: The errors, newest first. Body: RecentErrorList. Example: ```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 (status and type slug; see Errors below): - 400 invalid_parameter - 401 unauthorized - 401 missing_api_key - 401 invalid_api_key - 401 api_key_revoked - 401 project_deleted - 403 project_not_enabled - 403 key_mode_mismatch - 403 missing_scope - 403 verification_required - 429 rate_limited - 500 internal_error - 503 service_unavailable ## Schemas ### McpRequest - jsonrpc ("2.0", required) - id (string or number) - method (string, required) - params (object) ### McpResponse - jsonrpc ("2.0", required) - id (string or number or null, required) - result (object) ### Project The project behind the API key. - id (string (uuid), required): The id of the project. - name (string, required): The name of the project. The assistant uses it in its greeting. - edition (string, required): Always api for a project of the Hypamoon API. - created_at (string (date-time), required): 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 ("live" | "test", required): live or test: the mode of the key, which is always the mode of its project. - live_project_id (string (uuid) or null): 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. - phone ("live" | "test", required): Numbers, the call plan and the AI receptionist on inbound calls. - outbound ("live" | "test", required): Outbound calls (start_call). - sms ("live" | "test", required): SMS messages. - email ("live" | "test", required): Email to other people. With a live key, email to the project's own team is real even when this is test. - payments ("live" | "test", required): Checkout, orders and refunds: real money or test payments. - domains ("live" | "test", required): Buying, renewing and transferring a domain. Connecting a domain is always real. - verification (object): The verification (KYC) of the project. In test mode: the verification of the live project. - account_type ("personal" | "business", required): personal or business. You are verified for what you use: a personal account needs no business register number, a business does. A national number needs a business. - levels (array of object, required): Always four levels, in order: test, company (personal for a personal account), number_documents, full. - level ("test" | "company" | "personal" | "number_documents" | "full", required): test, then company (a business) or personal (a personal account), then number_documents and full. Each level unlocks more; see unlocks. - status ("not_started" | "submitted" | "in_review" | "approved" | "rejected", required): Where this level stands. company, personal and number_documents follow from the data you set; full needs our review. - reason (string or null, required): Only when rejected: what is wrong. Fix it and submit again. - missing (array of string, required): What is still needed, as codes: company_details.<field> (set with PUT /project/company), payment_mandate, spending_cap, document.proof_of_address, document.identity, document.company_registration (upload in the portal under Verification), level.company or level.personal (finish that level first), level.number_documents, director_identity, ubo_confirmation and review. Empty when approved. - unlocks (array of string, required): What this level unlocks, as codes: test_keys, test_numbers, simulated_calls, live_keys, live_chat, national_numbers, inbound_calls, geographic_numbers, sms, outbound_calls, spending_cap_above_500_eur, more_concurrent_calls. - access (object or null, required): 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. ### Advice - type ("webshop" | "bookings" | "restaurant" | "course_membership" | "wordpress_portal", required) - title (string, required) - summary (string, required) - country (string or null, required) - region ("eu" | "uk" | "us" | "other", required): eu (detailed), uk and us (basics), other (a general fallback). - audience ("consumers" | "businesses" | "both", required) - how_to_use (string, required): 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 (array of object, required) - id (string, required) - question (string, required) - why (string, required) - default (string, required): What to use when the user does not know or skips the question. - blocking (boolean, required): Only these block a working result; at most two or three per type. - build_plan (array of object, required) - id (string, required) - title (string, required) - how (string, required) - tools (array of string, required) - optional (boolean) - go_live_checklist (array of object, required) - id (string, required) - level ("usually_required" | "recommended", required) - description (string, required) - why (string, required) - how_on_hypamoon (string, required) - checked_by_check_readiness (boolean, required) - pitfalls (array of object, required) - id (string, required) - pitfall (string, required) - instead (string, required) - structured_data (object, required): Structured data for search engines, ready to fill in and add with deploy_site. - schema_type (string, required): The schema.org type, like LocalBusiness, Restaurant, Product or Course. - why (string, required) - snippet (string, required): A <script type="application/ld+json"> block with YOUR ... placeholders; fill them with the user's real details only, never invented ones. - required (array of string, required): The fields check_readiness expects in the block. - note (string, required): Guidance, not legal or tax advice. - other_types (object, required) - available (array of "webshop" | "bookings" | "restaurant" | "course_membership" | "wordpress_portal", required) - planned (array of string, required) - known_problems (array of object, required): 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. - id ("sandbox_blocks_upload" | "image_too_large" | "version_conflict" | "phone_verification_required" | "site_not_visible" | "is_hypamoon_down", required) - symptom (string, required): How you recognise it. - what_to_do (string, required): What you, the agent, do. - tell_user (string, required): What you can say to the user, in plain words. - summary_markdown (string, required) ### Status Whether Hypamoon is working, per component. - status ("operational" | "degraded" | "down" | "unknown", required): The overall status: the worst of the components (unknown does not count). - source ("status_page" | "self_check", required): status_page: from status.hypamoon.com. self_check: that page did not answer, so only a minimal check of our own. - components (array of object, required): Per component: sites, api, portal, phone, ai, mail, payments. - id (string, required) - name (string, required) - status ("operational" | "degraded" | "down" | "unknown", required) - incidents (array of object, required): Reported problems that are not resolved yet. - component (string, required) - severity (string, required) - started_at (string, required) - latest_update (string or null, required) - status_page (string, required): The public status page. - checked_at (string, required): When the status was measured. - user_message (string, required): One calm sentence for the user, like "Hypamoon is working normally. The problem is likely in your chat app." - next_step (string, required): What the chat or agent should do next. ### MoveToLiveInput Move sites from the test project to the live project. - sites (array of string (length 1..100) (0..100 items)): Optional. The sites to move, by id or slug. Leave it out to move every site of the test project. - collections (array of string (length 1..100) (0..100 items)): 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. ### MoveToLiveUndoInput Undo a move to live within 24 hours. - move_id (string (uuid)): Optional. The id from move_to_live. Leave it out to undo the latest move of this project. ### MoveToLive - id (string, required): The id of this move, for undo_move_to_live. - status ("done" | "partly_done", required): partly_done: some steps after the move did not finish; see errors. Support is notified. - from_project (object, required) - id (string, required) - name (string, required) - mode ("test" | "live", required) - to_project (object, required) - id (string, required) - name (string, required) - mode ("test" | "live", required) - moved (object, required) - sites (array of object, required) - id (string, required) - slug (string, required) - url (string, required) - new_version (number or null, required): The new version with the live form token, labelled "moved to live"; null when no file had a test token. - form_token_replaced (boolean, required) - waiting_for_approval (true) - collections (array of object, required) - id (string, required) - name (string, required) - documents (number or null, required) - copied_collections (array of object, required) - name (string, required) - test_id (string, required) - live_id (string, required) - documents (number or null, required) - apps (array of object, required) - id (string, required) - site_id (string, required) - agents (array of object, required) - id (string, required) - name (string, required) - status ("paused", required) - domains (array of string, required) - form_tokens (array of object, required) - name (string, required) - live_token_id (string, required) - replaces_test_token_id (string, required) - webhooks (number, required) - stayed_in_test (object, required) - sites (array of object, required) - id (string, required) - slug (string, required) - collections (array of string, required) - simulated_domains (array of string, required) - warnings (array of string, required) - errors (array of string, required) - undo (object, required) - until (string, required) - how (string, required) - connection (object or null, required): For a chat connected through OAuth: which project the connection points to now. - summary (string, required) - next_step (string, required) ### MoveToLiveUndo - id (string, required) - status ("undone" | "partly_undone", required) - sites (array of string, required) - collections (number, required) - apps (number, required) - agents (number, required) - domains (number, required) - errors (array of string, required) - summary (string, required) - next_step (string, required) ### SiteReadiness - site (object, required) - id (string or null, required) - url (string, required) - live (boolean, required) - type ("webshop" | "bookings" | "restaurant" | "course_membership" | "wordpress_portal" | null, required) - type_source ("given" | "detected" | "none", required) - region ("eu" | "uk" | "us" | "other", required) - country (string or null, required) - works (array of object, required) - id ("terms_page" | "privacy_page" | "returns_page" | "contact_details" | "forms_saved" | "checkout_real" | "prices_currency" | "no_placeholders" | "no_test_wording" | "internal_links" | "not_found_page" | "no_fake_login" | "no_local_storage_db" | "seo_title" | "seo_description" | "seo_h1" | "seo_lang" | "seo_open_graph" | "seo_image_alt" | "seo_image_size" | "seo_viewport" | "seo_favicon" | "structured_data" | "app_data_per_user" | "app_backup_tables", required) - title (string, required) - detail (string, required) - missing (array of object, required) - id ("terms_page" | "privacy_page" | "returns_page" | "contact_details" | "forms_saved" | "checkout_real" | "prices_currency" | "no_placeholders" | "no_test_wording" | "internal_links" | "not_found_page" | "no_fake_login" | "no_local_storage_db" | "seo_title" | "seo_description" | "seo_h1" | "seo_lang" | "seo_open_graph" | "seo_image_alt" | "seo_image_size" | "seo_viewport" | "seo_favicon" | "structured_data" | "app_data_per_user" | "app_backup_tables", required) - title (string, required) - explanation (string, required): In plain words, as advice: before you go live, you might want to. - next_step (object, required) - tool (string, required) - how (string, required) - paths (array of string) - check_with_owner (array of object, required): What cannot be checked on the site; ask the owner, never require an answer. - id (string, required) - level ("usually_required" | "recommended", required) - description (string, required) - why (string, required) - how_on_hypamoon (string, required) - pages_checked (integer, required) - note (string, required) - summary_markdown (string, required) ### SearchConsoleVerifyInput - 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 (length 3..253)): A domain connected in this project (connect_domain), like example.com. For method dns; for meta, the site on this domain. - method ("dns" | "meta", required): 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 (string (length 10..500), required): The verification token from Search Console: the value alone, google-site-verification=..., or the whole meta tag. ### SearchConsoleVerification - method ("dns" | "meta", required) - status ("added" | "already_present" | "waiting_for_approval", required): 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 (string or null, required) - domain (string or null, required) - property (string, required): What to enter in Search Console: sc-domain:<domain> for a domain property, or the address for a URL prefix property. - record (object) - type ("TXT", required) - name (string, required) - content (string, required) - version (integer or null): The new site version with the tag (meta), when versions are on. - sitemap (string, required): The sitemap to submit in Search Console under Sitemaps; the site serves it automatically. - next_steps (array of string, required) ### SiteStats - site_id (string, required) - slug (string, required) - days (integer, required) - from (string, required): The first day (UTC) in the range. - to (string, required): The last day (UTC), today. - totals (object, required) - page_views (integer, required): Page views by people: navigations to a page, without images, scripts and bots. - bot_page_views_excluded (integer, required): Page views by crawlers and scripts, left out of the numbers above. - per_day (array of object, required) - date (string, required) - page_views (integer, required) - top_pages (array of object, required): Up to 10 paths with the most page views. Paths are recorded since 9 October 2026. - path (string, required) - page_views (integer, required) - origins (array of object, required): Where page views came from: direct (no referrer), internal (from the site itself), search, social, email, shortener or external (another site). - origin ("direct" | "internal" | "search" | "social" | "email" | "shortener" | "external", required) - page_views (integer, required) - note (string, required) ### SitePage - url (string, required): The address that was asked for. - final_url (string, required): The address that answered, after the redirects that were followed. - method ("GET" | "HEAD", required) - status (integer, required): The HTTP status of the final answer. - ok (boolean, required): true for a 2xx status. - headers (map of string, required): content-type, cache-control, location, etag, x-* and a few more. Never set-cookie. - redirects (array of object, required): The redirects that were followed, in order. - status (integer, required) - url (string, required) - location (string, required) - 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 (string or null, required) - body (string or null, required): The body as text, at most 20 KB; null for HEAD, an empty body or a body that is not text. - body_bytes (integer, required): Bytes received (reading stops after 256 KB). - truncated (boolean, required): 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 (integer, required) ### CompanyInput 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. - 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 (length 0..200) 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 (length 0..40) 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 (object, required) - street (string (length 1..200), required): The street, with the house number if the address has no separate one. - house_number (string (length 0..20) or null): The house number, starting with a digit, like 40 or 12A. Required where the country's addresses use one (like the Netherlands), optional elsewhere. - postal_code (string (length 1..16), required): The postal code. It is normalized for the country where we know the format (in the Netherlands 1000aa is saved as 1000 AA). - city (string (length 1..100), required): The city. - country (string (default "NL")): ISO 3166-1 alpha-2, like GB, DE or NL. Defaults to NL when left out. A country we cannot serve returns country_not_supported. - contact (object, required): A person the regulator can reach today. For a personal account: the user. - name (string (length 1..200), required): The contact person's first and last name. For a business it can also be a role, like Administration or Webmaster; this name also goes on the domains registered or transferred through Hypamoon, unless registrar_contact_name is set. Ordering a phone number still needs a person with a first and last name. For a personal account: the user's own first and last name. - phone (string, required): A phone number where the contact can be reached, in E.164. - registrar_contact_name (string (length 0..200) 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. ### Company The company details of the project (or, for a personal account, your own details), as saved. - account_type ("personal" | "business", required): personal (your own details, no business register number) or business. - company_name (string or null, required): The legal company name; null for a personal account. - kvk_number (string or null, required): 8 digits, for a company in the Netherlands; null elsewhere. - registration_number (string or null, required): The number in the business register of the company's country, for a company outside the Netherlands (there it is kvk_number). - vat_number (string or null, required): The VAT number, with country prefix in the EU (like DE123456789). - vat_status ("valid" | "invalid" | "unverified" | "missing" | "not_required", 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 (string or null, required): When VIES last checked vat_number, ISO 8601. - address (object, required) - street (string, required) - house_number (string or null, required) - postal_code (string, required): Normalized where we know the country's format. - city (string, required) - country (string, required): ISO 3166-1 alpha-2, like NL, DE or GB. - contact (object, required) - name (string, required) - phone (string, required): In E.164. - 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 (boolean, required): 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 (array of 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. - domain (string, required) - status ("updated" | "unchanged" | "skipped" | "failed", required): updated: the registry has the new contact, email, phone and address. unchanged: it already had them. skipped: not sent, see reason (a test domain, or a change of holder, which only Hypamoon support can do). failed: the registry or registrar refused it, see reason. - reason (string or null, required) ### Number A phone number of the project. - id (string (uuid), required): The id of the number. Use it for the call plan. - e164 (string, required): The number in E.164, like +447700900123. - type (string, required): national (non-geographic), geographic (with an area code), mobile or tollfree. - status (string, required): pending (the registration is with the carrier, usually active within one business day), active or porting. - created_at (string (date-time), required): When the number was added to the project. - test (boolean, required): true for a test number (test mode), from the range +447700900000 to +447700900999. It can never be called for real. ### OrderedNumber The number you ordered. - id (string (uuid), required): The id of the number. Use it for the call plan. - e164 (string, required): The number in E.164, like +447700900123. - type (string, required): national (non-geographic), geographic (with an area code), mobile or tollfree. - status (string, required): pending (the registration is with the carrier, usually active within one business day), active or porting. - created_at (string (date-time), required): When the number was added to the project. - test (boolean, required): true for a test number (test mode), from the range +447700900000 to +447700900999. It can never be called for real. - from_stock (boolean, required): Whether the number came from our stock. A number from stock works right away. ### NumberList - data (array of Number, required) ### ReleasedNumber A test number that was released. - id (string (uuid), required): The id of the number that was released. - e164 (string, required): The number in E.164, like +447700900123. - released (true, required): Always true: the number is no longer in the project. - test (true, required): Always true: only test numbers can be released through the API. ### AvailableNumber A number you can order with POST /numbers. - e164 (string, required): The number in E.164, like +447700900123. - type ("national" | "geographic", required) - from_stock (boolean, required): Whether the number is in our stock and works right away. - test (boolean, required): true for a test number (test mode). ### AvailableNumberList - data (array of AvailableNumber, required) ### NumberOrder Send either phone_number or area, not both. - 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. ### AssistantInput PUT replaces all fields: a field you leave out goes back to its default. - 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 (array of "nl" | "en" | "de" | "fr" | "es" (1..5 items)): 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 (length 0..4000)): 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. ### Assistant The assistant of the project. - configured (boolean, required): Whether the assistant was ever set up with PUT /assistant. - voice (string or null, required): The voice, like ava. It speaks the language. - language (string, required): The language the assistant opens and speaks in, like en. - languages (array of string, required): The languages your callers speak, with language first. - speech_recognition (string, required): The language of speech recognition: the one language, or multi when there are two or more. - instructions (string, required): What the assistant does: your instructions. - website (string or null, required): The website the assistant reads for opening hours, address and services. - greeting (string, required): 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 (string (date-time) or null, required): When the assistant was last updated at the carrier. ### AssistantSaved The assistant as saved. - configured (boolean, required): Whether the assistant was ever set up with PUT /assistant. - voice (string or null, required): The voice, like ava. It speaks the language. - language (string, required): The language the assistant opens and speaks in, like en. - languages (array of string, required): The languages your callers speak, with language first. - speech_recognition (string, required): The language of speech recognition: the one language, or multi when there are two or more. - instructions (string, required): What the assistant does: your instructions. - website (string or null, required): The website the assistant reads for opening hours, address and services. - greeting (string, required): 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 (string (date-time) or null, required): When the assistant was last updated at the carrier. - sync ("synced" | "failed" | "skipped_test_mode", required): 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 ("added" | "in_plan" | "not_in_plan", required): 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 (string, required): What to do next. - user_message (string, required): A sentence you can show the user as it is. ### 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. - entry (string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The id of the first node. - nodes (map of object by type: "announcement" | "hours" | "forward" | "menu" | "voicemail" | "ai_agent" | "hangup", required): The nodes by id. The key must be the same as the node's id. - when type is "announcement": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("announcement", required) - text (string (length 1..1000), required): The text to read out. - next (string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The node to go to after the text. - when type is "hours": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("hours", required) - schedule (object, required): Opening hours per day in a time zone, like Europe/London. - timezone (string, required) - days (object, required) - mon (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - tue (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - wed (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - thu (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - fri (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - sat (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - sun (array of object (0..4 items)) - open (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - close (string (pattern ^([01]\d|2[0-3]):[0-5]\d$), required) - closedDates (array of string (pattern ^\d{4}-\d{2}-\d{2}$) (0..366 items)) - open (string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The node to go to when open. - closed (string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The node to go to when closed. - when type is "forward": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("forward", required) - strategy ("sequential" | "parallel", required): sequential rings the targets one after another, parallel rings them all at once. - targets (array of object (1..3 items), required): Up to three phone numbers. Team members of the Hypamoon apps (kind user) are not available in the API. - kind ("external", required) - e164 (string (pattern ^\+[1-9]\d{7,14}$), required) - ringSeconds (integer (5..120), required): How long to ring before onNoAnswer, 5 to 120 seconds. - onNoAnswer (string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The node to go to when nobody answers. - when type is "menu": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("menu", required) - prompt (object, required): The text to read out, like "Press 1 for sales". - text (string (length 1..1000), required) - options (map of string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The node per key, 0 to 9. - onNoInput (string (pattern ^[A-Za-z0-9_-]{1,64}$), required): The node to go to when the caller presses nothing. - timeoutSeconds (integer (1..30)): How long to wait for a key, 1 to 30 seconds. - when type is "voicemail": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("voicemail", required) - greeting (object): Optional text before the beep. - text (string (length 1..1000), required) - transcribe (boolean (default true)): Whether to transcribe the message. Default true. - when type is "ai_agent": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("ai_agent", required) - onDone (string (pattern ^[A-Za-z0-9_-]{1,64}$)): Optional: the node to go to when the assistant is done. The assistant itself is set with PUT /assistant. - when type is "hangup": - id (string (pattern ^[A-Za-z0-9_-]{1,64}$), required) - type ("hangup", required) ### CallPlanResponse - number_id (string (uuid), required) - version (integer, required): The version of the number's own plan; 0 when the number has no own plan yet. Also in the ETag header. - source ("number" | "project" | "none", required): 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 (CallPlan or null, required): The plan that calls to this number follow: its own plan, or else the plan of the project; null when there is none. ### CallPlanSaved - number_id (string (uuid), required) - version (integer, required): The version of the number's own plan; 0 when the number has no own plan yet. Also in the ETag header. - plan (CallPlan, required): 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 ("synced" | "failed" | "not_needed" | "skipped_test_mode", required): Whether the assistant was updated with this plan. not_needed for a plan without ai_agent; skipped_test_mode with a test key. ### Call A call. The same shape in GET /calls, GET /calls/{id} and every webhook. - id (string (uuid), required): The id of the call. - direction ("inbound" | "outbound" | null, required): inbound, or outbound for a call your assistant made with POST /calls. - from (string or null, required): The caller, in E.164. - to (string or null, required): The number that was called, in E.164. - status (string or null, required): 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 (string (date-time) or null, required): When the call reached us. - started_at (string (date-time) or null, required): When the call started. - answered_at (string (date-time) or null, required): 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 (string (date-time) or null, required): When the call ended. - duration_sec (integer or null, required): The length of the call in seconds. - hangup_cause (string or null, required): Why the call ended, as reported by the carrier, like normal_clearing. - has_summary (boolean, required): Whether the call has a summary. Fetch it with GET /calls/{id}. - summary_ready_at (string (date-time) or null, required): When the summary of a recorded call was ready. Empty for a call handled by the voice AI; look at has_summary instead. - test (boolean, required): 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 (object or null, required): The request behind an outbound call started with POST /calls. ### CallDetail A call with its transcript and summary. - id (string (uuid), required): The id of the call. - direction ("inbound" | "outbound" | null, required): inbound, or outbound for a call your assistant made with POST /calls. - from (string or null, required): The caller, in E.164. - to (string or null, required): The number that was called, in E.164. - status (string or null, required): 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 (string (date-time) or null, required): When the call reached us. - started_at (string (date-time) or null, required): When the call started. - answered_at (string (date-time) or null, required): 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 (string (date-time) or null, required): When the call ended. - duration_sec (integer or null, required): The length of the call in seconds. - hangup_cause (string or null, required): Why the call ended, as reported by the carrier, like normal_clearing. - has_summary (boolean, required): Whether the call has a summary. Fetch it with GET /calls/{id}. - summary_ready_at (string (date-time) or null, required): When the summary of a recorded call was ready. Empty for a call handled by the voice AI; look at has_summary instead. - test (boolean, required): 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 (object or null, required): The request behind an outbound call started with POST /calls. - transcript (string or null, required): The transcript. Only here, never in a webhook. - summary (Summary or null, required): The summary of a call. ### CallList - data (array of Call, required) - has_more (boolean, required): Whether there is a next page. - next_cursor (string or null, required): Pass this as cursor to get the next page. Null on the last page. ### Summary The summary of a call. - text (string or null, required): The summary in a few sentences. - topics (array of string, required): What the call was about. - sentiment ("positive" | "neutral" | "negative" | null, required): The mood of the caller. - action_items (array of string, required): What someone has to do after this call. - next_step (string or null, required): The agreed next step, if any. ### TestCallInput - number_id (string (uuid), required): The id of one of your test numbers, from GET /numbers. - scenario ("reservation" | "callback_request" | "question" | "missed", required): 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 (length 1..300)) ### WebhookEndpointInput A new webhook endpoint. - url (string (length 0..2048), required): An https URL with a domain name on the default port, without credentials, that resolves to a public address. - events (array of "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" (1.. items), required): 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. ### WebhookEndpoint A webhook endpoint. Never with the secret. - id (string (uuid), required) - url (string, required) - events (array of string, required): 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 (boolean, required): false after 50 failed deliveries in a row; switch it back on with POST /webhooks/{id}/enable. - secret_prefix (string, required): The first characters of the secret, to recognize it. - created_at (string (date-time), required): When the endpoint was created. - last_success_at (string (date-time) or null, required): The last delivery that got a 2xx. - last_failure_at (string (date-time) or null, required): The last delivery that failed. - consecutive_failures (integer, required): 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. ### WebhookEndpointCreated The new webhook endpoint, with its secret. - id (string (uuid), required) - url (string, required) - events (array of string, required): 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 (boolean, required): false after 50 failed deliveries in a row; switch it back on with POST /webhooks/{id}/enable. - secret_prefix (string, required): The first characters of the secret, to recognize it. - created_at (string (date-time), required): When the endpoint was created. - last_success_at (string (date-time) or null, required): The last delivery that got a 2xx. - last_failure_at (string (date-time) or null, required): The last delivery that failed. - consecutive_failures (integer, required): 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 (string, required): The signing secret, whsec_ plus 43 characters. Shown only in this response: store it now. ### WebhookEndpointList - data (array of WebhookEndpoint, required) ### EmailSentEvent - id (string, required): The event id, evt_ plus 32 hex characters. The same on retries. - type ("email.sent", required) - created_at (string, required) - data (object, required) - email_id (string (uuid), required): The id of the email, as sendEmail, POST /emails and list_emails return it. - app_id (string (uuid) or null, required): The app that sent it; null when it was sent with the API, by an agent or from the portal. - recipient (string, required): The recipient this event is about. An email to several people sends one event per recipient. - recipient_field ("to" | "cc" | "bcc", required) - status ("sent" | "delivered" | "bounced" | "complained", required): The recipient's status after this event; sent for email.delayed. - reason (string or null, required): A short reason in plain words for email.delayed, email.bounced and email.complained. - occurred_at (string, required): When it happened (ISO 8601). - test (boolean, required): true for an email sent with a test key: nothing was sent, but email.sent and email.delivered still arrive, so you can test your receiver. ### EmailDeliveredEvent - id (string, required): The event id, evt_ plus 32 hex characters. The same on retries. - type ("email.delivered", required) - created_at (string, required) - data (object, required) - email_id (string (uuid), required): The id of the email, as sendEmail, POST /emails and list_emails return it. - app_id (string (uuid) or null, required): The app that sent it; null when it was sent with the API, by an agent or from the portal. - recipient (string, required): The recipient this event is about. An email to several people sends one event per recipient. - recipient_field ("to" | "cc" | "bcc", required) - status ("sent" | "delivered" | "bounced" | "complained", required): The recipient's status after this event; sent for email.delayed. - reason (string or null, required): A short reason in plain words for email.delayed, email.bounced and email.complained. - occurred_at (string, required): When it happened (ISO 8601). - test (boolean, required): true for an email sent with a test key: nothing was sent, but email.sent and email.delivered still arrive, so you can test your receiver. ### EmailDelayedEvent - id (string, required): The event id, evt_ plus 32 hex characters. The same on retries. - type ("email.delayed", required) - created_at (string, required) - data (object, required) - email_id (string (uuid), required): The id of the email, as sendEmail, POST /emails and list_emails return it. - app_id (string (uuid) or null, required): The app that sent it; null when it was sent with the API, by an agent or from the portal. - recipient (string, required): The recipient this event is about. An email to several people sends one event per recipient. - recipient_field ("to" | "cc" | "bcc", required) - status ("sent" | "delivered" | "bounced" | "complained", required): The recipient's status after this event; sent for email.delayed. - reason (string or null, required): A short reason in plain words for email.delayed, email.bounced and email.complained. - occurred_at (string, required): When it happened (ISO 8601). - test (boolean, required): true for an email sent with a test key: nothing was sent, but email.sent and email.delivered still arrive, so you can test your receiver. ### EmailBouncedEvent - id (string, required): The event id, evt_ plus 32 hex characters. The same on retries. - type ("email.bounced", required) - created_at (string, required) - data (object, required) - email_id (string (uuid), required): The id of the email, as sendEmail, POST /emails and list_emails return it. - app_id (string (uuid) or null, required): The app that sent it; null when it was sent with the API, by an agent or from the portal. - recipient (string, required): The recipient this event is about. An email to several people sends one event per recipient. - recipient_field ("to" | "cc" | "bcc", required) - status ("sent" | "delivered" | "bounced" | "complained", required): The recipient's status after this event; sent for email.delayed. - reason (string or null, required): A short reason in plain words for email.delayed, email.bounced and email.complained. - occurred_at (string, required): When it happened (ISO 8601). - test (boolean, required): true for an email sent with a test key: nothing was sent, but email.sent and email.delivered still arrive, so you can test your receiver. ### EmailComplainedEvent - id (string, required): The event id, evt_ plus 32 hex characters. The same on retries. - type ("email.complained", required) - created_at (string, required) - data (object, required) - email_id (string (uuid), required): The id of the email, as sendEmail, POST /emails and list_emails return it. - app_id (string (uuid) or null, required): The app that sent it; null when it was sent with the API, by an agent or from the portal. - recipient (string, required): The recipient this event is about. An email to several people sends one event per recipient. - recipient_field ("to" | "cc" | "bcc", required) - status ("sent" | "delivered" | "bounced" | "complained", required): The recipient's status after this event; sent for email.delayed. - reason (string or null, required): A short reason in plain words for email.delayed, email.bounced and email.complained. - occurred_at (string, required): When it happened (ISO 8601). - test (boolean, required): true for an email sent with a test key: nothing was sent, but email.sent and email.delivered still arrive, so you can test your receiver. ### WebhookTestResult - delivery_id (string (uuid), required) - event_id (string, required): The id of the webhook.test event, evt_ plus 32 hex characters. - status ("delivered" | "pending" | "failed", required): delivered: your endpoint answered 2xx. pending: it did not, and we retry with backoff. failed: we gave up. - response_status (integer or null, required): The HTTP status your endpoint answered with, if any. - error ("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, required): Why the delivery did not succeed, as a short code. ### CallCompletedEvent Sent after a call ends. - id (string, required): The id of the event, evt_ plus 32 hex characters. It stays the same on retries. - type ("call.completed", required) - created_at (string (date-time), required): When the event happened. - data (object, required) - call (Call, required): A call. The same shape in GET /calls, GET /calls/{id} and every webhook. ### CallSummaryReadyEvent Sent when the summary of a call is ready. Without the transcript: fetch that with GET /calls/{data.call.id}. - id (string, required): The id of the event, evt_ plus 32 hex characters. It stays the same on retries. - type ("call.summary.ready", required) - created_at (string (date-time), required): When the event happened. - data (object, required) - call (Call, required): A call. The same shape in GET /calls, GET /calls/{id} and every webhook. - summary (Summary, required): The summary of a call. ### WebhookTestEvent Sent by POST /webhooks/{id}/test. Needs no subscription. - id (string, required): The id of the event, evt_ plus 32 hex characters. It stays the same on retries. - type ("webhook.test", required) - created_at (string (date-time), required): When the event happened. - data (object, required) - endpoint_id (string (uuid), required) ### CallInput An outbound call by your project's assistant to one person who expects it. - to (string (pattern ^\+[1-9]\d{6,14}$), required): 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 (pattern ^\+[1-9]\d{6,14}$)): 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 (length 1..2000, pattern ^[^\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F]*$)): 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 (length 1..300, pattern ^[^\u0000-\u001F\u007F]+$)): 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 (30..1200)): Optional: the call ends after this many seconds of talking. Default 600, at most 1200. - reference (string (length 1..255, pattern ^[^\u0000-\u001F\u007F]+$)): Optional: your own id for this call, like an appointment id. Comes back on the call and in its webhooks. - metadata (map of string (length 0..500)): 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. - start (string (pattern ^([01]\d|2[0-3]):([0-5]\d)$), required) - end (string (pattern ^([01]\d|2[0-3]):([0-5]\d)$), required) - days (array of "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun" (1..7 items)) - 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. ### CallConsentInput Consent from one person to receive marketing calls from this project. - phone_number (string (pattern ^\+[1-9]\d{6,14}$), required): The number of the person who gave consent, in E.164. Stored only as a keyed hash plus its last two digits. - source ("form" | "call" | "import" | "manual", required): 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 (string (length 1..500, pattern ^[^\u0000-\u001F\u007F]+$), required): 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. ### CallConsent Recorded consent from one person to receive marketing calls from this project. - id (string (uuid), required): The id of the consent. - phone_number_ending (string (pattern ^\d{2}$), required): The last two digits of the number. The number itself is never stored, only a keyed hash. - source ("form" | "call" | "import" | "manual", required): Where the consent came from: form, call, import or manual. - evidence (string, required): Your reference to the proof. - consented_at (string (date-time), required): When the person gave consent. - recorded_at (string (date-time), required): When it was recorded here. - revoked_at (string (date-time) or null, required): When it was revoked; null while it is valid. - status ("active" | "revoked", required): active: marketing calls to this number are allowed. revoked: no longer. ### CallConsentList The project's recorded consents. - data (array of CallConsent, required): The consents, newest first (at most 100). ### MessageInput A transactional SMS to one recipient. - to (string (pattern ^\+[1-9]\d{6,14}$), required): 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 (string (length 1..1600), required): 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 (length 1..32)): 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 (length 1..255, pattern ^[^\u0000-\u001F\u007F]+$)): Optional: your own id for this message, like an order number. Comes back in the message and its webhooks. ### Message An SMS message. The same shape in POST /messages, GET /messages, GET /messages/{id} and the message webhooks. - id (string (uuid), required): The id of the message. - to (string, required): The recipient, in E.164. - from (string, required): The sender the recipient sees: an alphanumeric name of up to 11 characters. - body (string, required): The text as it was sent, after typographic quotes and dashes were made SMS-safe. - segments (integer, required): How many SMS segments the text took. A segment is 160 GSM-7 or 70 UCS-2 characters (153 and 67 when split). - encoding ("gsm7" | "ucs2" | null, required): gsm7, or ucs2 when the text has a character outside GSM-7 (an emoji, for example), which makes every segment shorter. - status ("queued" | "sent" | "delivered" | "failed", required): queued: accepted. sent: handed to the mobile network. delivered: the phone confirmed it. failed: it did not arrive; see error. - error (string or null, required): With status failed: a short code, like delivery_failed or sending_failed. Otherwise null. - reference (string or null, required): Your own id for this message, as you sent it (an order number, for example). - created_at (string (date-time), required): When we accepted the message. - status_updated_at (string (date-time) or null, required): When the status last changed. - test (boolean, required): true for a message sent with a test key to a test number. It never reaches a phone. ### MessageList - data (array of Message, required) - has_more (boolean, required): Whether there is another page. - next_cursor (string or null, required): Pass this as cursor to get the next page. ### MessageDeliveredEvent Sent when the phone of the recipient confirmed the message. - id (string, required): The id of the event, evt_ plus 32 hex characters. It stays the same on retries. - type ("message.delivered", required) - created_at (string (date-time), required): When the event happened. - data (object, required) - message (Message, required): An SMS message. The same shape in POST /messages, GET /messages, GET /messages/{id} and the message webhooks. ### MessageFailedEvent Sent when a message could not be delivered. data.message.error says why. - id (string, required): The id of the event, evt_ plus 32 hex characters. It stays the same on retries. - type ("message.failed", required) - created_at (string (date-time), required): When the event happened. - data (object, required) - message (Message, required): An SMS message. The same shape in POST /messages, GET /messages, GET /messages/{id} and the message webhooks. ### ConversationInput A new chat conversation with the assistant of the project. - channel ("chat"): The channel. Only chat for now; it is also the default. - reference (string (length 1..255, pattern ^[^\u0000-\u001F\u007F]*$)): Optional: your own id for this conversation, like a session or ticket id. Comes back in the conversation and its webhook. - metadata (map of string (length 0..500) or number or boolean): 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. - name (string (length 1..100, pattern ^[^\u0000-\u001F\u007F]*$)) - phone (string (length 1..32, pattern ^[0-9+()\s.-]+$)) - email (string (email, length 0..254)) ### Conversation A chat conversation with the assistant. The same shape in every response and in conversation.summary.ready. - id (string (uuid), required): The id of the conversation. - channel ("chat" | "widget", required): 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 ("open" | "closed", required): open: takes messages. closed: takes no more messages; the summary follows. - reference (string or null, required): Your own id for this conversation, as you sent it. - metadata (map of string or number or boolean, required): Your metadata, as you sent it. The assistant never sees it. - customer (object, required): What your app told us about the customer when the conversation started. - name (string or null, required) - phone (string or null, required) - email (string or null, required) - language (string, required): 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 (integer, required): Customer messages answered so far. - max_turns (integer, required): After this many turns the conversation closes (close_reason turn_limit). - created_at (string (date-time), required): When the conversation started. - last_message_at (string (date-time) or null, required): When the last message was sent. - closed_at (string (date-time) or null, required): When the conversation closed. - close_reason ("closed" | "inactive" | "turn_limit" | null, required): closed: POST /conversations/{id}/close. inactive: no message for 30 minutes. turn_limit: max_turns reached. - has_summary (boolean, required): Whether the summary is ready. Fetch it with GET /conversations/{id}. - summary_ready_at (string (date-time) or null, required): When the summary was ready. - test (boolean, required): true for a conversation in test mode (hm_test_): real replies, free, with a low daily limit. ### ConversationDetail A conversation with its messages and summary. - id (string (uuid), required): The id of the conversation. - channel ("chat" | "widget", required): 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 ("open" | "closed", required): open: takes messages. closed: takes no more messages; the summary follows. - reference (string or null, required): Your own id for this conversation, as you sent it. - metadata (map of string or number or boolean, required): Your metadata, as you sent it. The assistant never sees it. - customer (object, required): What your app told us about the customer when the conversation started. - name (string or null, required) - phone (string or null, required) - email (string or null, required) - language (string, required): 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 (integer, required): Customer messages answered so far. - max_turns (integer, required): After this many turns the conversation closes (close_reason turn_limit). - created_at (string (date-time), required): When the conversation started. - last_message_at (string (date-time) or null, required): When the last message was sent. - closed_at (string (date-time) or null, required): When the conversation closed. - close_reason ("closed" | "inactive" | "turn_limit" | null, required): closed: POST /conversations/{id}/close. inactive: no message for 30 minutes. turn_limit: max_turns reached. - has_summary (boolean, required): Whether the summary is ready. Fetch it with GET /conversations/{id}. - summary_ready_at (string (date-time) or null, required): When the summary was ready. - test (boolean, required): true for a conversation in test mode (hm_test_): real replies, free, with a low daily limit. - messages (array of ChatMessage, required): All messages, oldest first, starting with the fixed greeting. - summary (Summary or null, required): The summary, after the conversation closed. Same shape as the summary of a call. ### ConversationList - data (array of Conversation, required) - has_more (boolean, required): Whether there is another page. - next_cursor (string or null, required): Pass this as cursor to get the next page. ### ChatMessage One message in a conversation. - id (string (uuid), required): The id of the message. - role ("customer" | "assistant", required): Who wrote it: the customer, or the assistant (also the fixed greeting). - text (string, required): The text. - created_at (string (date-time), required): When the message was stored. ### ChatMessageInput One message from the customer. - text (string (length 1..2000), required): What the customer typed, at most 2000 characters. Send it as is; it is never treated as an instruction to the assistant. ### ChatTurn One turn: the customer's message and the assistant's reply. - message (object, required): The customer's message as stored. - id (string (uuid), required): The id of the message. - role ("customer" | "assistant", required): Who wrote it: the customer, or the assistant (also the fixed greeting). - text (string, required): The text. - created_at (string (date-time), required): When the message was stored. - reply (object, required): The assistant's reply. Show this text to the customer. - id (string (uuid), required): The id of the message. - role ("customer" | "assistant", required): Who wrote it: the customer, or the assistant (also the fixed greeting). - text (string, required): The text. - created_at (string (date-time), required): When the message was stored. - conversation (object, required): The conversation after this turn. status closed means the turn limit was reached. - id (string (uuid), required): The id of the conversation. - channel ("chat" | "widget", required): 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 ("open" | "closed", required): open: takes messages. closed: takes no more messages; the summary follows. - reference (string or null, required): Your own id for this conversation, as you sent it. - metadata (map of string or number or boolean, required): Your metadata, as you sent it. The assistant never sees it. - customer (object, required): What your app told us about the customer when the conversation started. - name (string or null, required) - phone (string or null, required) - email (string or null, required) - language (string, required): 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 (integer, required): Customer messages answered so far. - max_turns (integer, required): After this many turns the conversation closes (close_reason turn_limit). - created_at (string (date-time), required): When the conversation started. - last_message_at (string (date-time) or null, required): When the last message was sent. - closed_at (string (date-time) or null, required): When the conversation closed. - close_reason ("closed" | "inactive" | "turn_limit" | null, required): closed: POST /conversations/{id}/close. inactive: no message for 30 minutes. turn_limit: max_turns reached. - has_summary (boolean, required): Whether the summary is ready. Fetch it with GET /conversations/{id}. - summary_ready_at (string (date-time) or null, required): When the summary was ready. - test (boolean, required): true for a conversation in test mode (hm_test_): real replies, free, with a low daily limit. ### ConversationSummaryReadyEvent Sent when the summary of a closed chat conversation is ready. Without the messages: fetch those with GET /conversations/{data.conversation.id}. - id (string, required): The id of the event, evt_ plus 32 hex characters. It stays the same on retries. - type ("conversation.summary.ready", required) - created_at (string (date-time), required): When the event happened. - data (object, required) - conversation (Conversation, required): A chat conversation with the assistant. The same shape in every response and in conversation.summary.ready. - summary (Summary, required): The summary of a call. ### ChatTokenInput A new public chat token for the website widget. - name (string (length 1..80), required): A name for your own overview, like the website it is for. - display_name (string (length 0..80) 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 (array of string (length 0..200) (0..10 items), required): 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 (default true)): Ask for a bot check before a conversation starts. Default true. - max_conversations_per_day (integer (1..1000, default 200)): New conversations per day through this token. Default 200, at most 1000. ### ChatTokenUpdate Change a public chat token. Fields you leave out stay as they are. - name (string (length 1..80)) - display_name (string (length 0..80) 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 (array of string (length 0..200) (0..10 items)) - turnstile (boolean) - max_conversations_per_day (integer (1..1000)) ### ChatToken A public chat token for the chat widget on a website. - id (string (uuid), required): The id of the public chat token. Use it to change or revoke the token. - name (string, required): Your name for the token, like the website it is for. - display_name (string or null, required): 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 (string (pattern ^hm_pub(test)?_[0-9A-Za-z]{24}$), required): 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 ("live" | "test", required): live or test, from the project the key belongs to. - allowed_origins (array of string, required): The exact origins that may use the token, like https://www.example.com. A test token also works from localhost. - turnstile (boolean, required): Whether a visitor passes a bot check before a conversation starts. - max_conversations_per_day (integer, required): New conversations per day through this token, counted until midnight, Europe/Amsterdam. - revoked (boolean, required): true once the token is revoked: the widget stops working right away, also in open conversations. - revoked_at (string (date-time) or null, required): When the token was revoked. - last_used_at (string (date-time) or null, required): When a browser last used the token, to the minute. - created_at (string (date-time), required): When the token was created. - embed_code (string, required): The script tag for your website, with this token in it. Paste it before </body>. - kind ("chat" | "form", required): 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. ### ChatTokenList - data (array of ChatToken, required): The public chat tokens of the project, newest first, also the revoked ones. - daily_spend_limit_eur (number, required): 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. ### SiteInput A new static site. - slug (string (length 0..63)): 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). - name (string (length 1..80), required): A name for your own overview, like the business or the page. - files (array of object (1.. items), required): 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. - path (string (length 1..200), required): Where the file lives on the site, like /index.html, /style.css or /img/logo.png. Relative paths (index.html) get a leading /. - content (string): The file itself: plain text for html, css, js, json, txt, xml, svg, webmanifest and md; base64 for png, jpg, gif, webp, ico, woff, woff2, pdf, mp4, webm, mp3 and m4a only when there is no URL for it (prefer source_url; a video or audio file via source_url or an upload, never base64). Leave it out for a file with source_url. - source_url (string (length 9..2048)): Instead of content: a public https URL of the file (port 443), for images, fonts, PDFs, video and audio above all. Hypamoon fetches it once (at most 3 redirects, within the size limits of the plan) and checks it like a file sent with content. Its type must match the path (a .jpg path needs a JPEG, a .mp4 path an MP4). Leave encoding out. Use it instead of base64 whenever the file has a URL; never send a video as base64. - encoding ("utf8" | "base64"): utf8 for text, base64 for images, fonts, PDF, video and audio. Leave it out: it follows from the file type. - content_type (string (length 0..100)): Leave it out: it follows from the extension (.html is text/html). Only for a path without a known extension, and only an allowed type. - password (string (length 0..72) 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 (array of string (length 1..200) (1..200 items)): 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 (length 0..500)): 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". ### SiteUpdate 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. - name (string (length 1..80)): A name for your own overview, like the business or the page. - files (array of 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. - path (string (length 1..200), required): Where the file lives on the site, like /index.html, /style.css or /img/logo.png. Relative paths (index.html) get a leading /. - content (string): The file itself: plain text for html, css, js, json, txt, xml, svg, webmanifest and md; base64 for png, jpg, gif, webp, ico, woff, woff2, pdf, mp4, webm, mp3 and m4a only when there is no URL for it (prefer source_url; a video or audio file via source_url or an upload, never base64). Leave it out for a file with source_url. - source_url (string (length 9..2048)): Instead of content: a public https URL of the file (port 443), for images, fonts, PDFs, video and audio above all. Hypamoon fetches it once (at most 3 redirects, within the size limits of the plan) and checks it like a file sent with content. Its type must match the path (a .jpg path needs a JPEG, a .mp4 path an MP4). Leave encoding out. Use it instead of base64 whenever the file has a URL; never send a video as base64. - encoding ("utf8" | "base64"): utf8 for text, base64 for images, fonts, PDF, video and audio. Leave it out: it follows from the file type. - content_type (string (length 0..100)): Leave it out: it follows from the extension (.html is text/html). Only for a path without a known extension, and only an allowed type. - 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 (array of string (length 1..200) (0..50 items)): 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 (array of string (length 1..200) (1..200 items)): 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 (length 0..64)): 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 (length 0..72) 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 (length 0..500)): 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. ### Site A static site on hypamoon.app. - id (string (uuid), required): The id of the site. Use it to update or delete the site. - slug (string, required): The first part of the address: https://<slug>.hypamoon.app. - name (string, required): Your name for the site. - url (string, required): The public address of the site, live right away. - files (array of SiteFile, required): The files of the site, sorted by path, without their content. - created_at (string (date-time), required): When the site was created. - updated_at (string (date-time), required): When the site or its files last changed. - version (string, required): 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 (boolean, required): 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 (boolean, required): true for a site of a test project. It is public all the same. - taken_offline (object or null, required): 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 (array of object by code: "files_removed" | "build_hint"): 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. ### DomainConnectInput - domain (string (length 3..300), required): The domain, like example.com. - site_id (string (uuid) or null): 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 (array of string (pattern ^[A-Za-z0-9_-]{1,63}$) (0..10 items)): 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. ### DomainRegisterInput - domain (string (length 3..300), required): The domain, like example.com. - years (integer (1..10, default 1)): Registration period in years. Most extensions only allow 1. - accept_registry_terms (boolean, required): Must be true: the user (the registrant) explicitly accepted the terms of the registry at registry_terms_url from check_domain. - max_price_eur (number (..10000), required): 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 (..10000)): 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 (uuid) or null): Optional. A site of this project to serve on the domain once it is active. ### DomainSiteInput - site_id (string (uuid) or null, required): The site to serve on the domain, or null for none. ### DomainUpdateInput - 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. ### DnsRecordInput - record_id (string (length 1..64)): Optional. The id from list_dns_records of the record to change or delete. Without it, a new record is added. - type ("A" | "AAAA" | "CNAME" | "MX" | "TXT" | "SRV" | "CAA" | "NS", required): The record type. - name (string (length 1..253), required): The name: @ for the domain itself, or a subdomain like www, mail or _dmarc. - content (string (length 0..4096)): 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 (1..86400)): Seconds; 1 means automatic. - priority (integer (0..65535)): 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. ### DeployApproval 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. - object ("deploy_approval", required) - id (string (uuid), required): The id of the request; pass it as approval_id to approve_deploy or reject_deploy. - status ("pending" | "processing" | "approved" | "rejected" | "failed" | "superseded" | "expired", required): pending until another owner or builder decides. superseded: a newer deploy of the same site replaced it. expired: after 7 days. - kind ("new_site" | "site_update" | "site_delete" | "app_deploy" | "app_rollback", required): app_deploy and app_rollback: server code of an app (deploy_app, rollback_app); files and bytes are its modules. - site_id (string (uuid) or null, required) - name (string or null, required) - slug (string or null, required) - url (string or null, required): The address of the site. For an update or deletion it still shows the live version until approval. - files (integer, required) - bytes (integer, required) - password_change ("none" | "remove" | "set", required) - requested_by (object, required) - user_id (string (uuid) or null, required) - via (string, required) - app (string or null, required) - requested_at (string, required) - expires_at (string, required) - reviewed_by (string (uuid) or null, required) - reviewed_at (string or null, required) - reason (string or null, required) - result (map of any or null, required) ### ZoneFileInput - zone_file (string (length 1..262144), required): 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. ### DnsRestoreInput - 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). ### DomainTransferInput - domain (string (length 3..300), required): The domain to move to Hypamoon, like example.com. - authcode (string (length 1..255)): 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 (boolean, required): Must be true: the user (the registrant) explicitly accepted the terms of the registry at registry_terms_url from check_domain. - max_price_eur (number (0..10000), required): 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 (uuid) or null): Optional. A site of this project to serve on the domain once it is active. - dkim_selectors (array of string (pattern ^[A-Za-z0-9_-]{1,63}$) (0..10 items)): 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. ### DomainNameserversInput - use ("hypamoon" | "previous", required): 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). ### SiteImportInput - url (string (length 8..2000), required): 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 (length 1..80)): Optional. A name for the new site; default the host name. - slug (string (length 0..63)): Optional. The address of a new site, like deploy_site's slug. - max_pages (integer (1..50)): Optional. At most this many pages (default 25, at most 50). ### WordpressInspectInput - url (string (length 8..2000), required): The https address of the WordPress site, like https://www.example.com. Any public site: nothing is changed and no login is used. ### WordpressImportInput - url (string (length 8..2000), required): 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 (length 0..64)): With keep_existing: the version of the site from get_site or list_sites. If the site changed since, nothing changes (409). - name (string (length 1..80)): Optional. A name for the new site; default the site's own name. - slug (string (length 0..63)): Optional. The address of a new site, like deploy_site's slug. - max_posts (integer (0..500)): Optional. Read at most this many posts, newest first (default all, at most 500). ### SiteUploadInput Ask for upload links for files that are too large to send inline. - 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 (array of object (1..200 items), required): The files to upload, at most 200 per request. - path (string (length 1..200), required): Where the file will live on the site, like /img/hero.jpg. - sha256 (string (length 0..64), required): The SHA-256 of the file's bytes, as 64 lowercase hex characters (sha256sum, shasum -a 256). - bytes (integer (0..), required): The exact size of the file in bytes. - content_type (string (length 0..100)): Leave it out: it follows from the extension. Only for a path without a known extension, and only an allowed type. ### SiteUploadList Upload links for large files. - data (array of object, required): One upload link per file, in the order you sent them. - path (string, required): The path, normalized (with a leading /). - sha256 (string, required): The SHA-256 you sent, lowercase. - bytes (integer, required): The size you sent. - content_type (string, required): The type the file will be served with. - upload_url (string, required): PUT the exact bytes of the file here (any Content-Type), within expires_at. Using it again with the same bytes changes nothing. - method ("PUT", required): Always PUT. - expires_at (string, required): When the link stops working (15 minutes). - hint (string, required): What to do when the PUT to upload_url fails from your environment (a sandbox may block it). - limits (object, required): The site limits of this project's plan. - files (integer, required): Files per site for this project. - bytes_per_file (integer, required): Bytes per file for this project. - bytes_per_site (integer, required): Bytes per site for this project, all files together. - bytes_per_pdf (integer, required): Bytes per PDF for this project. - bytes_per_media (integer, required): Bytes per video or audio file (mp4, webm, mp3, m4a) for this project, when uploaded with an upload link. ### SiteDropInput Create a drop link: a page where the user uploads files and whole folders in a browser. - 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 (length 1..120)): 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/<folder>/. ### SiteDrop A drop link and the files the user uploaded with it. - id (string (uuid), required): The id of the drop link; pass it to get_drop (GET /sites/drops/{id}). - status ("open" | "done" | "expired", required): open: waiting for the user. done: the user uploaded the files. expired: 24 hours passed; create a new one. - site_id (string (uuid) or null, required): The site the link is for, or null. - created_at (string, required) - expires_at (string, required): When the link stops working (24 hours after it was made). - completed_at (string or null, required): When the user finished uploading, or null. - images_only (boolean, required): 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 ("location" | "all" | "none", required): 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 (string or null, required): The folder under /media/ the files land in, or null for the user's own folder names. - max_files (integer, required): How many files the user can add with this link (a photo with its versions counts once). - files (array of object, required): The files that arrived, in order. Private originals are not listed. - path (string, required): Where the file lives on the site, like /media/portfolio/project-12/03.webp. You can use another path in deploy_site; keep sha256 and bytes. - sha256 (string, required): The SHA-256 of the file. - bytes (integer, required): The size in bytes. - content_type (string, required): The type the file will be served with. - name (string): The file name on the user's computer. - variant ("2048" | "640"): For photos made web-ready in the browser: 2048 is the web version (longest side at most 2048 px), 640 the small version of the same photo (path ends in -640). Use the small one in srcset or for thumbnails. - width (integer): Width in pixels, for photos. - height (integer): Height in pixels, for photos. - originals_kept (integer, required): How many originals were kept. They are private: never on a site, only downloadable in the portal. - site_version (object or null, required): 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 (array of object, required): The same files in the form deploy_site takes: add them to files, without content. - path (string, required) - sha256 (string, required) - bytes (integer, required) - 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. ### MediaFile A file in the project's media library. - id (string (uuid), required): The id of the file in the library. - path (string, required): Where the file goes on a site when you add it with deploy_site media, like /media/portfolio/project-12/03.webp. - folder (string, required): The folder under /media/, empty for /media/ itself. - name (string, required): The file name in the library. - original_name (string or null, required): The name on the user's computer, when it differs. - content_type (string, required) - bytes (integer, required) - width (integer or null, required) - height (integer or null, required) - alt (string or null, required): The alt text, set with update_media. - url (string or null, required): 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 (map of object, required): For photos: 640, the small version for srcset and thumbnails. - path (string, required) - bytes (integer, required) - width (integer or null, required) - height (integer or null, required) - url (string or null, required): Where the small version is served, once it is on a site. - srcset (string or null, required): A ready srcset for an img tag, once both versions are on a site. - original_kept (boolean, required): The full-size original is kept privately. It never goes on a site; the user downloads it in the portal. - sites (array of object, required): The sites that use this file right now, with the path and url there. - site_id (string (uuid), required) - name (string, required) - path (string, required) - url (string, required) - source ("upload" | "portaal" | "site" | "import", required): upload: an upload link; portaal: the portal; site: it was already on a site; import: an import. - created_at (string, required) - updated_at (string, required) ### MediaList - data (array of MediaFile, required) - folders (array of object, required): Every folder in the library with its number of files (empty is /media/ itself). - folder (string, required) - files (integer, required) - next_cursor (string or null, required): Pass it as cursor for the next page; null when there is no more. ### MediaUpdate 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. - name (string (length 1..120)): A new file name. The extension always stays that of the file. - folder (string (length 0..120) or null): Move the file to this folder under /media/; null or an empty string for /media/ itself. - alt (string (length 0..500) or null): The alt text for the image; null removes it. ### MediaLinkInput Create an upload page for the media library. - folder (string (length 1..120)): 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. ### SiteFile One file of a site, without its content. - path (string, required): The path of the file on the site, like /index.html. - content_type (string, required): The type it is served with, like text/html. - bytes (integer, required): The size of the file in bytes. - updated_at (string (date-time), required): When the file was last written. ### SiteList - data (array of Site, required): The sites of the project, newest first. Deleted sites are not listed. ### DeletedSite A deleted site that can still be restored. - id (string (uuid), required): The id of the site. Send it to POST /sites/{id}/restore. - slug (string, required): The address it had and will get back: https://<slug>.hypamoon.app. It stays reserved for this project. - name (string, required): Your name for the site. - deleted_at (string (date-time), required): When the site was deleted. - restore_until (string (date-time), required): Until when the site can be restored. After that its files are erased for good. - test (boolean, required): true for a site of a test project. ### DeletedSiteList - data (array of DeletedSite, required): The deleted sites that can still be restored, most recently deleted first. ### RestoredSite The restored site, live again at its old address. - id (string (uuid), required): The id of the site. Use it to update or delete the site. - slug (string, required): The first part of the address: https://<slug>.hypamoon.app. - name (string, required): Your name for the site. - url (string, required): The public address of the site, live right away. - files (array of SiteFile, required): The files of the site, sorted by path, without their content. - created_at (string (date-time), required): When the site was created. - updated_at (string (date-time), required): When the site or its files last changed. - version (string, required): 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 (boolean, required): 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 (boolean, required): true for a site of a test project. It is public all the same. - taken_offline (object or null, required): 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 (array of object by code: "files_removed" | "build_hint"): 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 (array of string, required): What did not come back with the site (an app on it, a custom domain), and how to set it up again. ### CollectionInput A new collection. - name (string (pattern ^[a-z][a-z0-9_]{1,39}$), required): 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 (length 0..500)): 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. - public_write (boolean): Anyone with the project's public token may add documents from an allowed origin, after a bot check and within limits: a form on a website. Default false. - public_read (boolean): Anyone with the project's public token may read the newest documents (data and created_at only) from an allowed origin. Default false. - assistant_read (boolean): The assistant gets the newest documents (added through the API, never those from a form) in its chat instructions, 8 kB at most. Default false. - triggers (object): What happens when a document is added, through the API or a form. - email_on_insert (boolean): Email every new document to the project's billing email address (live projects only, not in test mode). Default false. - webhook_on_insert (boolean): Send the webhook document.created for every new document, to endpoints subscribed to it. Default false. - 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. - to (array of string (length 0..254) (0..5 items)): Where the email goes when no rule matches. Empty or left out: the billing email address. - rules (array of object (0..10 items)): Up to 10 rules on a field of the new document; the first that matches decides where the email goes. - field (string (length 1..64), required): A top-level field of the document, like vacancy or subject. - equals (string (length 0..200) or number or boolean, required): The value that field must have. Text matches without regard to case or surrounding spaces; for a list (checkboxes) one of its values must match. - to (array of string (length 0..254) (1..5 items), required): Where the email goes when this rule matches. - schema (map of any): 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. ### CollectionUpdate Change a collection. Fields you leave out stay as they are. - name (string (pattern ^[a-z][a-z0-9_]{1,39}$)): 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 (length 0..500) 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. - public_write (boolean): Anyone with the project's public token may add documents from an allowed origin, after a bot check and within limits: a form on a website. Default false. - public_read (boolean): Anyone with the project's public token may read the newest documents (data and created_at only) from an allowed origin. Default false. - assistant_read (boolean): The assistant gets the newest documents (added through the API, never those from a form) in its chat instructions, 8 kB at most. Default false. - triggers (object): What happens when a document is added, through the API or a form. - email_on_insert (boolean): Email every new document to the project's billing email address (live projects only, not in test mode). Default false. - webhook_on_insert (boolean): Send the webhook document.created for every new document, to endpoints subscribed to it. Default false. - 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 (map of any 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. ### Collection A collection of JSON documents: the data layer of a project. - id (string (uuid), required): The id of the collection. Its name works in the path as well. - name (string, required): The name, unique in the project. A form posts to it by this name. - description (string or null, required): What the collection is for, or null. - access (object, required): Who may use the collection besides your API key. All false is private. - public_write (boolean, required): A form on an allowed origin may add documents, with the public token, a bot check and limits. - public_read (boolean, required): A page on an allowed origin may read the newest documents (data and created_at) with the public token. - assistant_read (boolean, required): The assistant gets the newest documents added through the API in its chat instructions. - triggers (object, required) - email_on_insert (boolean, required): Every new document is emailed to the project's billing email address (live only). - webhook_on_insert (boolean, required): Every new document sends the webhook document.created. - email_routing (object or null, required): Where the email of email_on_insert goes. null: the billing email address. - schema (map of any or null, required): 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 (integer, required): How many documents the collection has now. - created_at (string (date-time), required): When the collection was created. - updated_at (string (date-time), required): When the collection last changed. - test (boolean, required): true for a collection of a test project. ### CollectionList - data (array of Collection, required): The collections of the project, newest first. Deleted collections are not listed. ### DocumentInput A new document. - data (map of any, required): The document: any JSON object, at most 64 kB. ### DocumentUpdate Change a document: send data to replace it, or merge to change some fields. Not both. - data (map of any): The document: any JSON object, at most 64 kB. - merge (map of any): 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. ### Document A document in a collection. - id (string (uuid), required): The id of the document. - collection_id (string (uuid), required): The id of its collection. - data (map of any, required): The document itself: a JSON object. - source ("api" | "public", required): api: added with an API key. public: sent from a form on a website. - created_at (string (date-time), required): When the document was added. - updated_at (string (date-time), required): When the document last changed. - test (boolean, required): true for a document of a test project. No email was sent for it. ### DocumentList - data (array of Document, required): The documents, newest first. - has_more (boolean, required): Whether there is another page. - next_cursor (string or null, required): Pass this as cursor to get the next page. ### PublicDocumentInput A document from a form on a website. - data (map of any, required): The fields of the form as a JSON object, like { "name": "Anna", "email": "anna@example.com", "message": "..." }. - turnstile_token (string (length 0..2048)): The bot-check token from the challenge frame (GET /public/challenge). Required; form.js does this for you. - honeypot (string (length 0..1000)): The value of a hidden field that people leave empty. When it is filled in, the answer is 201 but nothing is stored. ### PublicDocumentCreated A form entry was stored. The data is not sent back. - id (string (uuid), required): The id of the new document. - created_at (string (date-time), required): When it was stored. - test (boolean, required): true when the public token is a test token: stored, but no email is sent. ### PublicDocumentList - data (array of object, required): The newest documents, newest first: only data and created_at. - data (map of any, required) - created_at (string (date-time), required): When the document was added. ### PublicChatConfig What the chat widget needs before a conversation starts. - name (string, required): The name of the project, as the widget shows it. - greeting (string, required): The fixed greeting, in the language the assistant opens in. - language (string, required): The language the assistant opens in (nl, en, de, fr or es). - languages (array of string, required): The languages of the assistant. - mode ("live" | "test", required): live or test, from the token. - turnstile (boolean, required): Whether POST /public/conversations needs a turnstile_token. - max_message_length (integer, required): The longest message a visitor can send, in characters. - max_turns (integer, required): After this many turns the conversation closes. ### PublicConversationInput Start a conversation from the chat widget. - turnstile_token (string (length 0..2048)): The bot-check token from the challenge frame. Required when the public token has turnstile on (the default); the widget does this for you. ### 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}. - id (string (uuid), required): The id of the conversation. - channel ("widget", required): Always widget: started through a public chat token. - status ("open" | "closed", required): open: takes messages. closed: takes no more messages. - language (string, required): The language the assistant opened in. - turns (integer, required): Visitor messages answered so far. - max_turns (integer, required): After this many turns the conversation closes (close_reason turn_limit). - created_at (string (date-time), required): When the conversation started. - last_message_at (string (date-time) or null, required): When the last message was sent. - closed_at (string (date-time) or null, required): When the conversation closed. - close_reason ("closed" | "inactive" | "turn_limit" | null, required): Why it closed: inactive after 30 minutes without a message, or turn_limit. - test (boolean, required): true for a conversation through a test token (hm_pubtest_). ### PublicConversationStart A new conversation from the widget, with its secret. - conversation (PublicConversation, required): 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 (array of ChatMessage, required): The fixed greeting, as the first message. - conversation_token (string, required): 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 (string (date-time), required): When conversation_token stops working (6 hours after the start). ### PublicConversationDetail The visitor's own conversation with its messages. - conversation (PublicConversation, required): 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 (array of ChatMessage, required): All messages, oldest first, starting with the greeting. ### PublicChatTurn One turn in the widget: the visitor's message and the assistant's reply. - message (object, required): The visitor's message as stored. - id (string (uuid), required): The id of the message. - role ("customer" | "assistant", required): Who wrote it: the customer, or the assistant (also the fixed greeting). - text (string, required): The text. - created_at (string (date-time), required): When the message was stored. - reply (object, required): The assistant's reply. Show this text. - id (string (uuid), required): The id of the message. - role ("customer" | "assistant", required): Who wrote it: the customer, or the assistant (also the fixed greeting). - text (string, required): The text. - created_at (string (date-time), required): When the message was stored. - conversation (object, required): The conversation after this turn. status closed means the turn limit was reached. - id (string (uuid), required): The id of the conversation. - channel ("widget", required): Always widget: started through a public chat token. - status ("open" | "closed", required): open: takes messages. closed: takes no more messages. - language (string, required): The language the assistant opened in. - turns (integer, required): Visitor messages answered so far. - max_turns (integer, required): After this many turns the conversation closes (close_reason turn_limit). - created_at (string (date-time), required): When the conversation started. - last_message_at (string (date-time) or null, required): When the last message was sent. - closed_at (string (date-time) or null, required): When the conversation closed. - close_reason ("closed" | "inactive" | "turn_limit" | null, required): Why it closed: inactive after 30 minutes without a message, or turn_limit. - test (boolean, required): true for a conversation through a test token (hm_pubtest_). ### Balance The prepaid balance with the latest transactions. - balance_eur (number, required): The prepaid balance in euro, excluding VAT. Can be slightly below zero after a call that ran on. - currency ("EUR", required): Always EUR. - spent_this_month_eur (number, required): What was deducted this calendar month (minus refunds). Counts toward the spending cap. - spend_cap_eur (number or null, required): The spending cap per calendar month, or null without one. - auto_reload (AutoReload, required): Automatic top-up with a saved card. - transactions (array of Transaction, required): The latest 20 transactions, newest first. - test (boolean, required): true with a test key: you see the balance of the live project, read-only. Test mode is free and never touches the balance. ### Transaction One line in the ledger of the prepaid balance. The same shape in GET /balance and the balance webhooks. - id (string, required): The id of the transaction. - type ("top_up" | "charge" | "refund" | "adjustment", required): 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. - source ("payment" | "sms" | "call" | "number" | "chat" | "site" | "support", required): 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. - amount_eur (number, required): The amount in euro excluding VAT, with a sign: positive for a top-up or refund, negative for a charge. Up to four decimals. - balance_after_eur (number, required): The balance right after this transaction, in euro excluding VAT. - description (string, required): What it was for, in a few words. - quantity (number or null, required): How many units, like 2 SMS segments or 3 minutes. Null for a top-up. - unit (string or null, required): The unit of quantity, like segment, minute or month. Null for a top-up. - reference_id (string or null, required): The id of what caused it: a message, a call, a number or a payment. - created_at (string (date-time), required): When the transaction was booked. ### AutoReload Automatic top-up with a saved card. - enabled (boolean, required): Whether automatic top-up is switched on. Change it in the portal. - active (boolean, required): Whether it can run now: switched on, with a saved card that has not expired. - threshold_eur (number, required): When the balance drops below this amount, a top-up starts. - amount_eur (number, required): How much each automatic top-up adds, excluding VAT. - max_per_month (integer, required): The most automatic top-ups per calendar month. - used_this_month (integer, required): How many automatic top-ups ran this calendar month. - disabled_reason (string or null, required): Why it is off or cannot run: no_card, card_expired, repeated_failures or turned_off. Null when it is active. - card (object or null, required): The saved card used for automatic top-up, or null without one. ### Reload An automatic top-up that failed. - id (string, required): The id of the top-up payment. - amount_eur (number, required): The amount it tried to add, excluding VAT. - status (string, required): The status of the payment, like failed, canceled or expired. - failure_reason (string or null, required): A short code for why it failed, or null. - created_at (string (date-time), required): When the top-up started. ### BalanceLowEvent Sent when a charge takes the balance below the automatic top-up threshold. Carries balance_eur and threshold_eur. - id (string, required): The id of the event, evt_ plus 32 hex characters. It stays the same on retries. - type ("balance.low", required) - created_at (string (date-time), required): When the event happened. - data (object, required) - balance_eur (number, required): The balance at the moment of the event, in euro excluding VAT. - threshold_eur (number): The threshold for automatic top-up. With balance.low and balance.reload_failed. - transaction (Transaction): With balance.topped_up: the top-up in the ledger. - reload (Reload): With balance.reload_failed: the failed top-up. ### BalanceToppedUpEvent Sent when a top-up was paid and added to the balance, by hand or automatically. Carries balance_eur and transaction. - id (string, required): The id of the event, evt_ plus 32 hex characters. It stays the same on retries. - type ("balance.topped_up", required) - created_at (string (date-time), required): When the event happened. - data (object, required) - balance_eur (number, required): The balance at the moment of the event, in euro excluding VAT. - threshold_eur (number): The threshold for automatic top-up. With balance.low and balance.reload_failed. - transaction (Transaction): With balance.topped_up: the top-up in the ledger. - reload (Reload): With balance.reload_failed: the failed top-up. ### BalanceReloadFailedEvent 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. - id (string, required): The id of the event, evt_ plus 32 hex characters. It stays the same on retries. - type ("balance.reload_failed", required) - created_at (string (date-time), required): When the event happened. - data (object, required) - balance_eur (number, required): The balance at the moment of the event, in euro excluding VAT. - threshold_eur (number): The threshold for automatic top-up. With balance.low and balance.reload_failed. - transaction (Transaction): With balance.topped_up: the top-up in the ledger. - reload (Reload): With balance.reload_failed: the failed top-up. ### DocumentCreatedEvent 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. - id (string, required): The id of the event, evt_ plus 32 hex characters. It stays the same on retries. - type ("document.created", required) - created_at (string (date-time), required): When the event happened. - data (object, required) - collection (object, required): The collection the document was added to. - id (string (uuid), required) - name (string, required) - document (object, required) - id (string (uuid), required) - data (map of any, required): The document as it is when the webhook is sent. - source ("api" | "public", required): api or public (a form). - created_at (string (date-time), required): When the document was added. ### Problem An error, as application/problem+json (RFC 9457). - type (string (uri), required): A URL that explains the error and the fix: https://hypamoon.com/docs/errors/<slug>. - title (string, required): The HTTP status in words. - status (integer, required): The HTTP status. - detail (string, required): What went wrong in this request. - request_id (string, required): The id of this request, also in the X-Request-Id header. Mention it when you contact support. - user_message (string, required): One calm sentence in plain English for the end user, without technical terms. A chat or app can show it as it is. - next_step (string): With some errors: what the chat or agent itself should do next. - errors (array of FieldError): With validation_failed, schema_mismatch and team_targets_not_allowed: every problem. - missing (array of string): With company_details_missing: the missing fields, as paths of PUT /project/company. - current_version (integer or string): 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). - spend_cap_eur (number): With billing_blocked: the spending cap per calendar month. - spent_eur (number): With billing_blocked: what was spent this calendar month. - balance_eur (number): With insufficient_balance: the prepaid balance now. - required_eur (number): With insufficient_balance: what this request costs. - auto_reload_started (boolean): With insufficient_balance: true when an automatic top-up just started; retry in a minute. ### FieldError - path (string, required): Where the problem is, with dots, like address.postal_code. body means the body as a whole. - message (string, required): What is wrong, in English. ### RecentError - id (string, required) - at (string, required) - source ("mcp" | "rest", required) - client (string, required) - tool (string or null, required) - route (string or null, required) - error (string, required) - status (integer, required) - detail (string, required) - duration_ms (integer or null, required) - request_id (string, required) - mode (string or null, required) - arguments (map of any or null, required) - field_errors (array of object or null, required) ### RecentErrorList - data (array of RecentError, required) - next_before (string or null, required) - dropped_today (integer, required) ## Webhooks ### call.completed A call ended. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: CallCompletedEvent. Example: ```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 } } } ``` ### call.summary.ready The summary of a call is ready. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: CallSummaryReadyEvent. Example: ```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." } } } ``` ### message.delivered An SMS was delivered. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: MessageDeliveredEvent. Example: ```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 } } } ``` ### message.failed An SMS could not be delivered. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: MessageFailedEvent. Example: ```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 } } } ``` ### conversation.summary.ready The summary of a chat conversation is ready. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: ConversationSummaryReadyEvent. Example: ```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." } } } ``` ### balance.low The balance dropped below the top-up threshold. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: BalanceLowEvent. Example: ```json { "id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3", "created_at": "2026-10-01T11:00:01.000Z", "type": "balance.low", "data": { "balance_eur": 4.88, "threshold_eur": 5 } } ``` ### balance.topped_up A top-up was added to the balance. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: BalanceToppedUpEvent. Example: ```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" } } } ``` ### balance.reload_failed An automatic top-up failed. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: BalanceReloadFailedEvent. Example: ```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" } } } ``` ### document.created A document was added to a collection. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: DocumentCreatedEvent. Example: ```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" } } } ``` ### email.sent An email was accepted for delivery (one event per recipient). We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: EmailSentEvent. Example: ```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 } } ``` ### email.delivered An email reached the recipient's mail server. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: EmailDeliveredEvent. Example: ```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 } } ``` ### email.delayed Delivery of an email is delayed; it is retried automatically. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: EmailDelayedEvent. Example: ```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 } } ``` ### email.bounced An email bounced. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: EmailBouncedEvent. Example: ```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 } } ``` ### email.complained The recipient marked an email as spam. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: EmailComplainedEvent. Example: ```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 } } ``` ### webhook.test A test event from POST /webhooks/{id}/test. We POST this event to every active endpoint subscribed to it (webhook.test only to the endpoint you test). Respond with any 2xx within ten seconds; anything else is retried with backoff for up to 24 hours, in eight attempts. The event id stays the same on retries, so you can skip duplicates. Header Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body before you parse the JSON, and reject a t more than five minutes from your clock. Body: WebhookTestEvent. Example: ```json { "id": "evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3", "created_at": "2026-10-01T09:15:38.000Z", "type": "webhook.test", "data": { "endpoint_id": "9e8d7c6b-5a49-4382-9716-a5b4c3d2e1f0" } } ``` ## Errors Every error is application/problem+json with type, title, status, detail and request_id, and sometimes errors, missing or current_version (see Problem). type is a URL whose last part is the slug below; the page at that URL explains the fix. ### bad_request Status 400. https://hypamoon.com/docs/errors/bad_request **400.** The request could not be understood. Read `detail`; it names the part that is wrong. Fix the request and send it again. ### unauthorized Status 401. https://hypamoon.com/docs/errors/unauthorized **401.** The request has no valid API key or OAuth access token. See the more specific errors below. With OAuth (an assistant such as Claude.ai or ChatGPT connected by URL), this means the access token expired or was revoked: refresh it with the refresh token, or reconnect Hypamoon in the assistant. ### payment_required Status 402. https://hypamoon.com/docs/errors/payment_required **402.** Billing blocks this request. See `payment_mandate_missing`, `billing_blocked`, `insufficient_balance` and `voice_ai_not_enabled`. ### forbidden Status 403. https://hypamoon.com/docs/errors/forbidden **403.** The key is valid but is not allowed to do this. See the more specific errors below. ### not_found Status 404. https://hypamoon.com/docs/errors/not_found **404.** The resource in the URL does not exist, or it belongs to another project. We do not say which: an id from another project looks exactly like one that never existed. Check the id you used. A path that is not an endpoint at all (a typo, like `/api/v1/call`) gives the same error; `detail` names the path, and `GET /api/v1/openapi.json` lists every endpoint. ### method_not_allowed Status 405. https://hypamoon.com/docs/errors/method_not_allowed **405.** The path exists, but not with this method, for example `DELETE /api/v1/project`. The `Allow` header lists the methods it accepts, and `detail` says which one to use. A path that does not exist at all gives `not_found`. ### conflict Status 409. https://hypamoon.com/docs/errors/conflict **409.** The request clashes with the current state. Read `detail`. A domain route also gives `diagnosis` (what exactly is in the way) and `fix` (one suggested call or portal link); `diagnose_domain` checks the whole domain. ### version_conflict Status 409 or 412. https://hypamoon.com/docs/errors/version_conflict **409 or 412.** Someone changed this resource after you read it. For a call plan: `GET` it again, apply your change to what you get back, and `PUT` it with `If-Match` set to the new `ETag`. `current_version` holds the version that is there now. Without `If-Match` you get 409; with an outdated `If-Match`, 412. ### confirmation_required Status 409. https://hypamoon.com/docs/errors/confirmation_required **409.** The change would replace or delete something that is live now (a DNS record on an active domain, an old record on a subdomain, a nameserver switch that does not match yet). Nothing was changed. The error holds `preflight` (what changes, what is live there now, whether email is affected), a `diagnosis`, and `fix`: the same call with the confirmation flag set (`confirm_overwrite`, `replace_existing`, `ignore_preflight` or `confirm`). Show the preflight to the user and only send `fix` after they agree. Most of these changes can be reversed for 30 days with `undo_dns_change`. ### validation_failed Status 422. https://hypamoon.com/docs/errors/validation_failed **422.** The body is JSON, but some fields are wrong or missing. `errors` lists every problem as `{ path, message }`, where `path` uses dots (`address.postal_code`, `nodes.team.targets.0.e164`). Fix all of them and send the whole request again. A path of `body` means the problem is the body as a whole, for example a field we do not know. ### rate_limited Status 429. https://hypamoon.com/docs/errors/rate_limited **429.** Too many requests: the limit is 120 per minute per key. Number search (`GET /api/v1/numbers/available`) has its own limit of 20 per minute per project, and sending SMS (`POST /api/v1/messages`) one of 30 per minute per project. Chat has two more: 20 new conversations per minute per project (`POST /api/v1/conversations`) and 12 messages per minute per conversation (`POST /api/v1/conversations/{id}/messages`). The chat widget has its own limits per IP address and per public chat token on the `/api/v1/public` routes (see the recipe "Add a chat widget to your website" in the quickstart). Wait the number of seconds in `Retry-After`, then continue. Search results stay the same for 60 seconds, so reuse them instead of searching again. For outbound calls (`POST /api/v1/calls`) a project can start at most 5 calls per minute. Nobody was called; wait the seconds in `Retry-After` and send the same request again with the same `Idempotency-Key`. The limits per day and on calls at the same time have their own errors: `call_limit_reached` and `concurrent_call_limit`. ### internal_error Status 500. https://hypamoon.com/docs/errors/internal_error **500.** Something went wrong on our side. Unless `detail` says otherwise, nothing was changed and you can retry. If `detail` says the number was ordered but could not be saved, do not order again: contact support with the `request_id`. The same goes for a call: if `detail` says the call was started, do not call again; look for it in `GET /api/v1/calls`. ### upstream_error Status 502. https://hypamoon.com/docs/errors/upstream_error **502.** The carrier refused the request, or did not answer a search. Nothing was ordered or sent, and nobody was called. Try again in a moment, or pick another number. When an order itself gets no clear answer you get `order_status_unknown` instead. ### service_unavailable Status 503. https://hypamoon.com/docs/errors/service_unavailable **503.** This part of the API is temporarily unavailable. Try again later. It can also happen on any route, including when you order a number, if we briefly cannot read the project's verification or its type while checking your key or OAuth token: we return 503 rather than let the request through unchecked. Nothing was done; wait the seconds in `Retry-After` and send the same request again (for an order, with the same `Idempotency-Key`). ### invalid_json Status 400. https://hypamoon.com/docs/errors/invalid_json **400.** The body is not valid JSON. Send `Content-Type: application/json` and a JSON body. A form (`a=b&c=d`) or an empty body gives this error. ### invalid_parameter Status 400. https://hypamoon.com/docs/errors/invalid_parameter **400.** A query parameter or header has a value we cannot use, for example `limit=500` on `GET /api/v1/calls`, a `cursor` you did not get from us, or an `If-Match` that is not a version. `detail` names the parameter and the allowed values. ### missing_api_key Status 401. https://hypamoon.com/docs/errors/missing_api_key **401.** There is no `Authorization` header. Send `Authorization: Bearer hm_test_...` (or `hm_live_...`) with every request. With OAuth, send the access token the same way: `Authorization: Bearer hm_oat_...`. ### invalid_api_key Status 401. https://hypamoon.com/docs/errors/invalid_api_key **401.** We do not recognize this key. Check that you copied all of it and that it starts with `hm_test_` or `hm_live_`. Keys cannot be recovered; if it is lost, create a new one in the portal at https://app.hypamoon.com/api-sleutels. ### api_key_revoked Status 401. https://hypamoon.com/docs/errors/api_key_revoked **401.** This key was revoked. Use another key of the project, or create a new one in the portal at https://app.hypamoon.com/api-sleutels. ### project_deleted Status 401. https://hypamoon.com/docs/errors/project_deleted **401.** The project of this key no longer exists. Its keys stopped working along with it. A test project is tied to its live project: when the live project is deleted, its test keys stop working too. ### project_not_enabled Status 403. https://hypamoon.com/docs/errors/project_not_enabled **403.** The key belongs to an account that is not an API project. Only API projects can use `/api/v1`. Sign up for a project yourself at https://app.hypamoon.com; you get a test project and a test key right away. ### missing_scope Status 403. https://hypamoon.com/docs/errors/missing_scope **403.** The key lacks the scope this route needs; `detail` names it. The scopes are `project:read`, `project:write`, `numbers:read`, `numbers:write`, `assistant:write`, `webhooks:write`, `calls:read`, `messages:read`, `messages:write`, `chat:read` and `chat:write`. Create a key with the scope you need in the portal at https://app.hypamoon.com/api-sleutels. With OAuth, reconnect Hypamoon in the assistant and select that scope on the consent screen. ### invalid_chat_token Status 401. https://hypamoon.com/docs/errors/invalid_chat_token **401.** A `/api/v1/public` route of the chat widget got no public chat token, one that does not look like `hm_pub_...` or `hm_pubtest_...`, or one we do not know. Copy the `embed_code` of the token again from the portal or from `GET /api/v1/chat-tokens`. A `hm_pubtest_` token belongs to the test project and a `hm_pub_` token to the live one. Never put your API key (`hm_live_`, `hm_test_`) in a web page instead: it is a server-side secret. ### chat_token_revoked Status 401 or 409. https://hypamoon.com/docs/errors/chat_token_revoked **401 or 409.** The public chat token was revoked. On a `/api/v1/public` route (401) the widget stops: no new conversations and no more messages in open ones. Create a new token with `POST /api/v1/chat-tokens` or in the portal and replace the snippet on your website. `PATCH /api/v1/chat-tokens/{id}` on a revoked token gives 409: a revoked token cannot be changed or switched back on. ### origin_not_allowed Status 403. https://hypamoon.com/docs/errors/origin_not_allowed **403.** The page that called a `/api/v1/public` route is not on the list of allowed origins of the public chat token, or the request had no `Origin` header. The origin must match exactly: scheme and host, like `https://www.example.com`. `https://example.com` does not cover `https://www.example.com`; list both if your site answers on both. Add it with `PATCH /api/v1/chat-tokens/{id}` or in the portal. A test token (`hm_pubtest_`) also works from `localhost`. A request from a server without an `Origin` header is not what these routes are for: from your backend, use your API key with `/api/v1/conversations`. ### turnstile_failed Status 403. https://hypamoon.com/docs/errors/turnstile_failed **403.** The check that the visitor is a person failed, expired or was missing when the widget started a conversation. The widget shows the check again by itself. If it keeps failing for real visitors, check that nothing on your page blocks the frame from `api.hypamoon.com` (a Content-Security-Policy with `frame-src`, an ad blocker). You can turn the check off per token (`turnstile: false`), but then only the limits stand between your balance and a bot. ### invalid_conversation_token Status 401. https://hypamoon.com/docs/errors/invalid_conversation_token **401.** A request about a conversation of the chat widget had no `X-Conversation-Token`, a wrong one, or one older than 6 hours. That secret comes with `POST /api/v1/public/conversations` and is only valid for that one conversation and that public chat token. The widget then starts a new conversation. A conversation started with your API key can never be read or continued through a public route. ### public_chat_limit_reached Status 429. https://hypamoon.com/docs/errors/public_chat_limit_reached **429.** The chat widget reached a daily limit: the number of new conversations per day of this public chat token (default 200, up to 1000, set per token), or the daily spending limit of the project's public chat (default 5 euro of replies per day, up to 1000, set in the portal; 0 turns the live widget off). The count resets at midnight, Europe/Amsterdam. Raise the limit if your site really needs more; a sudden jump is often a bot, so look at the conversations in the portal first. Per minute and per hour there are separate limits, which give `rate_limited` with a `Retry-After` header. ### chat_unavailable Status 503. https://hypamoon.com/docs/errors/chat_unavailable **503.** The chat widget cannot chat right now, and the visitor is not told why. Behind it is a reason for you, not for your visitor: the balance is empty, the spending cap is reached, the company verification is missing or was refused, the assistant is not set up, or a check could not be done for a moment. The portal shows the real reason, and so does a conversation you start with your API key (`POST /api/v1/conversations`): that gives the specific error. Fix that and the widget works again; for a temporary cause, wait the seconds in `Retry-After`. ### vat_number_invalid Status 422. https://hypamoon.com/docs/errors/vat_number_invalid **422.** `PUT /api/v1/project/company` for a company in the EU outside the Netherlands, and VIES (the VAT number check of the European Commission) says the `vat_number` is not valid. Nothing was saved. Without a valid EU VAT number we cannot reverse charge the VAT, so the company verification level stays closed. Ask the user for the VAT number as it appears on their VAT registration, with or without the country prefix (`DE123456789` or `123456789`), and send the request again. When VIES itself does not answer, the request succeeds with `vat_status: "unverified"` and we check again automatically. ### country_not_supported Status 422. https://hypamoon.com/docs/errors/country_not_supported **422.** `PUT /api/v1/project/company` with an `address.country` where Hypamoon does not provide its services, because of sanctions that apply to us or to our telecom and payment providers: Russia (`RU`), Belarus (`BY`), North Korea (`KP`), Iran (`IR`), Syria (`SY`) and Cuba (`CU`). Nothing was saved. This is not something you can fix in a request; contact Hypamoon support if you think it is wrong. Also **422** when a personal account outside the Netherlands orders a number. Numbers and calls are available in the Netherlands now; other countries follow. The error then carries `user_message` (show it to the user as it is) and `waitlist_url`, a link to the waitlist with the user's country filled in. We email them once when their country opens. ### key_mode_mismatch Status 403. https://hypamoon.com/docs/errors/key_mode_mismatch **403.** The key and its project are in different modes: a test key (`hm_test_`) on a live project, or a live key (`hm_live_`) on a test project. Every project comes as a pair: a test project for building and a live project for real calls, each with its own keys. Use the test key while you build and switch to the live key when you go live. ### test_key_required Status 403. https://hypamoon.com/docs/errors/test_key_required **403.** This route only exists in test mode, for example `POST /api/v1/test/calls`. Send it with your test key (`hm_test_...`). In live mode real calls come in on your number instead. ### test_number_limit_reached Status 422. https://hypamoon.com/docs/errors/test_number_limit_reached **422.** A test project has at most a few test numbers. Reuse one you already have (`GET /api/v1/numbers`); simulated calls work on any of them. ### company_details_missing Status 422. https://hypamoon.com/docs/errors/company_details_missing **422.** A phone number is registered to a business or a person, and the project's details are incomplete. `missing` lists the fields, as paths of `PUT /api/v1/project/company` (`registration_number`, `contact.name`; for a company in the Netherlands the registration number is `kvk_number`). Send that request with all fields, then order again. For a company in the EU outside the Netherlands, `vat_number` is listed until VIES has confirmed it as valid; for a company outside the EU, `registration_number` is listed until it is set. You do not need a company: a personal account (`account_type: "personal"`) needs only the user's own name and phone number as the contact and the user's address. If a personal account is not available for the country of that address yet, `validation_failed` says so. In test mode no registration or VAT number is ever listed. ### business_required Status 422. https://hypamoon.com/docs/errors/business_required **422.** A personal account asked for a national number. The telecom registration of a national number asks for the full legal company name as registered in the business register, so it needs a business. Nothing was ordered. Either order a number with a local area code, which a personal account can have after uploading proof of address and an ID in the Hypamoon portal under Verification, or add business details with `PUT /api/v1/project/company` (`account_type: "business"`, `company_name` and the registration number) and order again. ### account_type_change_blocked Status 422. https://hypamoon.com/docs/errors/account_type_change_blocked **422.** `PUT /api/v1/project/company` with `account_type: "personal"` for a project that has a national number. That number is registered to a business, so the project cannot become a personal account while it has it. Nothing was saved. Keep `account_type: "business"`, or ask the user to contact Hypamoon support to release the national number first. A change of account type in the other direction is allowed. Every change sets the verification levels `number_documents` and `full` back to `not_started` (a rejection stays), because an approval belonged to the old account type. ### spend_cap_missing Status 422. https://hypamoon.com/docs/errors/spend_cap_missing **422.** The project has no spending cap. Every project needs one before it can order a number, so a mistake in your code can never run up an open-ended bill. Set it yourself in the portal, under Billing (https://app.hypamoon.com/instellingen/facturen), then order again. ### payment_mandate_missing Status 402. https://hypamoon.com/docs/errors/payment_mandate_missing **402.** The project has no way to pay yet: no saved card and no prepaid balance. A live project pays in advance from its balance, and ordering a number or sending an SMS costs money. Top up your balance in the portal (https://app.hypamoon.com/tegoed), or save a card there for automatic top-up. Then send the same request again; the same `Idempotency-Key` works. ### billing_blocked Status 402. https://hypamoon.com/docs/errors/billing_blocked **402.** This month's spending cap is reached. Everything deducted from your balance this calendar month counts toward the cap: calls (inbound and outbound), numbers and SMS. For `POST /messages` and `POST /numbers` the price of the request itself counts too, and so does the reply to a chat message: when this month's spending plus this request would pass the cap, nothing is sent or ordered and the assistant does not answer. The body carries `spend_cap_eur` and `spent_eur`. Raise the cap under Billing in the portal (https://app.hypamoon.com/instellingen/facturen) or wait until next month. A balance does not lift the cap: the cap is there so a mistake in your code can never spend more than you planned. ### insufficient_balance Status 402. https://hypamoon.com/docs/errors/insufficient_balance **402.** Your prepaid balance is too low for this request: sending this SMS, ordering this number, answering this chat message or starting this outbound call would take the balance below zero, so nothing was sent, ordered, answered or called. The body carries `balance_eur` (your balance now), `required_eur` (what this request costs) and `auto_reload_started` (`true` when an automatic top-up with your saved card has just started). When `auto_reload_started` is `true`, retry in a minute. Otherwise top up your balance in the portal (https://app.hypamoon.com/tegoed), or save a card there so the balance is topped up automatically when it runs low. `GET /api/v1/balance` shows your balance and the latest transactions. Test mode is free and never returns this error. ### regulatory_documents_missing Status 422. https://hypamoon.com/docs/errors/regulatory_documents_missing **422.** A number with a local area code (`type: "geographic"`) needs documents: proof of address, a copy of an ID and, for a business, an extract from the business register (a personal account needs no extract). That is the `number_documents` verification level. Upload them in the Hypamoon portal under Verification; they cannot be uploaded through the API. `missing` lists what is still needed, with the same codes as `verification` in `GET /api/v1/project`. A business can use a national number instead (non-geographic, it works across the country). ### verification_required Status 403. https://hypamoon.com/docs/errors/verification_required **403.** This needs a verification level the project does not have yet. The first time a project uses a function with real-world effects (a phone number, outbound calls, SMS, email to other people, payments, buying a domain), this error carries `function` (phone, outbound, sms, email, payments or domains), `missing`, `next_step` and `verify_url`: a link to exactly the step in the Hypamoon portal that is still open. Nothing was done or charged. Once the user has finished it, send the same request again with the same arguments; from then on that function is real for the project. For a number or SMS, the error can also say more: `required_level` names the level (`number_documents` for a number with a local area code, and for SMS from a personal account; `company` with `missing: ["kvk_check"]` for a national number or SMS from a business that has not passed the business register check), `verification_status` says where it stands (`submitted`, `in_review` or `rejected`), `reason` says why we rejected it, and `missing` lists what is still needed. See `verification` in `GET /api/v1/project`. When we are still reviewing, try again later; when we rejected it, fix what `reason` says in the Hypamoon portal under Verification. A business gets a national number and sends SMS once it has passed a business register check (`kvk_check`): the user picks the company from the register under Settings, Company in the Hypamoon portal, or, if it is not listed, our team checks the registration number by hand. A personal account sends SMS and gets a number with a local area code once our team has approved its identity documents (proof of address and an ID); uploading them is not enough. `next_step` in the error says what to tell the user. The `company` level (and `personal` for a personal account) also asks for two things besides a balance or a saved card. A saved card or a balance alone is not enough: - `phone_verification`: a verified phone number on the account. Verify your phone at https://app.hypamoon.com/verify-phone by calling us once from your own phone; `verify_url` is in the error. - `payment_authentication`: one payment confirmed with your bank. Complete card authentication by topping up at https://app.hypamoon.com/tegoed with iDEAL, or with a card that completes 3D Secure at your bank. Until both are there, a live key, ordering a number, outbound calls, text messages and buying a domain return this error with `required_level` `company` and the missing items in `missing`. Test mode needs neither. For outbound calls (`POST /api/v1/calls`) with a live key, `required_level` is `full`: we check by hand who the project calls and why before its assistant calls real people. Request it in the Hypamoon portal under Verification (https://app.hypamoon.com/verificatie); it cannot be requested through the API. Until it is approved, nobody is called. Test mode needs no verification: build and test with a test key and test numbers in the meantime. With `required_level` `company` and `verification_status` `rejected`, we rejected the company behind a live project. Then every live request gets this error, on every route, and no number can be ordered; the project's live keys and OAuth connections were revoked when we rejected it. Test mode keeps working. Fix what `reason` says in the portal at https://app.hypamoon.com/verificatie; once the company is approved again, create a new live key (or reconnect with OAuth). ### number_unavailable Status 409. https://hypamoon.com/docs/errors/number_unavailable **409.** Someone else got this number between your search and your order. Search again with `GET /api/v1/numbers/available` and pick another one, or order with an `area` as that search returns it and let us pick. ### number_already_yours Status 409. https://hypamoon.com/docs/errors/number_already_yours **409.** This number is already in your project. Find it with `GET /api/v1/numbers`. If you sent the order twice, use the same `Idempotency-Key` for retries: then you get the original answer instead of this error. ### no_numbers_available Status 422. https://hypamoon.com/docs/errors/no_numbers_available **422.** There are no free numbers for this area right now. Try another area code, or try again later. In test mode it means the shared pool of test numbers is used up for the moment; reuse a test number you already have. ### number_limit_reached Status 403. https://hypamoon.com/docs/errors/number_limit_reached **403.** During the beta a live project has at most three numbers (released numbers do not count). Reuse a number you already have (`GET /api/v1/numbers`), or contact Hypamoon support if you need more. Nothing was ordered, and the same `Idempotency-Key` works again once there is room. ### order_status_unknown Status 502. https://hypamoon.com/docs/errors/order_status_unknown **502.** The carrier did not answer the order in time, or answered with an error on its side. The number may have been ordered. Retry with the same `Idempotency-Key` and the same body: we then check with the carrier. If the number was ordered, you get it (`201`); if not, we order it then. Never send the order again with a new key, or you may get a second number. ### number_release_not_supported Status 409. https://hypamoon.com/docs/errors/number_release_not_supported **409.** `DELETE /api/v1/numbers/{id}` only releases test numbers, with a test key. Releasing a live number cancels it at the carrier and stops its monthly fee, and the number is gone for good, so that is not possible through the API. Ask Hypamoon support at hello@hypamoon.com. The number stays in your project until then. ### telephony_unavailable Status 503. https://hypamoon.com/docs/errors/telephony_unavailable **503.** Searching or ordering numbers, sending SMS or making an outbound call is not available at the moment. Nothing was ordered or sent, and nobody was called. Try again later; the same `Idempotency-Key` works. For `POST /api/v1/calls` with a live key it can also mean that live outbound calling is not switched on yet: we turn it on step by step. Test mode works in the meantime. With `reason: "outbound_paused"` outbound calls and SMS are paused: for the platform, for one country, or for this project. `detail` says which. When the project is blocked after a site was taken offline for abuse (see `site_offline`), inbound calls keep working and nothing goes out until we lift the block. ### idempotency_key_missing Status 400. https://hypamoon.com/docs/errors/idempotency_key_missing **400.** `POST /api/v1/numbers` and `POST /api/v1/calls` need an `Idempotency-Key` header: 1 to 255 visible characters without spaces, for example a UUID. Create one for every new order or call (like `appointment-8812-confirm`), store it with the order or call, and reuse it when you retry. A retry with the same key never orders a second number or calls the person twice. We remember a key for 24 hours. ### idempotency_key_invalid Status 400. https://hypamoon.com/docs/errors/idempotency_key_invalid **400.** The `Idempotency-Key` header is there but cannot be used: it must be 1 to 255 visible ASCII characters without spaces, for example a UUID. Nothing was ordered. ### idempotency_key_reused Status 409. https://hypamoon.com/docs/errors/idempotency_key_reused **409.** You used this `Idempotency-Key` before with a different body. A key belongs to exactly one request. Use a new key for a new order. ### idempotency_request_in_progress Status 409. https://hypamoon.com/docs/errors/idempotency_request_in_progress **409.** The first request with this `Idempotency-Key` is still running. Wait the seconds in `Retry-After` and send it again; you then get its answer. A request that never finished is picked up again after about 90 seconds: a retry then checks what happened to the order and finishes it. ### assistant_not_configured Status 422. https://hypamoon.com/docs/errors/assistant_not_configured **422.** The call plan sends calls to the assistant (`ai_agent`), you started a chat (`POST /api/v1/conversations`), or you asked the assistant to make a call (`POST /api/v1/calls`), but the project's assistant was never set up. Call `PUT /api/v1/assistant` first, then send the request again. ### voice_ai_not_enabled Status 402. https://hypamoon.com/docs/errors/voice_ai_not_enabled **402.** The call plan sends calls to the assistant, but voice AI is not available for this project. Voice AI is included in every API project, so you should not see this; if you do, contact Hypamoon support. ### team_targets_not_allowed Status 422. https://hypamoon.com/docs/errors/team_targets_not_allowed **422.** A `forward` node has a target with `kind: "user"`: a team member of the Hypamoon apps. The API has no team members. Forward to a phone number instead: `{ "kind": "external", "e164": "+447700900123" }`. `errors` lists every such target. ### forward_target_not_allowed Status 422. https://hypamoon.com/docs/errors/forward_target_not_allowed **422.** A `forward` node points to a phone number we do not forward to. `errors` lists every such target with its path (`nodes.team.targets.0.e164`) and the reason. With a live key a target must be a mobile, geographic, national or toll-free number in a country your project supports. Never allowed: premium-rate, service and short numbers, test numbers, and any Hypamoon number (forwarding to it would loop). With a test key a target must be a test number from `+447700900000` to `+447700900999` that is free or one of your own test numbers. Change the targets and send the whole plan again. ### invalid_webhook_endpoint Status 400. https://hypamoon.com/docs/errors/invalid_webhook_endpoint **400.** The webhook URL or events are not usable. The URL must be `https`, on a public address, and the events must come from `call.completed`, `call.summary.ready`, `message.delivered`, `message.failed` and `conversation.summary.ready`. `detail` says which. ### webhook_limit_reached Status 409. https://hypamoon.com/docs/errors/webhook_limit_reached **409.** A project has at most ten webhook endpoints. Delete one with `DELETE /api/v1/webhooks/{id}` first. ### webhook_endpoint_disabled Status 409. https://hypamoon.com/docs/errors/webhook_endpoint_disabled **409.** This endpoint was switched off after 50 failed deliveries in a row. Fix your receiver, then `POST /api/v1/webhooks/{id}/enable` and test again. ### destination_not_allowed Status 422. https://hypamoon.com/docs/errors/destination_not_allowed **422.** We do not send an SMS to this number. `reason` says why: `not_a_dutch_mobile_number` (the number is not a mobile number in a country we send SMS to yet; landlines and national numbers are not supported either), `premium_or_service_number` (premium-rate, service and satellite numbers never), `test_number` (a test number with a live key) or `test_mode_requires_test_number` (with a test key, send to a test number from `+447700900000` to `+447700900999`; `+447700900999` simulates a failed delivery). Check the number your app collected; never retry the same request. For outbound calls (`POST /api/v1/calls`) the assistant does not call this number. `reason` is one of: `invalid_number`, `premium_rate`, `service_number` (service and short numbers), `test_number` (a test number with a live key), `satellite`, `high_cost_country`, `platform_number` (a number on our platform, or one of your own test numbers: calling it would loop back), `country_not_supported` (we do not call this country yet; the error then has `user_message` and `waitlist_url`, a link to the waitlist for that country), `number_type_not_supported` (toll-free and other types the assistant does not call) or `test_mode_requires_test_number` (with a test key, call a test number from `+447700900000` to `+447700900999`). ### message_too_long Status 422. https://hypamoon.com/docs/errors/message_too_long **422.** The text is more than six SMS segments. `segments` says how many it would be and `encoding` which character set it needs. In plain text (`gsm7`) a segment holds 153 characters once a message is split; one emoji or other character outside GSM-7 switches the whole message to `ucs2`, where a segment holds 67. Shorten the text, remove the emoji, or send a link to the details. ### sender_not_allowed Status 422. https://hypamoon.com/docs/errors/sender_not_allowed **422.** The `from` of the message is not a sender we can use. `reason` says why: `phone_number` (your Hypamoon phone numbers cannot send SMS, so leave `from` out or use a name), `invalid_format` (1 to 11 letters, digits and spaces, with at least one letter; no accents or punctuation) or `protected_name` (a bank, a government body, a carrier or another brand that is often impersonated). Leave `from` out to send from your project name. ### message_limit_reached Status 429. https://hypamoon.com/docs/errors/message_limit_reached **429.** The project sent its maximum number of messages for today (200 during the beta). The count resets at midnight, Europe/Amsterdam. If your app really needs more, contact Hypamoon support. Per minute there is a separate limit, which gives `rate_limited` with a `Retry-After` header. ### number_required Status 422. https://hypamoon.com/docs/errors/number_required **422.** The assistant calls from one of your project's numbers, so the person sees who is calling and can call back, and the project has no active number yet. Order one with `POST /api/v1/numbers` (in test mode you get a free test number), then make the call again. ### outside_calling_hours Status 422. https://hypamoon.com/docs/errors/outside_calling_hours **422.** It is outside the calling hours of the person you want to call, so nobody was called. By default the assistant calls from 09:00 to 20:00, Monday to Saturday, in the local time of the person called; your `calling_window` can change that within 08:00 to 21:00. The body carries `timezone` and `next_window_start` (an ISO time, or null when there is no allowed moment in the next week). Schedule the call for `next_window_start`, with a new `Idempotency-Key`. In test mode the test number `+447700900995` always gives this error, so you can test this path. ### concurrent_call_limit Status 429. https://hypamoon.com/docs/errors/concurrent_call_limit **429.** Too many outbound calls are running at the same time. `scope` says whose limit it is: `project` (a project can have 2 calls at once) or `platform` (all outbound lines are busy for a moment). `limit` is the number. Nobody was called. Wait the seconds in `Retry-After`, or until `call.completed` arrives for one of your calls, and send the same request again with the same `Idempotency-Key`. ### call_limit_reached Status 429. https://hypamoon.com/docs/errors/call_limit_reached **429.** The project started its maximum number of outbound calls for today (100). The count resets at midnight, Europe/Amsterdam. Nobody was called. If your app really needs more, contact Hypamoon support. Per minute there is a separate limit, which gives `rate_limited` with a `Retry-After` header. ### consent_required Status 403. https://hypamoon.com/docs/errors/consent_required **403.** You asked for a call with `purpose` set to `marketing`, and this project has no recorded consent from this person. Under Dutch telemarketing rules a sales or promotional call to a consumer needs their prior consent, and since 1 July 2026 that also applies to existing customers. Nobody was called. Record the consent first with `POST /api/v1/call-consents` (or the MCP tool `record_call_consent`), with where it came from and a reference to the proof. If the call answers the person's own question, confirms or reminds them of an appointment, gives an order status or returns a callback they asked for, it is transactional: send `purpose: "transactional"` (the default). ### conversation_closed Status 409. https://hypamoon.com/docs/errors/conversation_closed **409.** The conversation is closed and takes no more messages. `close_reason` says why: `closed` (you called `POST /api/v1/conversations/{id}/close`), `inactive` (no message for 30 minutes) or `turn_limit` (it reached its maximum number of turns). Start a new conversation with `POST /api/v1/conversations`. The summary of the old one comes with the webhook `conversation.summary.ready` and in `GET /api/v1/conversations/{id}`. ### conversation_busy Status 409. https://hypamoon.com/docs/errors/conversation_busy **409.** The assistant is still answering an earlier message in this conversation, or the conversation closed at that moment. A conversation takes one message at a time. Wait for the reply (or the seconds in `Retry-After`), then send the next message. In a chat window, disable the send button while a reply is on its way. Nothing was stored or charged for the refused message, so you can send it again, even with the same `Idempotency-Key`. If a request fails partway through, the conversation is free again after at most 60 seconds. ### conversation_turn_limit_reached Status 409. https://hypamoon.com/docs/errors/conversation_turn_limit_reached **409.** The conversation reached its maximum number of turns (`max_turns`, 30). A turn is one customer message with its reply. The conversation is closed and its summary follows. If the customer wants to continue, start a new conversation with `POST /api/v1/conversations`. A conversation that keeps going this long is often a loop in your code or a bot behind your website; check that first. ### conversation_limit_reached Status 429. https://hypamoon.com/docs/errors/conversation_limit_reached **429.** The project started its maximum number of conversations for today: 1000 for a live project during the beta, 25 for a test project. `limit` holds the number. The count resets at midnight, Europe/Amsterdam. Open conversations keep working. If your app really needs more, contact Hypamoon support. Per minute there is a separate limit, which gives `rate_limited` with a `Retry-After` header. ### test_free_replies_used Status 402. https://hypamoon.com/docs/errors/test_free_replies_used **402.** In test mode the assistant gives real replies. The first 20 replies per test project per day are free; after that each reply is deducted from the prepaid balance of your live project at the live price. This error means the free replies for today are used up and the live project has no balance and no saved card. `free_replies_per_day` holds the number. Add credit at https://app.hypamoon.com/tegoed, or wait until midnight (Europe/Amsterdam), when the free replies reset. ### test_free_calls_used Status 402. https://hypamoon.com/docs/errors/test_free_calls_used **402.** Not an HTTP response: a run of an agent in a test project is logged as skipped with this reason (`GET /agents/{id}/runs`). The first 20 model calls per test project per day (all agents together) are free; after that each model call is deducted from the prepaid balance of your live project the same way as live, billed per use. This reason means the free calls for today are used up and the live project has no balance and no saved card. Add credit at https://app.hypamoon.com/tegoed, or wait until midnight (Europe/Amsterdam), when the free calls reset. ### site_limit_reached Status 422. https://hypamoon.com/docs/errors/site_limit_reached **422.** A project can have 3 sites, and this one has 3 already. Put the new version in an existing site with `PUT /api/v1/sites/{id}` (the MCP tool `deploy_site` with `site_id`), or delete a site you no longer need with `DELETE /api/v1/sites/{id}`. The test project and the live project each have their own 3. In its first week a new project can create 3 sites in total (deleted sites count too), so a fresh account cannot publish many sites by creating and deleting them. Change an existing site with `deploy_site` and its `site_id` instead. When the trust model for free sites is switched on for Hypamoon, there is no limit on the number of sites. Instead an account can create 5 new sites a day (**429**, with `Retry-After`; deleted sites count too), until the project has topped up its balance or passed verification. The limit ends after a week, or earlier with the first top-up or an approved verification. `next_step` in the error says the same. ### slug_taken Status 409. https://hypamoon.com/docs/errors/slug_taken **409.** Another site already has this slug, so `https://<slug>.hypamoon.app` is not free. Pick another slug, or leave `slug` out: then we derive one from `name` and add a short suffix when that one is taken too. A deleted site keeps its slug, so an old link never shows someone else's site. ### slug_reserved Status 422. https://hypamoon.com/docs/errors/slug_reserved **422.** This slug is reserved, like `www`, `api`, `app`, `docs`, `test`, `demo` or anything that starts with `hypamoon`. Pick another slug, or leave `slug` out to derive one from `name`. ### site_too_large Status 422. https://hypamoon.com/docs/errors/site_too_large **422.** The site is larger than a site can be: at most 50 files, 1 MB per file and 5 MB for all files together (after base64 decoding). `errors` names what is too large. Make images smaller (webp is often a tenth of a png), load large libraries from a CDN instead of uploading them, and leave out files the page does not use. On plans with larger limits, a PDF and a video or audio file have their own limit per file (see `limits` from `POST /sites/uploads`); a video larger than the inline limit has to come through an upload link. ### site_blocked Status 422. https://hypamoon.com/docs/errors/site_blocked **422.** We check every deploy before it goes live, and this one was not published. The check is automatic: no person looked at it. `reasons` lists what we found, each with a `code`, the `path` of the file (or `slug` or `name`) and a `message` you can show the user: - `sensitive_input`: an input field for a password, PIN code, card number or CVC. Sites cannot ask for these. To keep a site private, use the `password` option of the site; to take payments, link to the payment page of your payment provider. - `external_form`: a form that sends what visitors type to another site. Use a collection with `public_write` and the form snippet instead. A search box that sends to a search engine with `method="get"` is fine. - `hidden_iframe`: an iframe nobody can see (hidden, or 0 or 1 pixel). The noscript tag of Google Tag Manager is fine. - `inline_iframe`: an iframe with its page inside it (`srcdoc` or a `data:` address). Put that page in its own file. - `external_redirect`: a meta refresh, or a script that sends visitors to another domain by itself when the page loads. A link or a button the visitor clicks is fine. - `obfuscated_script`: code that hides what it does, like `eval(atob(...))` or output of an obfuscator. Minified code is fine. - `brand_login`: fields for a username, customer number, IBAN or a login or verification code on a page whose title, main heading, slug or name carries the name of a bank, government service, parcel or payment service or large platform. For platforms like Google or Instagram, only a code field counts. - `brand_in_address`: the slug or name combines such a brand with a word like `login` or `verify`. - `pdf_script`: a PDF with embedded JavaScript or an action that starts a program (`/Launch`). Export it again without scripts, for example with "Save as PDF". - In a PDF, `external_form` is a form that submits to another host, and `brand_login` is a PDF with such a brand, a link to another site and a phrase like "log in", "verify your account" or "pay now". Video and audio are only checked on their type (the first bytes) and size. Fix what the reasons say and deploy again with all files. There is no way to skip the check. If you think it is wrong, email hello@hypamoon.com with the site address and the `request_id`. ### site_offline Status 403. https://hypamoon.com/docs/errors/site_offline **403.** Hypamoon took this site offline because of abuse. The reason is in `detail` and in `taken_offline` on the site, and the owner got it by email. Visitors see a page that says the site is unavailable; the files are kept. While it is offline the site cannot be changed or deleted. To object, reply to the email or write to hello@hypamoon.com with the site address. With `reason: "project_blocked"` it is about the whole project: one of its sites was taken offline for abuse, so the project cannot publish anything until we lift the block. No new deploys of sites or apps, no new versions, previews or rollbacks. Sites that are online keep working and can be read. Outbound calls and SMS (`telephony_unavailable`) and email (`email_paused`) are blocked too; inbound calls keep working. Tell the user, and do not try to work around it: reply to the email about the site or write to hello@hypamoon.com. ### deploy_limit_reached Status 429. https://hypamoon.com/docs/errors/deploy_limit_reached **429.** This project deployed too often in the last hour: 120 deploys per hour, 30 in the first week of a project (until its first top-up or an approved verification). A blocked deploy counts too. Wait the seconds in `Retry-After`, and combine several changes into one deploy instead of one call per change. ### upload_not_received Status 422. https://hypamoon.com/docs/errors/upload_not_received **422.** `deploy_site` names a file by `sha256` and `bytes`, an upload link was created for it with `create_upload_urls` (`POST /sites/uploads`), but the bytes never arrived: nothing was PUT to the `upload_url` (it is valid for 15 minutes). This almost always means the environment of the chat or agent could not reach the upload host; a code sandbox in a chat app often blocks it. Hypamoon itself is working. Two ways out: send images under about 3 MB as base64 in `content` of `deploy_site` instead, or create a drop link (`create_drop_link`, `POST /sites/drops`) so the user uploads the files in a browser, then read them with `get_drop` and deploy them by `sha256`. `errors` names each file; `user_message` and `next_step` are in the error. ### phone_verification_required Status 403. https://hypamoon.com/docs/errors/phone_verification_required **403.** Publishing a public site (no password, not unlisted) or connecting your own domain needs a verified phone number on the account, or a payment method on the project. The user verifies once at https://app.hypamoon.com/verify-phone by calling us from their own phone; we never answer and never call or text back. `verify_url` and `next_step` are in the error. Until then, a site with a password (or an unlisted site) works without it. This only applies when phone verification is switched on for Hypamoon. With the trust model for free sites switched on, sites, data, forms and connecting your own domain only need an email address. The verified phone moves to what reaches people or moves money: ordering a phone number, outbound calls, text messages, email from your own domain, real payments on a site, buying a domain and agents that send messages. There a payment method does not replace the phone. A project whose site an automated review was unsure about needs the verified phone for public sites and domains too. ### collection_limit_reached Status 422. https://hypamoon.com/docs/errors/collection_limit_reached **422.** This project has reached the number of collections its plan allows: 20 on Free, 50 on Online, 100 on Reachable, 200 on Business. Reuse a collection (one collection can hold many kinds of entries, with a field like `type`), delete one you no longer need with `DELETE /api/v1/collections/{id}`, or choose a higher plan. The test project and the live project each have their own collections; the test project follows the plan of the live project. ### collection_name_taken Status 409. https://hypamoon.com/docs/errors/collection_name_taken **409.** This project already has a collection with this name. Names are unique per project, so a form can post to it by name. Pick another name, or change the existing collection with `PATCH /api/v1/collections/{id}` (the MCP tool `update_collection`). A deleted collection frees its name. ### document_limit_reached Status 422. https://hypamoon.com/docs/errors/document_limit_reached **422.** The collection already has 10,000 documents, the maximum. Delete the documents you no longer need (`DELETE /api/v1/collections/{id}/documents/{docId}`), or start a new collection, for example one per year. ### document_too_large Status 422. https://hypamoon.com/docs/errors/document_too_large **422.** A document can be at most 64 kB as JSON (UTF-8). Store large text or files elsewhere and keep a link in the document. From a form, this usually means a field was filled with far more text than a person types; limit it with `maxLength` in the collection's schema or on the input. ### schema_mismatch Status 422. https://hypamoon.com/docs/errors/schema_mismatch **422.** The document does not match the JSON Schema of its collection. `errors` names every field that is wrong, with a path like `data.email` and what is expected. Fix the data, or change the schema with `PATCH /api/v1/collections/{id}` (send `"schema": null` to remove it). From a form, `form.js` shows the first error next to the button. A schema that uses a keyword we do not check (like `oneOf`) is refused when you save it, with `validation_failed`. ### collection_not_public Status 403. https://hypamoon.com/docs/errors/collection_not_public **403.** A website tried to write to or read from a collection that does not allow it. Writing from a form needs `access.public_write`, reading from a page needs `access.public_read`; turn the one you need on with your API key (`PATCH /api/v1/collections/{id}` or the MCP tool `update_collection`). A collection is private until you do. Form entries never reach the assistant, not even with `assistant_read`. ### plan_limit_reached Status 402 or 409. https://hypamoon.com/docs/errors/plan_limit_reached **402 or 409.** With 402 the project is on the Free plan, and this call would go above what Free includes: 3 sites per project, 10 MB of data, 100 MB of files, one team member, and phone numbers and payments in test mode only. Nothing was changed. `next_step` says what to do: upgrade to Online in the portal (Plan), or make room (update or delete a site, delete documents you no longer need). `limit` says which limit, `plan` the current plan and `upgrade_to` the next plan up. Paid plans have no limit on sites; data and files above the plan are billed per GB-month from the balance. With the trust model for free sites switched on, Free has no limit on sites either (see `site_limit_reached` for the daily limit on new sites). With 409 the project is on a paid plan and would go above the 10 GB of data a project can hold. Delete documents you no longer need, or ask Hypamoon about an Enterprise plan. For apps (`POST /api/v1/apps`, `deploy_app`), `limit` is `apps`: the Free plan includes one app per project, and a new app above that gets 402 (a new version of an existing app always works). With 409 a project on a paid plan already has the maximum number of apps a project can have. ### app_blocked Status 422. https://hypamoon.com/docs/errors/app_blocked **422.** We check every app deploy before it goes live, and this one was not published. The check is automatic: no person looked at it. `reasons` lists what we found, each with a `code`, the `module` and a `detail` you can show the user: - `miner`: it looks like cryptocurrency mining. - `remote_code`: it downloads code and runs it (`eval` of a response, `import` of a URL). Bundle all code into the modules instead. - `obfuscated_script`: deliberately hidden code, like `eval(atob(...))` or a packer. Send the readable code. - The codes of `site_blocked` (like `sensitive_input` or `external_form`) when a module serves a page that would be blocked as a site. There is no option to skip the check. If the user thinks it is wrong, they can email hello@hypamoon.com with the site address and the `request_id`. ### app_too_large Status 413. https://hypamoon.com/docs/errors/app_too_large **413.** The modules of the app are larger than the plan allows for one app. `errors` says how large they are. Minify the bundle and leave out dependencies the code does not use; static files (images, styles) belong in the site, not in the app. ### migration_failed Status 422. https://hypamoon.com/docs/errors/migration_failed **422.** A migration from `apply_app_migration` (`POST /api/v1/apps/{id}/migrations`) reached the app's database but SQLite refused it, for example a syntax error, a table that already exists or a column that is missing. That migration was not applied at all: each migration runs as one batch, all or nothing. Migrations before it in the same call did apply; they are in `applied`. Fix the SQL and send it again under the same name (it was never recorded). ### file_too_large Status 413. https://hypamoon.com/docs/errors/file_too_large **413.** The file an app requested an upload link for is larger than one file may be on this plan (`max_bytes`). The `detail` and `next_step` say how the app can make it smaller (a photo scaled down on a canvas, audio at a lower bitrate, a shorter video clip). Ask the user for a smaller file, or upgrade the plan for larger files. All files of a project together also count toward the files in the plan (`plan_limit_reached` with `limit` `bestanden`). ### query_failed Status 422. https://hypamoon.com/docs/errors/query_failed **422.** The read-only query from `query_app_db` (`POST /api/v1/apps/{id}/query`) reached the app's database but SQLite refused it, for example a table or column that does not exist or a syntax error. Nothing changed: the query only reads. `get_app_schema` (`GET /api/v1/apps/{id}/schema`) shows the tables and columns; fix the query and send it again. ### mail_not_enabled Status 403. https://hypamoon.com/docs/errors/mail_not_enabled **403.** Email is an extra module, and it is not turned on for this project. The owner turns it on in the portal (Mail). Until then, nothing is sent and no sending domain can be added. Test keys can try everything without it: test emails are simulated and never leave Hypamoon. ### email_paused Status 403. https://hypamoon.com/docs/errors/email_paused **403.** Sending is paused for this project because too many recent emails bounced (more than 5% of the last 200) or someone marked one as spam (more than 0.1%). `detail` says which. Check the recipients: mail only people who asked for it, at addresses they gave you. After bounces the owner can resume in the portal (Mail); after spam complaints contact hello@hypamoon.com. With `reason: "project_blocked"` the project is blocked after one of its sites was taken offline for abuse (see `site_offline`); nothing is sent until we lift the block. ### recipient_not_allowed Status 403. https://hypamoon.com/docs/errors/recipient_not_allowed **403.** The address is not in this project: not in a form submission, a document, a chat conversation or a sign-in of one of your apps, and the owner did not confirm it. We only send to people your project already knows, never to a bought or imported list. If the user really wants to mail this person, ask the owner to confirm the address (`allow_email_recipient` in a chat where the owner is signed in, or in the portal), then send again. This error also appears when the person unsubscribed and the email is not `transactional`. ### email_limit_reached Status 429. https://hypamoon.com/docs/errors/email_limit_reached **429.** The project reached its daily email limit (`limit` in the error), or the limit on new recipients that only appear in documents your app wrote (`reason: "new_recipients_limit"`, with `used_today`). The limits count over the last 24 hours. The daily limit is raised by hello@hypamoon.com. The limit on new recipients grows by itself: 25 to start, 100 and then 500 as emails are delivered without bounces or spam complaints, sooner with a verified business or a paid plan; `user_message` says what helps, and `list_emails` shows it in `mail.new_recipients`. App users who signed in, buyers and addresses the owner confirmed never count toward it. ### email_blocked Status 422. https://hypamoon.com/docs/errors/email_blocked **422.** The content check stopped this email: it looked like phishing or contained something email clients and spam filters treat as an attack (a form, a script, a link that hides where it goes, a link shortener, a known bad link). `reasons` lists each finding with an explanation. Fix the email and send it again. There is no option to skip the check; if you think it is wrong, email hello@hypamoon.com with the request id. ### email_domain_not_verified Status 409. https://hypamoon.com/docs/errors/email_domain_not_verified **409.** The `from` address is not on a verified sending domain of this project. Add the domain with `add_email_domain` (`POST /api/v1/email-domains`), make sure the DNS records are in place, and check it with `verify_email_domain` until its status is `verified`. ### attachment_too_large Status 413. https://hypamoon.com/docs/errors/attachment_too_large **413.** The attachments of this email are over the limit: at most 10 attachments, and at most 10 MB together (`limit_bytes`). `index` says which attachment went over. Send a link to the file instead, or make it smaller. Inline `content` also counts against the size of the request (about 4 MB); for a larger file use `media_id` (a file in Media) or `path` (a file on one of the project's sites). ### attachment_not_allowed Status 422. https://hypamoon.com/docs/errors/attachment_not_allowed **422.** This attachment cannot be sent. Allowed are PDF, PNG, JPG, GIF and WebP, plus calendar (`.ics`), text (`.txt`) and CSV (`.csv`) files as UTF-8 text. The type is checked on the first bytes of the file, not only on its name, so the extension must match what the file really is. Never allowed: anything that runs or renders as a page (exe, js, html, svg, zip and the like), and a PDF with scripts, launch actions or embedded files. `index` says which attachment; `allowed` lists the extensions. ### email_address_invalid Status 422. https://hypamoon.com/docs/errors/email_address_invalid **422.** Nothing was sent: before the first email to an address we check that it can receive email, and this one did not pass. `check` says why: `likely_typo` (a typo in a big mail domain, like gmial.com; `suggestion` has the address that was probably meant), `domain_not_found` (the domain does not exist), `no_mail_server` (the domain does not accept email), `disposable` (a throwaway address; only refused for category `other`) or `invalid_syntax`. `recipient` names the address and `user_message` is a sentence for the user. Ask the user for the right address; never correct it on your own. Check addresses up front with `check_email_address` (`POST /api/v1/email-recipients/check`). If the user is sure the address is right, send again with `check_recipients: false`. An address that bounced before is always refused (`recipient_not_allowed` with `suppression: "hard_bounce"`). ### bimi_not_ready Status 409. https://hypamoon.com/docs/errors/bimi_not_ready **409.** The logo in the inbox (BIMI) needs DMARC with `p=quarantine` or `p=reject` for all mail (no `pct` below 100), and this domain does not have that yet. Nothing was changed. `dmarc` shows the current record. Making DMARC stricter is the user's decision: do it only once their email passes DMARC, or real mail gets rejected. Then call `set_email_bimi` again. ### dedicated_ip_not_available Status 403. https://hypamoon.com/docs/errors/dedicated_ip_not_available **403.** A dedicated sending IP is available on the Business and Enterprise plans; this project is on another plan, so nothing was requested. `user_message` says so in plain words and `price` shows the monthly price per IP. The owner can move to Business in the portal (Plan), or ask hello@hypamoon.com about Enterprise. A dedicated IP makes sense from about 100,000 emails a month with steady volume; below that the shared pool usually delivers better. ### ai_credit_exhausted Status 402. https://hypamoon.com/docs/errors/ai_credit_exhausted **402.** `env.HYPAMOON.ai.generate` in an app's server code was refused because the AI credit of the app's project is used up (the AI credit in the plan and the balance). Nothing ran and nothing was charged. The error carries a `user_message` in the app's language that never mentions credit or amounts, so the app can show it as is; the app should fall back to its own logic. The project owner tops up the balance in the portal, or turns on automatic top-up. ### ai_limit_reached Status 429. https://hypamoon.com/docs/errors/ai_limit_reached **429.** `env.HYPAMOON.ai.generate` reached a daily limit: 1 euro of AI per app per day, or 20 calls per signed-in user per day. Both reset at midnight (Europe/Amsterdam). The `user_message` is in the app's language and safe to show to the person using the app. Nothing ran and nothing was charged; fall back to the app's own logic. ## Payments: sell on your own site Everything on this page works; where a part has a limit, it is marked. Your chat can always tell you where your project stands: `get_payments_status` returns `availability` (`live`) and `mode` (`live` when the project takes real payments, `test` when every payment is a test payment and no money moves). The same two words are used everywhere: in the tools, in the portal and on this page. ### How the money moves You are the seller. You connect your own payment account in the Hypamoon portal (Payments), and the money of every sale goes straight to that account. Hypamoon never holds it. Hypamoon takes an application fee per successful payment, only after the owner of the project has agreed to it in the portal. A test project always pays in test mode. You are also responsible for what a seller has to publish: your terms, your privacy notice, your returns policy and, if you sell to consumers in the EU, VAT. Where available, a paid order gets an invoice in your name, never in Hypamoon's (see VAT and invoices); your order data is yours to export. ### Products A product is a document in the product collection of the project. Create that collection once: - with MCP: `create_collection` with `"role": "products"` (or, on connections without the shop settings, `create_product_collection`); - with REST: `POST /api/v1/collections` with `{"name": "products", "role": "products"}` (or `POST /api/v1/payments/product-collection`). Then add products with `insert_document`. The fields that matter for the checkout: | field | meaning | |---|---| | `name` | What the buyer sees, 1 to 120 characters. | | `price_cents` | A whole number in the smallest unit of the currency, like `1995` for 19.95. VAT included, unless the shop setting `prices_include_vat` is `false`. | | `currency` | Optional, ISO 4217. Left out: the shop currency (EUR by default). One checkout is one currency. See Currencies. | | `stock` | Optional whole number. Left out: unlimited. It goes down when an order is paid. | | `active` | Optional. `false` takes the product off sale. | | `line_fields` | Optional, at most 3 questions per order line, like an engraving or a name on a cake. Same shape as `checkout_fields` below. | | `tax_class` | Optional, where available: `standard` (default), `reduced`, `zero` or `exempt`. Which VAT rate of the shop's rate table the product gets (see VAT and invoices). A variant may have its own `tax_class`. | | `kind` | Optional: `physical` (default), `digital`, `service` or `gift_card`. With VAT per EU country, goods and digital products follow the buyer's country; a service stays in the seller's country. | Other fields (`description`, `image_url`, `sku`, anything you like) may be added; the checkout ignores them. The product collection can be read by a site but never written by one: a visitor can never set a price. #### Currencies The currency is your choice, per product. Set `currency` on a product, or a default for the whole shop with the shop setting `currency`; a product without either is EUR. Hypamoon never converts a price: if you sell in EUR and GBP, you set both prices yourself, as two products. You can sell in EUR, USD, GBP, CAD, AUD, CHF, SEK, NOK, DKK and PLN. Currencies without cents (like JPY) are not available. What a buyer can pay with depends on the currency and on your payment provider: | | payment provider for Europe | second payment provider (outside Europe) | |---|---|---| | One payment in EUR | Every method you turned on, like iDEAL, Bancontact, cards, Apple Pay and PayPal. | Every method you turned on. | | One payment in another currency | Cards, Apple Pay and PayPal. iDEAL and Bancontact are EUR only. | Cards and Apple Pay, plus the local methods your account offers in that currency. | | A subscription | EUR only. | Every currency above. | Everything after the sale stays in the currency of the order: the order, the invoice and credit notes, a gift card bought with it and its balance, and every refund. A gift card only pays for an order in its own currency. The shop setting `payment_methods` (optional: `card`, `apple_pay`, `paypal`, `ideal`, `bancontact`) limits what the checkout offers. When none of the chosen methods can take the order's currency, the checkout refuses the order with `currency_not_available`, and `configure_shop` refuses the setting, with the way out in the message (add a method that can, leave `payment_methods` out, or price in EUR). Selling in another currency than EUR needs fee terms that list the fee per currency. Terms agreed to before 14 October 2026 cover EUR only; the owner agrees to the current terms in the portal (Payments). `get_payments_status` shows `currencies`: what the project can sell in once (`one_time`) and as a subscription (`subscriptions`), the methods outside EUR, and whether the fee terms cover other currencies. #### Variants (where available) A product that comes in sizes or colours stays one document, with `options` and `variants`: ```json { "name": "T-shirt", "price_cents": 2500, "options": [ { "name": "Size", "values": ["S", "M", "XL"] }, { "name": "Colour", "values": ["Blue", "Black"] } ], "variants": [ { "id": "s-blue", "options": { "Size": "S", "Colour": "Blue" }, "stock": 4, "sku": "TS-S-BL" }, { "id": "xl-black", "options": { "Size": "XL", "Colour": "Black" }, "price_cents": 2700, "stock": 2, "image_url": "https://example.com/xl-black.jpg" } ] } ``` | field | meaning | |---|---| | `options` | At most 3, each with a `name` and 1 to 50 `values`. | | `variants` | At most 100. Each has an `id` (letters, digits, `_` and `-`, unique in the product) and a value for every option. | | `variants[].price_cents` | Optional. Left out: the product's `price_cents`. | | `variants[].stock` | Optional. Left out: the product's `stock` counts for this variant (a shared stock), and without that it is unlimited. | | `variants[].active` | Optional. `false` takes only this variant off sale. | | `variants[].sku`, `variants[].image_url` | Optional. | A product with variants sells only a variant: every line names a `variant_id`. The stock goes down on the variant when the order is paid, and back up on a full refund that was never shipped. When a product document does not fit these rules, `insert_document` says which field is wrong (like `data.variants.2.options.Size`). A product without `variants` works exactly as before. Importing from WooCommerce turns a variable product and its variations into one product with variants. Products imported earlier as separate variations stay as they are. ### The buy button `get_checkout_snippet` returns a button and the script tag: ```html <button type="button" data-hypamoon-checkout data-hypamoon-token="hm_pub_..." data-hypamoon-product="<product id>">Buy now</button> <p data-hypamoon-checkout-status role="status"></p> <script src="https://api.hypamoon.com/checkout.js" defer></script> ``` Options on the button: `data-hypamoon-items` (a JSON list of `{"product_id", "quantity", "fields"}` for several lines), `data-hypamoon-quantity`, `data-hypamoon-shipping="true"` (ask for an address), `data-hypamoon-return` (a path on the site to come back to), and `data-hypamoon-terms` and `data-hypamoon-privacy` (https links, used when the shop settings have none). The button uses the project's public token; the site's origin must be in its `allowed_origins`. When the visitor clicks, a small window asks for an email address (and an address with shipping) and runs a bot check. Before the pay button it shows the total, the name of the seller and the links to the seller's terms and privacy notice; without a total the pay button stays off. Then the visitor goes to the payment page and comes back with `?hypamoon_order=<id>` and a signed `?hypamoon_ref=` (see "After paying" below). For a product with variants, add `data-hypamoon-variant="<variant id>"` (or `variant_id` in each line of `data-hypamoon-items`). For a shop with more than one product, the cart below is usually the better fit. When the shop cannot take payments right now (the quote says `checkout_available: false`, for example because the owner has to connect payments again), the window shows no pay button but a short line for the buyer with the shop's contact link, and a warning in the browser console for you. In test mode (`test_mode: true`) it shows a "Test mode" label next to the title and "No real money moves." under the pay button. #### After paying: the confirmation You do not have to build a thank-you page. The payment provider and the test payment page both send the buyer back to the page of the button (or `data-hypamoon-return`), whatever happened. checkout.js (1.5.0 and later) then shows a window in the same style as the checkout window, with the same `--hm-*` variables: | What happened | The window | |---|---| | Paid | "Thank you, your order is confirmed", the order number, what was bought, the total and "A confirmation is on its way to <email>". For a gift card: that it is attached to that email (and goes to the recipient, or on which day), with **Download gift card (PDF)** and **Print**. | | Not paid yet (the payment provider has not told us yet, or the buyer came back without finishing) | "We are waiting for your payment". It checks again by itself (every 2 seconds, then every 5, for 5 minutes), with **Continue to payment** to the payment page. | | Failed, canceled or expired | "The payment did not go through", with **Try again** (opens the checkout with the same products, or the cart). | | Refunded | "This order was refunded". | The status line from the snippet (`data-hypamoon-checkout-status`) gets the same short sentence. Escape and the close button close the window; focus goes back to the status line. How it works: the return URL carries the order id and a signed reference (`hypamoon_ref`, an HMAC of the order id with a server key). checkout.js reads the order with `GET https://api.hypamoon.com/api/v1/public/orders/<order id>?ref=<reference>` and then takes the reference out of the address bar (it keeps it in `sessionStorage` for a reload in the same tab). Without the right reference that route answers 404, like for an order that does not exist; order ids or numbers alone open nothing. It returns only what the buyer entered or bought: status, order number, the lines, the total, the buyer's email, test or not, and the gift cards (value, whether they go to a recipient, never who, and never the code). The confirmation email links to the same page with the reference ("View your order"), so a buyer who closed the tab can come back. The gift card PDF (`/api/v1/public/orders/<order id>/gift-cards/<n>?ref=`, `&download=1` to download) is made on request and not stored. It works for 30 days after the payment; after that the PDF in the email and the balance page still have the card. The code itself is never shown in the window: the window runs on your page, where other scripts (analytics, chat, session replay) can read what it shows, and a code is money. Your own texts or your own confirmation: - `data-hypamoon-paid-title`, `data-hypamoon-pending-title` and `data-hypamoon-failed-title` on the button or the status line replace the titles. The value of `data-hypamoon-checkout-status` replaces the paid sentence in the status line. - `data-hypamoon-confirmation="false"` on the button, the status line or the script tag: no window. The status line still updates, and every check fires `hypamoon:order` on `window` with `{ status, order_id, order_number, test, total_cents, currency, items }` (`status` is `paid`, `pending`, `failed` or `refunded`), so you can show your own. Orders from before 1.5.0 have no reference in their return URL: then only the status line shows its sentence, as before. checkout.js and cart.js are served with `Cache-Control: max-age=300, stale-while-revalidate=3600`: a new version reaches visitors within minutes, without a version in the script URL. #### The look of the checkout window Hypamoon is the plumbing; the site picks the style. The window lives in a shadow root, so the site's own CSS (like `button {}` or `input {}`) does not leak into it. It starts calm and neutral (rounded corners, a soft shadow, light and dark through `prefers-color-scheme`) and then takes over the site's look in three layers, the first one wins: 1. CSS variables set by the site, on `:root` or on the button itself (`[data-hypamoon-checkout]`). The window reads them when it opens. 2. The appearance of the shop from `GET /api/v1/public/appearance` (the same as the cart). 3. The neutral style. | Variable | What it sets | | --- | --- | | `--hm-accent` | The pay button and checkboxes | | `--hm-accent-text` | Text on the pay button (default: black or white, whichever reads best on `--hm-accent`) | | `--hm-font` | The font of the window | | `--hm-font-heading` | The font of the title | | `--hm-radius` | Corner radius of fields and buttons (the window itself is a bit rounder) | | `--hm-bg` | Background of the window | | `--hm-surface` | Fields and the summary box | | `--hm-text` | Text | | `--hm-muted` | Labels and small print | | `--hm-line` | Thin dividers | | `--hm-border` | Borders of fields and secondary buttons | | `--hm-focus` | The focus ring | | `--hm-error` | Error messages | Without `--hm-font` the window uses the font of the page (the `font-family` of `body`), and without `--hm-font-heading` the font of the first `h1` or `h2` on the page. The older `--accent`, `--primary` and `--brand` on `:root` still work as a fallback for `--hm-accent`. If you set `--hm-bg` but not the text colours, the window picks neutral light or dark colours that go with your background. For dark mode there are no separate names: set the same variables again inside a `prefers-color-scheme` block. ```css :root { --hm-font: "Hanken Grotesk", system-ui, sans-serif; --hm-font-heading: "Source Serif 4", Georgia, serif; --hm-accent: #9a4f27; --hm-accent-text: #ffffff; --hm-bg: #f6f1e9; --hm-surface: #ede4d6; --hm-radius: 4px; } @media (prefers-color-scheme: dark) { :root { --hm-accent: #de9d6e; --hm-accent-text: #1c120b; --hm-bg: #17120e; --hm-surface: #221a14; } } ``` The window is a modal `<dialog>` with its title as label: Tab and Shift+Tab stay inside it, Escape and the close button close it, and focus goes back to the button that opened it. Fields have their label above them and are at least 44 px high; on a phone the window fills almost the whole screen and scrolls inside. ### The cart (where available) `get_cart_snippet` gives a drop-in shopping cart: one script line for every page, a cart button for the header, and an add-to-cart button per product. ```html <script src="https://api.hypamoon.com/cart.js" data-token="hm_pub_..." defer></script> <hypamoon-cart></hypamoon-cart> <hypamoon-add-to-cart product="<product id>">Add to cart</hypamoon-add-to-cart> <hypamoon-add-to-cart product="<product id>" variant="xl-black">Add to cart</hypamoon-add-to-cart> ``` - The cart button shows a counter and opens a drawer where the buyer changes quantities, removes lines and picks a variant. Without `<hypamoon-cart>` on the page a floating cart button appears (turn that off with `data-floating="false"` on the script tag). - An add-to-cart button for a product with variants and without `variant` shows a variant picker next to it; sold-out variants cannot be picked. Add `open="false"` to keep the drawer closed after adding. - The cart keeps only product ids, variants and quantities, in the visitor's browser per public token. Every amount, name and stock message comes from the quote below, never from the page. - Checkout opens the same checkout window as the buy button. Options on the script tag: `data-shipping="true"`, `data-return="/thanks"`, `data-terms`, `data-privacy` and `data-lang` (`en` or `nl`; otherwise the page's `<html lang>`). - Back from the payment page, the cart (1.1.0 and later) loads checkout.js, which shows the confirmation (see "After paying" above). The cart empties only when the order is paid; after a failed payment the lines stay, and **Try again** opens the checkout from the cart. - It takes the site's colours (light and dark), fonts, corner radius and language from `GET /api/v1/public/appearance?token=...`. Override anything with CSS variables on the element, like `hypamoon-cart { --hm-accent: #0a7b55; --hm-radius: 4px }` (also `--hm-bg`, `--hm-surface`, `--hm-text`, `--hm-muted`, `--hm-line`, `--hm-border`, `--hm-accent-text`, `--hm-focus`, `--hm-error`, `--hm-font`, `--hm-font-heading`), or with `::part(button)`, `::part(count)`, `::part(drawer)` and `::part(checkout)`. - Keyboard and screen readers: every control is a real button, select or input with a label, the drawer is a modal dialog (Escape closes it and focus goes back to the cart button), and the counter is announced. #### Your own cart The same script gives `window.Hypamoon.cart` for a cart in your own design: ```js Hypamoon.cart.add(productId, { variant: "m-blue", quantity: 1, fields: { engraving: "Anna" } }); Hypamoon.cart.remove(0); // by index, or { product_id, variant_id } Hypamoon.cart.setQuantity(0, 3); Hypamoon.cart.setVariant(0, "s-blue"); Hypamoon.cart.get(); // [{ product_id, variant_id, quantity, fields }] Hypamoon.cart.quote(); // a Promise with the quote below Hypamoon.cart.open(); // the drawer; open(items) replaces the cart first Hypamoon.cart.checkout(); // straight to the checkout window window.addEventListener("hypamoon:cart", (e) => console.log(e.detail.type, e.detail.count, e.detail.quote)); ``` Without the script, call the quote yourself and start the checkout with the same `items`: the amounts are the same either way. ### The quote: one place where amounts are calculated `POST /api/v1/public/cart/quote?token=hm_pub_...&lang=en` calculates an order without starting it. The checkout uses the same calculation to create the payment, so a cart you build yourself always shows the amount the buyer will pay. Public, with the public token and its allowed origins, like a form. Request: ```json { "items": [{ "product_id": "<id>", "quantity": 2, "fields": { "engraving": "Anna" } }], "fields": { "delivery_date": "2026-12-24" }, "country": "NL", "code": "WELCOME10" } ``` Answer (amounts in the smallest unit, like `price_cents`): ```json { "ok": true, "currency": "EUR", "prices_include_vat": true, "items": [{ "product_id": "<id>", "name": "Mug", "unit_price_cents": 1495, "quantity": 2, "subtotal_cents": 2990, "discount_cents": 0 }], "subtotal_cents": 2990, "discount_cents": 0, "discount_code": null, "shipping": { "cents": 0, "method": null, "options": [] }, "tax": { "mode": "not_calculated", "included": true, "total_cents": null, "lines": [], "note": null }, "gift_card_cents": 0, "total_cents": 2990, "to_pay_cents": 2990, "total": "29.90", "total_display": "€29.90", "seller": { "name": "Example Shop B.V." }, "links": { "terms": "https://example.com/terms", "privacy": "https://example.com/privacy", "returns": null, "contact": null }, "checkout_fields": [{ "key": "delivery_date", "label": "Delivery date", "type": "date", "required": true, "max_length": 500 }], "problems": [] } ``` `problems` lists what stops a payment, each with `code`, `path` and `message`: `not_available`, `sold_out`, `not_enough_stock`, `mixed_currency`, `currency_not_available`, `invalid_fields` and `code_invalid`. A quote with problems is a `200` with `ok: false`; show the messages to the buyer. With variants (where available), each line in `items` may have a `variant_id`, and each line in the answer also has `variant_id`, `variant_name` (like `"M / Blue"`), `options` (like `{"Size": "M"}`), `image_url`, `needs_variant` and, for a product with variants, `choices`: the product's `options` and its variants that are on sale, each with `id`, `label`, `options`, `price_cents` and `available`. A line without a variant for such a product comes back with `needs_variant: true` and the problem `variant_required`; an unknown or switched-off variant gives `variant_not_available`. `code_field` says whether the shop takes a code. Stock that another buyer is paying for right now counts as gone (see the checkout). With VAT set up in the shop (where available, see VAT and invoices), `tax` has the calculation: `mode` (`none`, `domestic`, `eu_oss` or `stripe_tax`), `included`, `total_cents`, `lines` per rate with `rate`, `country`, `treatment` (`standard`, `reverse_charge`, `export`, `exempt`, `outside_scope`, `none`), `base_cents` and `tax_cents`, the `country` of the rate, `vat_number` (the buyer's number with its `status` and whether VAT was `reverse_charge`d), `notices` for the buyer, and a `note` for the receipt. Send `vat_number` in the request for a business buyer, and `country` as soon as the buyer's country is known. Without a VAT setting, `tax.mode` stays `not_calculated` and the prices are what you set. Not here yet: discount codes and shipping rules. Until then `discount_cents` and `shipping.cents` are `0`. Include shipping in your prices for now. ### Downloads (where available) Sell a file (an e-book, a pattern, music, a font, a zip) as a download. The file is private: it is never on a site, and buyers only get links that expire. 1. `create_download_upload_urls` with `name`, `sha256` and `bytes` per file. You get an `upload_url` per file: `PUT` the exact bytes there within 15 minutes. Types: pdf, zip, epub, mp3, m4a, wav, flac, mp4, png, jpg, webp, gif, svg, txt, csv, json, woff2, ttf and otf. A file can be as large as a video on your plan. 2. Put the returned `file` objects on the product: `{"name": "Recipes e-book", "price_cents": 1200, "delivery": "download", "files": [...]}`. At most 10 files per product; a variant can have its own `delivery` and `files` (a CD and an MP3 of the same album). `"kind": "digital"` means the same as `"delivery": "download"`. At checkout the buyer ticks that delivery starts right away and that they lose their right of withdrawal (EU law for digital content); without the tick the checkout is refused with an error on `waive_withdrawal`. An order with only downloads asks no shipping address. After payment the order confirmation has one link to the buyer's download page. Each file works a number of days and a number of downloads (shop setting `downloads`, default 7 days and 5 times; or `download: {"expires_days": 30, "max_downloads": 3}` on the product). Opening the page counts nothing; only the download button does. When the links have expired or are used up, the buyer asks for new ones on the same page (they go to the email of the order) or on their orders page. A full refund closes the links. A download product without files cannot be bought; `check_business_readiness` lists files that are missing. ### My orders for buyers (where available) Every site of the project has `/_hypamoon/orders`: buyers enter the email of their order and get a sign-in link (30 minutes, once). They see their paid orders with the status, tracking, invoices, gift cards they bought with the balance left, their downloads and a button for new links, and they can download all their data or have it deleted. Orders they paid for stay with you for your accounts, with their name and address but without their email. On a site with an app with sign-in, a signed-in member is a buyer with the same email, without a second sign-in. Link to it from your site, like "My orders". The checkout fills in the name, email and last address of a buyer who is signed in on that site. Turn it off with the shop setting `buyer_account`. ### Product feed (where available) A public feed of your active products at a fixed address, shown by `get_shop` as `feed`: - `https://api.hypamoon.com/api/v1/public/shops/{project_id}/products.xml` for Google Merchant Center (add it there as a scheduled fetch); - `.../products.json`, the same items as simple JSON. Each variant is an item with `item_group_id` set to the product, with colour, size and material from its options. Price from the server (a sale price when `compare_at_cents` is higher), availability from the stock, the image of the variant or the product, and the link from the product's `url` or the pattern in the shop setting `feed.product_url` (like `https://example.com/products/{slug}`, with `{slug}`, `{id}` or `{sku}`). A product without a link is left out; `check_business_readiness` says which. Products with `"feed": false`, access products, gift cards and services are never in it. Updated every five minutes. Turn it off with `feed.enabled`. ### The checkout `POST /api/v1/public/checkout?token=hm_pub_...` is what `checkout.js` calls. It takes the same `items` and `fields` as the quote, plus `customer` (`email`, optional `name`; where available also `company`, `vat_number` and `country`, the country for VAT when there is no address), an optional `shipping_address`, an optional `billing_address` (the address on the invoice, where available), an optional `return_path`, a `turnstile_token` from the bot check, `waive_withdrawal` (`true`, required when the order has a download) and an `idempotency_key` (the same key twice gives one order). It answers with `order_id` and `checkout_url`; send the visitor there. Limits: 20 checkouts per network per hour, 300 per project per day. Where the shop settings are available, starting a checkout holds the stock of its lines for 30 minutes, or until the payment fails, expires or is canceled. Two buyers can never pay for the last item: the second one gets a `422` with the detail `Not enough stock.` before any payment starts. ### Orders `list_orders` and `get_order` (REST: `GET /api/v1/orders` and `GET /api/v1/orders/{id}`). Statuses: `open`, `paid`, `shipped`, `canceled`, `refunded`, and `charged_back` or `disputed` when the buyer's bank is involved. Every order is also copied into a private collection `orders` that can never be public; the copy is for reading. Where the shop settings are available, an order also has: | field | meaning | |---|---| | `number`, `order_number` | The order number from the project's own series, like `QS-1001`, next to the `id`. | | `fields` | The answers to the shop's `checkout_fields`; `items[].fields` has the answers per line. | | `amounts` | The breakdown: `subtotal`, `discount`, `shipping`, `tax`, `tax_included`, `gift_card`, `total`. | | `amount_refunded`, `refunds` | What has been refunded so far, also partly, with each refund (`id`, `amount`, `at`, `via` api or dashboard). | | `tracking` | `number` and `url`, from `update_order_status` with `shipped`. | Mark an order shipped with `update_order_status` (REST: `PATCH /api/v1/orders/{id}` with `{"status": "shipped", "tracking_number": "...", "tracking_url": "https://..."}`). ### Emails to the buyer - **Order confirmation**, always, once, when the order is paid. In the language and colours of the site, with the order number, the lines, the total, the buyer's answers, the seller's name, address and contact, the links to terms and privacy and, for physical products, the 14-day right of withdrawal with the model form. In the EU the buyer must get this confirmation by email; it cannot be turned off. With downloads it also has the link to the download page and the text the buyer ticked. - **Shipped**, with the tracking link, when you mark an order shipped (shop setting `emails.shipped`). - **Refunded**, with the amount, after every refund (shop setting `emails.refunded`). The emails come from a Hypamoon address with the shop's name as the sender, and replies go to the shop's `contact_email` (by default the project's billing email). ### Refunds `refund_order` (REST: `POST /api/v1/orders/{id}/refund`) refunds a paid or shipped order from your payment account, after an explicit yes from the user (`confirm: true`). Calling it twice refunds once. - Without `amount_cents`: everything that is left. The order becomes `refunded`, and the stock that went down for it goes back up, once, unless the order was shipped. The answer has `stock_restore` with what happened. - With `amount_cents` (where the shop settings are available): only that part. The order stays `paid` or `shipped`, `amount_refunded` goes up, the stock stays. You cannot refund more than is left. A refund you make in the dashboard of your payment account, in full or in part, is picked up the same way: the order shows it and the buyer gets the refund email. ### Shop settings (where available) `get_shop` and `configure_shop` (REST: `GET` and `PATCH /api/v1/shop`). One object; send only what changes, `null` clears a field. `get_shop` also shows the seller as buyers see them, from `set_company_details`, with what is missing. | setting | meaning | |---|---| | `currency` | The default for products without a currency. Default `EUR`. See Currencies. | | `payment_methods` | Optional: which of `card`, `apple_pay`, `paypal`, `ideal`, `bancontact` the checkout offers. Left out: everything you turned on that works in the order's currency. | | `prices_include_vat` | `true` by default, as consumers in the EU must see prices. | | `shop_name` | The sender name of buyer emails. Default: the site's name. | | `contact_email`, `contact_phone` | Where buyers reach you; the reply-to of every buyer email. | | `links` | `terms`, `privacy`, `returns`, `contact`: https links shown in the checkout and in every confirmation. You publish these pages yourself. | | `order_number` | `prefix` (like `QS-`) and `start` (default `1001`). | | `checkout_fields` | At most 5 questions to the buyer: `key`, `label` (text, or `{"en": "...", "nl": "..."}`), `type` (`text`, `textarea`, `select`, `checkbox`, `date`), `required`, `options` for a select, `max_length`. A field you did not declare is refused with an error that names it. | | `withdrawal_right` | `true` by default. `false` only if nothing in the shop has the right of withdrawal (made to order, perishable). | | `stock` | `deactivate_when_sold_out` (set `active` to false at stock 0) and `low_threshold` (send `stock.low`). | | `emails` | `shipped`, `refunded` (both on by default) and a `footer` line. | | `gift_cards` | `desk_for_viewers` (let viewers redeem at the desk), `redeem_url` (your own balance page; the QR links there with the code after `#`) and `terms` (a short line printed on every card). | | `tax` | How VAT works in the shop: `mode`, `country`, `none_reason`, `rates`, `country_rates`, `rates_confirmed`, `oss_pricing`, `reverse_charge`, `stripe_tax`. See VAT and invoices. | | `invoices` | `enabled` (default `true`), `prefix` (default `INV-`), `attach` (the PDF goes with the order confirmation, default `true`) and `note` (a line at the bottom of every invoice). | | `downloads` | `expires_days` (default 7) and `max_downloads` per file (default 5) for download links. | | `buyer_account` | `true` by default: buyers see their orders at `/_hypamoon/orders`. | | `feed` | `enabled` (default `true`), `product_url` (a link pattern with `{slug}`, `{id}` or `{sku}`) and `brand`. | ### Gift cards (where available) Sell gift cards on your site, issue them at the desk, and redeem them at the desk or online. Every card comes as a PDF in the look of your site that buyers can print or forward. **A gift card is a product.** Add a document to the product collection with `kind: "gift_card"` and a `gift_card` block: ```json { "name": "Gift card", "kind": "gift_card", "price_cents": 5000, "gift_card": { "mode": "amount", "amounts": [2500, 5000, 7500], "open_amount": { "min_cents": 1000, "max_cents": 25000 }, "validity_months": 24, "redeem": "both", "vat": "multi" } } ``` - `mode`: `amount` (a value in money) or `service` with `service`, like `"One haircut"`; then `price_cents` is what the card is worth. - `amounts` and `open_amount`: the buyer picks a fixed amount or any amount in the range. Without them the card is worth `price_cents`. - `validity_months`: at least and by default 24. In the Netherlands a gift card for consumers must be valid for at least two years. - `redeem`: `in_store`, `online` or `both`. - `vat`: `multi` (default) when the card can be used for things with different VAT rates; VAT is then due when it is used. `single` with `vat_rate` when it is for one thing with a known rate, like a haircut at 9%; VAT is then due when it is sold. Ask your accountant if you are not sure. - `pdf` (optional): the look of the PDF card. Leave it out and the card follows the site it was bought on: its name, accent colour (`--hm-accent`, the same variable the checkout reads), its own fonts and its icon next to the name. To change that: - `accent`: a colour like `"#9a4f27"` for the card. When no text is readable on it, the default stays. - `logo`: a path to an image on that same site, like `"/logo.png"` (png, jpg, webp, gif, or an SVG with only shapes in it), at most 512 kB. It replaces the name on the card. `"none"` prints only the name. - `font`: `site` (default), `sans`, `serif` or `mono`. The PDF never loads anything from another host: only files from the site itself. The checkout asks the buyer, per card: `amount_cents` (when there are amounts or an open amount), `recipient_name`, `recipient_email`, `message` and `send_on` (a date). Send them as `fields` on the line. **After payment** every card gets its own 16-character code (like `K7QM-3XRD-9PTA-WC2H`). The buyer gets an email with the card as a PDF; with `recipient_email` the recipient gets it too, right away or on `send_on`. The code is never stored in readable form: only as a fingerprint and encrypted. **Redeeming online.** The checkout has one field for a discount or gift card code (`code` on `POST /api/v1/public/checkout` and the quote). A gift card pays after VAT: it comes off the total, not off the price. A card cannot buy another card, and a card for `in_store` only does not work online. When the card covers everything, the order is paid at once without a payment page. When it covers part, the buyer pays the rest. If the rest would be below the minimum of the payment provider, the card is used for a little less. **At the desk.** Open **Gift cards** in the portal (`/gift-cards`), type the code or scan the QR with the phone camera, see the balance and take off part or all of it. The QR on the card opens a balance page with a download of the PDF, and a link to the desk for staff. Every change is in the card's history, with who did it. **From the API or your chat.** | tool | REST | what | |---|---|---| | `issue_gift_card` | `POST /api/v1/gift-cards` | A card without a payment: sold at the desk, or as an apology. Needs `amount_cents` and a `reason`; with `email` it is sent as a PDF (or on `send_on`). | | `check_gift_card` | `POST /api/v1/gift-cards/check` | Balance, valid until, status and history, by `code` or `id`. | | `redeem_gift_card` | `POST /api/v1/gift-cards/redeem` | Take `amount_cents` (or `"all"`) off. Send an `idempotency_key`: the same key never takes money off twice. The balance never goes below zero. | | `list_gift_cards` | `GET /api/v1/gift-cards` | Newest first, with `status`, `limit` and `before`. | | `void_gift_card` | `POST /api/v1/gift-cards/{id}/void` | Cancel a card with a `reason`. Cannot be undone. | | | `POST /api/v1/gift-cards/{id}/adjust` | Correct the balance (plus or minus) with a `reason` and an `idempotency_key`. | | | `GET /api/v1/gift-cards/{id}` | One card with its history. | | | `GET /api/v1/gift-cards/{id}/pdf` | The card as a PDF (`?download=1` to download). | Only an owner or builder can issue, redeem, cancel and correct. A viewer can redeem at the desk when you turn on `gift_cards.desk_for_viewers`. Editors never touch gift cards. **Refunds.** A full refund of an order puts the gift card part back on the card, and cancels cards that were bought in that order and not used yet. **Expiry.** A card past its date expires by itself; its history says so. ### VAT and invoices (where available) **You are the seller, and you stay responsible for your VAT.** Hypamoon calculates the VAT with the rates you confirm and puts it on an invoice in your name. The rate table we provide is a starting point to check, not tax advice. Check the rates for your products with your accountant or the tax authority before you sell. Hypamoon does not file any VAT return. #### Three modes Set `tax.mode` with `configure_shop`: | mode | for | what happens | |---|---|---| | `none` | A small business scheme (in the Netherlands the KOR), or a seller outside VAT. | No VAT on anything. The confirmation and the receipt say why (`none_reason`: `small_business`, `not_registered` or `exempt`). | | `domestic` | Most small shops: one country. | The rates of your own country (`tax.country`, by default the country of your address) for every buyer. Below the EU threshold for distance sales (10,000 euro a year to consumers in other EU countries together) this is also right for buyers elsewhere in the EU. | | `eu_oss` | Above that threshold, registered for the One Stop Shop. | Goods and digital products to a consumer in another EU country carry the VAT of that country. Services (a haircut, a lesson) stay at your own rate: they happen where you are. With prices including VAT, `oss_pricing` `same_gross` (default) keeps the price the same everywhere; `same_net` keeps the amount without VAT the same, so the price follows the rate. | Without a mode, nothing changes: VAT is not calculated and prices are what you set. **Prices including or excluding VAT.** `prices_include_vat` (default `true`): the VAT is taken out of the price. With `false` (a shop for businesses), the VAT comes on top. **Rates per product.** Every product (or variant) has `tax_class`: `standard`, `reduced`, `zero` or `exempt`. The rate comes from the table for the country, or from your own `tax.rates` (`{"standard": 21, "reduced": 9}`) and, with `eu_oss`, `tax.country_rates` (`{"FR": {"reduced": 5.5}}`). Where a country has more than one reduced rate, the table uses the highest; set your own when your product has another. `get_shop` with `include_vat_rates` returns the table with its date and source. When you have checked your rates, `configure_shop` with `{"tax": {"rates_confirmed": true}}`; when the table changes, `check_business_readiness` asks you to confirm again. **The order of the calculation.** A discount comes off the price before VAT (it lowers the taxable amount); shipping is added and follows the rates of the lines it ships; then VAT; a gift card pays from the total after VAT, as a means of payment. A gift card for several purposes (`gift_card.vat` `multi`) has no VAT when it is sold, only when it is used; one for a single purpose carries its rate when it is sold. **Exports.** Goods and digital products to a buyer outside the EU have no EU VAT (`treatment: "export"`); with prices including VAT, the VAT comes off. #### Business buyers in the EU The buy button and the cart do this for you: with VAT set up, the checkout shows the VAT amount and a box "I am buying for a business" with company name and VAT number (and, with `eu_oss` and no shipping address, the buyer's country). In your own cart, the checkout takes `customer.vat_number` (and the quote `vat_number`). We check it in the EU register VIES. Valid, from another EU country than yours, `tax.reverse_charge` on (the default) and your own VAT number known: no VAT, the price without VAT, and the invoice says "VAT reverse charged". The VIES consultation number is kept with the order as your proof. Not valid, or VIES not reachable right now: VAT is charged as for anyone else, the payment goes on, and the quote has a notice for the buyer in `tax.notices`. #### When your payment provider calculates the VAT Outside Europe, payments run on a second payment provider (see How the money moves). That provider can calculate the VAT itself on your own account. With `tax.stripe_tax: true` on such a project, it calculates the VAT in its checkout instead of our table, asks for the billing address, collects business VAT numbers, and creates the invoice on your own account there, so Hypamoon does not make one. You set up your tax registrations with that provider, and it charges for this. Prices must include VAT, so the amount the buyer pays never changes after the order is made. The payment provider for Europe does not calculate VAT. There, our own calculation and rate table always apply, and Hypamoon makes the invoice. The amount sent to the provider is the total with VAT. #### The invoice Once `tax.mode` is set and your details are complete (`set_company_details`: name, address, company number, and your VAT number unless the mode is `none`), every paid order gets an invoice in your name: - its own number from your series, like `INV-0001`, running on without gaps (a test order gets `TEST-INV-0001` from a separate series); - the date, your name, address, company number and VAT number, the buyer's name and address (and company and VAT number for a business), each line with quantity, unit price without VAT, rate and amount, the taxable amount and VAT per rate, the total, what a gift card paid, and the notes the law asks for (reverse charge, OSS, no VAT); - in the colours, letter and logo of your site; - attached to the order confirmation as a PDF (setting `invoices.attach`), and always as a link the buyer can open later. An invoice never changes. A refund, in full or in part, adds a **credit note** with the next number, the refunded amount spread over the rates of the invoice, and a reference to the invoice. It goes with the refund email. Hypamoon is not on the invoice: you are the seller. | tool | REST | what it does | |---|---|---| | `get_invoice` | `GET /api/v1/invoices?order_id=...`, `GET /api/v1/invoices/{id}`, `GET /api/v1/invoices/{id}/pdf` | The invoice of an order and its credit notes, with `pdf_url` and `buyer_url`. | | `create_invoice` | `POST /api/v1/invoices` with `{"order_id": "..."}` | Makes the invoice of a paid order that has none, for example one paid before your details were complete. | | `export_orders` | `GET /api/v1/shop/export?format=...&from=...&to=...` | CSV for your bookkeeping. | The invoices are also under Payments in the portal, next to each order. #### Export for your bookkeeping `export_orders` gives a CSV over a period (`from` included, `to` not; by default the current quarter): - `invoices`: one row per invoice and credit note; - `vat`: sales per VAT rate, country and treatment, credit notes subtracted; - `oss`: only the sales with the VAT of another EU country, per country and rate, for your quarterly OSS return. Amounts use a dot (`12.50`). A link with an accounting package comes later through webhooks; until then, the export is the way. ### Webhooks Subscribe with `create_webhook` (REST: `POST /api/v1/webhooks`), signed like every other webhook. | event | when | `data` | |---|---|---| | `order.paid` | A buyer paid. | `order` | | `order.shipped` | You marked the order shipped. Where available. | `order` | | `order.refunded` | Money went back, in full or in part, through the API or the dashboard. One event per refund. Where available. | `order` | | `order.canceled` | An open order was canceled or its payment expired. Where available. | `order` | | `stock.low` | A paid order brought a product to the shop's `low_threshold` or below. Where available. | `product` | | `gift_card.issued` | A card was issued, after a payment or with `issue_gift_card`. Where available. | `gift_card` (no code, no email address) | | `gift_card.redeemed` | Money was taken off a card, at the desk, online or through the API. | `gift_card`, `amount_cents`, `channel`, `order_id` | | `gift_card.expired` | A card passed its date with a balance left. | `gift_card`, `expired_cents` | ### Collections with a role `create_collection` with `role` makes a collection the shop uses: `products` (the product collection) or `orders` (the private copy of the orders, made by the first checkout if you do not make it yourself). `discounts` comes later. Gift cards are not a collection: use the gift card tools above. ### REST at a glance | method | path | scope | |---|---|---| | GET | `/api/v1/payments` | `project:read` | | POST | `/api/v1/payments/product-collection` | `data:write` | | GET | `/api/v1/shop` | `data:read` | | PATCH | `/api/v1/shop` | `project:write` | | GET | `/api/v1/orders` | `data:read` | | GET | `/api/v1/orders/{id}` | `data:read` | | PATCH | `/api/v1/orders/{id}` | `data:write` | | POST | `/api/v1/orders/{id}/refund` | `project:write` | | GET | `/api/v1/public/shops/{project_id}/products.xml` and `.json` | none (public) | | POST | `/api/v1/public/cart/quote` | public token | | POST | `/api/v1/public/checkout` | public token | | GET | `/api/v1/public/appearance` | public token | | GET | `/api/v1/gift-cards` | `data:read` | | POST | `/api/v1/gift-cards` | `data:write` | | POST | `/api/v1/gift-cards/check` | `data:read` | | POST | `/api/v1/gift-cards/redeem` | `data:write` | | GET | `/api/v1/gift-cards/{id}` | `data:read` | | GET | `/api/v1/gift-cards/{id}/pdf` | `data:read` | | POST | `/api/v1/gift-cards/{id}/void` | `data:write` | | POST | `/api/v1/gift-cards/{id}/adjust` | `data:write` | | GET | `/api/v1/invoices?order_id=...` | `data:read` | | POST | `/api/v1/invoices` | `data:write` | | GET | `/api/v1/invoices/{id}` | `data:read` | | GET | `/api/v1/invoices/{id}/pdf` | `data:read` | | GET | `/api/v1/shop/export` | `data:read` | | GET | `/api/v1/public/invoices/{id}?s=...` | the signed link from the buyer's email | These routes are not in the OpenAPI document yet; this page is their reference.