{
  "info": {
    "name": "Hypamoon API",
    "description": "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.\n\nCalls are inbound, plus outbound calls your assistant makes to people who expect them (POST /calls).\n\nEvery 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.\n\nAn 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.\n\nEvery error is `application/problem+json` with a `type` URL that explains the fix (`https://hypamoon.com/docs/errors/<slug>`). Every response carries an `X-Request-Id` header; include it when you ask us for help.\n\nField 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.\n\nGenerated from openapi.json (version 1.0.0). Set the variable apiKey to a test key (hm_test_...) first: test keys never reach the carrier and cost nothing.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "bearer",
    "bearer": [
      {
        "key": "token",
        "value": "{{apiKey}}",
        "type": "string"
      }
    ]
  },
  "variable": [
    {
      "key": "baseUrl",
      "value": "https://api.hypamoon.com/api/v1",
      "type": "string"
    },
    {
      "key": "apiKey",
      "value": "hm_test_...",
      "type": "string"
    },
    {
      "key": "publicToken",
      "value": "hm_pubtest_...",
      "type": "string"
    }
  ],
  "item": [
    {
      "name": "Domains",
      "description": "Your own domain for a site: connect a domain the user owns (nameservers, with existing records copied), or register a new one. DNS records are managed here.",
      "item": [
        {
          "name": "List the domains",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/domains",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains"
              ]
            },
            "description": "The domains of the project, newest first, each with its status, nameservers, site and next_step.\n\nScope: sites:read"
          },
          "response": []
        },
        {
          "name": "Connect a domain the user owns",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains"
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"domain\": \"example.com\",\n  \"site_id\": \"6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "List watched domains and their health",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/domains/health",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                "health"
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": []
        },
        {
          "name": "Watch a domain, or park it",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains/watch",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                "watch"
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"domain\": \"example.com\",\n  \"expected_hosts\": [\n    \"netlify\"\n  ],\n  \"park\": \"page\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Suggest a cleanup of left-over DNS records",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/dns-cleanup",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "dns-cleanup"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": []
        },
        {
          "name": "Remove left-over DNS records in one step",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/dns-cleanup",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "dns-cleanup"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"include_record_ids\": [\n    \"372e67954025e0ba6aaa6d586b9e0b59\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Put the records of a cleanup back",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/dns-cleanup/restore",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "dns-cleanup",
                "restore"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"cleanup_id\": \"0b6f0e0e-2a7c-4d39-9a8e-1d3c5b7a9e21\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Check whether a domain is available",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/domains/check?domain=",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                "check"
              ],
              "query": [
                {
                  "key": "domain",
                  "value": "",
                  "description": "The domain, like example.com."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": []
        },
        {
          "name": "Suggest available domains",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/domains/suggestions",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                "suggestions"
              ],
              "query": [
                {
                  "key": "query",
                  "value": "",
                  "description": "A few words, like 'corner bakery'. Required without names.",
                  "disabled": true
                },
                {
                  "key": "tlds",
                  "value": "",
                  "description": "Optional. Comma-separated extensions, like nl,com.",
                  "disabled": true
                },
                {
                  "key": "names",
                  "value": "",
                  "description": "Optional. Up to ten comma-separated names to check, with or without extension, like getcrumb,crumbhq.com.",
                  "disabled": true
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": []
        },
        {
          "name": "Register a domain",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "A unique key for this purchase, like a UUID."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains/register",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                "register"
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"domain\": \"example.nl\",\n  \"years\": 1,\n  \"accept_registry_terms\": true,\n  \"max_price_eur\": 7.32\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Get a domain",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/domains/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": []
        },
        {
          "name": "Update the settings of a domain",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"hsts\": \"on\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Disconnect a domain",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/domains/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write"
          },
          "response": []
        },
        {
          "name": "Check a domain and attach its site",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/activate",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "activate"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write"
          },
          "response": []
        },
        {
          "name": "Set the site of a domain",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/site",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "site"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "Puts one of the project's sites on the domain and on www, or removes it with null.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"site_id\": \"6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Get the transfer code of a domain",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/authcode",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "authcode"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write"
          },
          "response": []
        },
        {
          "name": "List the DNS records",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/dns-records",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "dns-records"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "The DNS records of a domain connected through its nameservers. managed_by_hypamoon marks the records that serve the site.\n\nScope: sites:read"
          },
          "response": []
        },
        {
          "name": "Add, change or delete a DNS record",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/dns-records",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "dns-records"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"type\": \"TXT\",\n  \"name\": \"@\",\n  \"content\": \"google-site-verification=abc123\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Undo the last DNS change",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/dns-records/undo",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "dns-records",
                "undo"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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).\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"confirm\": true\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Check a domain and suggest fixes",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/domains/diagnose",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                "diagnose"
              ],
              "query": [
                {
                  "key": "domain",
                  "value": "",
                  "description": "The domain or subdomain, like example.com or portal.example.com.",
                  "disabled": true
                },
                {
                  "key": "domain_id",
                  "value": "",
                  "description": "Instead of domain: the id of a domain of this project.",
                  "disabled": true
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": []
        },
        {
          "name": "Import a zone file",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/zone-file",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "zone-file"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"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\",\n  \"replace_existing\": false\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Compare the old and the new DNS",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/preflight",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "preflight"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": []
        },
        {
          "name": "Restore the DNS records",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/restore",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "restore"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Switch the nameservers",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains/:id/nameservers",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                ":id",
                "nameservers"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"use\": \"hypamoon\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Transfer a domain to Hypamoon",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "A unique key for this transfer, like a UUID."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/domains/transfer",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "domains",
                "transfer"
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"domain\": \"example.nl\",\n  \"authcode\": \"AbC123xyz!\",\n  \"accept_registry_terms\": true,\n  \"max_price_eur\": 7.32,\n  \"dns\": \"hypamoon\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "Project",
      "description": "The project behind the key and its company details.",
      "item": [
        {
          "name": "Get the project",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/project",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "project"
              ]
            },
            "description": "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.\n\nScope: project:read"
          },
          "response": [
            {
              "name": "200 The project.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/project",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "project"
                  ]
                },
                "description": "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.\n\nScope: project:read"
              },
              "status": "The project.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7d4e2b10-3c5a-4f6e-9b8d-1a2b3c4d5e6f\",\n  \"name\": \"Acme Plumbing\",\n  \"edition\": \"api\",\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"spending_limit_eur\": 100,\n  \"numbers_count\": 0,\n  \"mode\": \"live\",\n  \"rails\": {\n    \"phone\": \"live\",\n    \"outbound\": \"live\",\n    \"sms\": \"live\",\n    \"email\": \"live\",\n    \"payments\": \"live\",\n    \"domains\": \"test\"\n  },\n  \"verification\": {\n    \"account_type\": \"business\",\n    \"levels\": [\n      {\n        \"level\": \"test\",\n        \"status\": \"approved\",\n        \"reason\": null,\n        \"missing\": [],\n        \"unlocks\": [\n          \"test_keys\",\n          \"test_numbers\",\n          \"simulated_calls\"\n        ]\n      },\n      {\n        \"level\": \"company\",\n        \"status\": \"not_started\",\n        \"reason\": null,\n        \"missing\": [\n          \"payment_mandate\",\n          \"payment_authentication\",\n          \"phone_verification\"\n        ],\n        \"unlocks\": [\n          \"live_keys\",\n          \"national_numbers\",\n          \"inbound_calls\"\n        ]\n      },\n      {\n        \"level\": \"number_documents\",\n        \"status\": \"not_started\",\n        \"reason\": null,\n        \"missing\": [\n          \"level.company\",\n          \"document.proof_of_address\",\n          \"document.identity\",\n          \"document.company_registration\"\n        ],\n        \"unlocks\": [\n          \"geographic_numbers\"\n        ]\n      },\n      {\n        \"level\": \"full\",\n        \"status\": \"not_started\",\n        \"reason\": null,\n        \"missing\": [\n          \"level.company\",\n          \"director_identity\",\n          \"ubo_confirmation\",\n          \"review\"\n        ],\n        \"unlocks\": [\n          \"outbound_calls\",\n          \"spending_cap_above_500_eur\",\n          \"more_concurrent_calls\"\n        ]\n      }\n    ]\n  },\n  \"access\": null\n}"
            }
          ]
        },
        {
          "name": "Set the company or personal details",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/project/company",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "project",
                "company"
              ]
            },
            "description": "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.\n\nScope: project:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"company_name\": \"Acme Plumbing Ltd\",\n  \"registration_number\": \"01234567\",\n  \"address\": {\n    \"street\": \"Main Street\",\n    \"house_number\": \"40\",\n    \"postal_code\": \"SW1A 1AA\",\n    \"city\": \"London\",\n    \"country\": \"GB\"\n  },\n  \"contact\": {\n    \"name\": \"Jane Smith\",\n    \"phone\": \"+447700900123\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The company details as saved.",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/project/company",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "project",
                    "company"
                  ]
                },
                "description": "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.\n\nScope: project:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"company_name\": \"Acme Plumbing Ltd\",\n  \"registration_number\": \"01234567\",\n  \"address\": {\n    \"street\": \"Main Street\",\n    \"house_number\": \"40\",\n    \"postal_code\": \"SW1A 1AA\",\n    \"city\": \"London\",\n    \"country\": \"GB\"\n  },\n  \"contact\": {\n    \"name\": \"Jane Smith\",\n    \"phone\": \"+447700900123\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The company details as saved.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"account_type\": \"business\",\n  \"company_name\": \"Acme Plumbing Ltd\",\n  \"kvk_number\": null,\n  \"registration_number\": \"01234567\",\n  \"vat_number\": null,\n  \"vat_status\": \"not_required\",\n  \"vat_checked_at\": null,\n  \"address\": {\n    \"street\": \"Main Street\",\n    \"house_number\": \"40\",\n    \"postal_code\": \"SW1A 1AA\",\n    \"city\": \"London\",\n    \"country\": \"GB\"\n  },\n  \"contact\": {\n    \"name\": \"Jane Smith\",\n    \"phone\": \"+447700900123\"\n  },\n  \"registrar_contact_name\": null,\n  \"complete\": true\n}"
            }
          ]
        },
        {
          "name": "Get the blueprint for a kind of application",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/advice",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "advice"
              ],
              "query": [
                {
                  "key": "type",
                  "value": "webshop",
                  "description": "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.",
                  "disabled": true
                },
                {
                  "key": "description",
                  "value": "A recipe book for my family with photos, voice memos and a share link",
                  "description": "Only with type app: what the app should do, in the user's words (one or two sentences).",
                  "disabled": true
                },
                {
                  "key": "topic",
                  "value": "sandbox_blocks_upload",
                  "description": "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.",
                  "disabled": true
                },
                {
                  "key": "country",
                  "value": "NL",
                  "description": "Where the business is: an ISO country code like NL, or a name. Leave it out for the Netherlands.",
                  "disabled": true
                },
                {
                  "key": "audience",
                  "value": "consumers",
                  "description": "Who the user sells to; consumers (the default) adds the consumer rules.",
                  "disabled": true
                }
              ]
            },
            "description": "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.\n\nScope: project:read"
          },
          "response": [
            {
              "name": "200 The blueprint, with a short markdown summary. With topic: that one known problem (id, symptom, what_to_do, tell_user",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/advice",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "advice"
                  ],
                  "query": [
                    {
                      "key": "type",
                      "value": "webshop",
                      "description": "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.",
                      "disabled": true
                    },
                    {
                      "key": "description",
                      "value": "A recipe book for my family with photos, voice memos and a share link",
                      "description": "Only with type app: what the app should do, in the user's words (one or two sentences).",
                      "disabled": true
                    },
                    {
                      "key": "topic",
                      "value": "sandbox_blocks_upload",
                      "description": "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.",
                      "disabled": true
                    },
                    {
                      "key": "country",
                      "value": "NL",
                      "description": "Where the business is: an ISO country code like NL, or a name. Leave it out for the Netherlands.",
                      "disabled": true
                    },
                    {
                      "key": "audience",
                      "value": "consumers",
                      "description": "Who the user sells to; consumers (the default) adds the consumer rules.",
                      "disabled": true
                    }
                  ]
                },
                "description": "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.\n\nScope: project:read"
              },
              "status": "The blueprint, with a short markdown summary. With topic: that one known problem (id, symptom, what_to_do, tell_user, other_topics).",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"type\": \"webshop\",\n  \"title\": \"Webshop\",\n  \"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.\",\n  \"country\": \"NL\",\n  \"region\": \"eu\",\n  \"audience\": \"consumers\",\n  \"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.\",\n  \"ask_first\": [\n    {\n      \"id\": \"country\",\n      \"question\": \"Which country is your business in, and where do you sell to?\",\n      \"why\": \"It decides the rules (returns, VAT, what the site must show), the currency and the language.\",\n      \"default\": \"The Netherlands, selling within the Netherlands, in euro and Dutch.\",\n      \"blocking\": true\n    },\n    {\n      \"id\": \"products\",\n      \"question\": \"What do you sell, and roughly how many products? Do they come in variants like size or color?\",\n      \"why\": \"It decides how the product list is built and whether every variant needs its own price and stock.\",\n      \"default\": \"A small catalogue (up to 20 products) without variants; each variant would be its own product.\",\n      \"blocking\": true\n    },\n    {\n      \"id\": \"seller\",\n      \"question\": \"Do you sell as a business or as a private person, and to consumers or to other businesses?\",\n      \"why\": \"Consumer rules (14 days to withdraw, prices with VAT) apply when a business sells to consumers.\",\n      \"default\": \"A business selling to consumers.\",\n      \"blocking\": false\n    },\n    {\n      \"id\": \"shipping\",\n      \"question\": \"How do you ship, and what does shipping cost?\",\n      \"why\": \"Shipping costs must be clear before the customer orders.\",\n      \"default\": \"One flat shipping rate within the country, shown on every product page and before checkout; pickup on request.\",\n      \"blocking\": false\n    },\n    {\n      \"id\": \"returns\",\n      \"question\": \"How do you handle returns?\",\n      \"why\": \"Consumers in the EU and UK can usually withdraw within 14 days; the site has to explain how.\",\n      \"default\": \"The legal minimum: 14 days to withdraw, the customer pays return shipping, refund within 14 days.\",\n      \"blocking\": false\n    },\n    {\n      \"id\": \"stock\",\n      \"question\": \"Do you want the shop to track stock?\",\n      \"why\": \"With stock tracking a product goes off sale when it runs out.\",\n      \"default\": \"No stock tracking (unlimited); the owner takes a product off sale by hand.\",\n      \"blocking\": false\n    },\n    {\n      \"id\": \"payment_methods\",\n      \"question\": \"Which payment methods do you want to offer?\",\n      \"why\": \"Buyers expect their usual method (iDEAL in the Netherlands, cards elsewhere).\",\n      \"default\": \"What the user's own payment account offers (in the Netherlands usually iDEAL and cards).\",\n      \"blocking\": false\n    },\n    {\n      \"id\": \"order_emails\",\n      \"question\": \"Who should receive the order emails?\",\n      \"why\": \"Someone has to see every order and ship it.\",\n      \"default\": \"The project's billing email address.\",\n      \"blocking\": false\n    },\n    {\n      \"id\": \"language_currency\",\n      \"question\": \"Which language and currency should the shop use?\",\n      \"why\": \"Prices and texts must match where the customers are.\",\n      \"default\": \"The language and currency of the user's country.\",\n      \"blocking\": false\n    }\n  ],\n  \"build_plan\": [\n    {\n      \"id\": \"pages\",\n      \"title\": \"Pages first\",\n      \"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.\",\n      \"tools\": [\n        \"deploy_site\"\n      ]\n    },\n    {\n      \"id\": \"products\",\n      \"title\": \"Products as data\",\n      \"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.\",\n      \"tools\": [\n        \"create_product_collection\",\n        \"create_collection\",\n        \"insert_document\"\n      ]\n    },\n    {\n      \"id\": \"checkout\",\n      \"title\": \"A real checkout, or an honest order request\",\n      \"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.\",\n      \"tools\": [\n        \"get_payments_status\",\n        \"connect_payments\",\n        \"get_checkout_snippet\",\n        \"create_collection\",\n        \"get_form_snippet\"\n      ]\n    },\n    {\n      \"id\": \"orders\",\n      \"title\": \"Orders and emails\",\n      \"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.\",\n      \"tools\": [\n        \"list_orders\",\n        \"get_order\",\n        \"update_order_status\",\n        \"query_documents\",\n        \"send_email\",\n        \"add_email_domain\",\n        \"verify_email_domain\"\n      ]\n    },\n    {\n      \"id\": \"contact_form\",\n      \"title\": \"Contact form that really arrives\",\n      \"how\": \"create_collection \\\"contact\\\" with access {\\\"public_write\\\": true} and triggers {\\\"email_on_insert\\\": true}, then get_form_snippet on /contact.\",\n      \"tools\": [\n        \"create_collection\",\n        \"get_form_snippet\"\n      ]\n    },\n    {\n      \"id\": \"accounts\",\n      \"title\": \"Customer accounts (optional)\",\n      \"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.\",\n      \"tools\": [\n        \"deploy_app\",\n        \"set_table_owner_column\"\n      ],\n      \"optional\": true\n    },\n    {\n      \"id\": \"chat\",\n      \"title\": \"Chat that knows the shop (optional)\",\n      \"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.\",\n      \"tools\": [\n        \"create_collection\",\n        \"insert_document\",\n        \"create_chat_widget\"\n      ],\n      \"optional\": true\n    },\n    {\n      \"id\": \"structured_data\",\n      \"title\": \"Structured data for search\",\n      \"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.\",\n      \"tools\": [\n        \"deploy_site\"\n      ],\n      \"optional\": true\n    },\n    {\n      \"id\": \"launch\",\n      \"title\": \"Before launch\",\n      \"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.\",\n      \"tools\": [\n        \"check_readiness\",\n        \"label_site_version\"\n      ]\n    }\n  ],\n  \"go_live_checklist\": [\n    {\n      \"id\": \"company_details\",\n      \"level\": \"usually_required\",\n      \"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.\",\n      \"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.\",\n      \"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.\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"privacy_statement\",\n      \"level\": \"usually_required\",\n      \"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.\",\n      \"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.\",\n      \"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.\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"cookie_notice\",\n      \"level\": \"recommended\",\n      \"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.\",\n      \"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.\",\n      \"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.\",\n      \"checked_by_check_readiness\": false\n    },\n    {\n      \"id\": \"accessibility\",\n      \"level\": \"recommended\",\n      \"description\": \"Basic accessibility: readable contrast, alt text on images, labels on form fields, keyboard navigation, and an accessibility statement for online shops and services.\",\n      \"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.\",\n      \"how_on_hypamoon\": \"Build the pages with labels, alt text and enough contrast; add /accessibility if the business is not a micro-enterprise.\",\n      \"checked_by_check_readiness\": false\n    },\n    {\n      \"id\": \"prices_incl_vat\",\n      \"level\": \"usually_required\",\n      \"description\": \"Prices for consumers shown as the total price, VAT included, with the currency (for example € 19,95).\",\n      \"why\": \"Consumer law in the EU and UK requires the total price including taxes; a price without currency or VAT confuses buyers.\",\n      \"how_on_hypamoon\": \"Show prices with the currency on the page; with checkout, price_cents in the product collection is the price including VAT.\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"terms_and_conditions\",\n      \"level\": \"recommended\",\n      \"description\": \"Terms and conditions on /terms, linked in the footer and in the order flow, that the customer can read and save before ordering.\",\n      \"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.\",\n      \"how_on_hypamoon\": \"Write them with the user, put them on /terms with deploy_site and link them next to the order button.\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"withdrawal_right\",\n      \"level\": \"usually_required\",\n      \"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.\",\n      \"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.\",\n      \"how_on_hypamoon\": \"Put it on /returns with the model withdrawal form, link it in the footer and from the order flow (deploy_site).\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"withdrawal_function\",\n      \"level\": \"usually_required\",\n      \"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.\",\n      \"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.\",\n      \"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.\",\n      \"checked_by_check_readiness\": false\n    },\n    {\n      \"id\": \"shipping_costs_upfront\",\n      \"level\": \"usually_required\",\n      \"description\": \"Shipping costs, delivery restrictions (countries) and the expected delivery time shown before the customer starts ordering.\",\n      \"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.\",\n      \"how_on_hypamoon\": \"Show shipping costs on the product pages and next to the order button (deploy_site).\",\n      \"checked_by_check_readiness\": false\n    },\n    {\n      \"id\": \"payment_methods_upfront\",\n      \"level\": \"usually_required\",\n      \"description\": \"The accepted payment methods shown at the latest at the start of the ordering process.\",\n      \"why\": \"The Consumer Rights Directive asks for this at the start of the order.\",\n      \"how_on_hypamoon\": \"List the methods of the user's payment account on the product page or in the footer.\",\n      \"checked_by_check_readiness\": false\n    },\n    {\n      \"id\": \"order_button_text\",\n      \"level\": \"usually_required\",\n      \"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\\\".\",\n      \"why\": \"The EU Consumer Rights Directive and the UK rules require this; without it the customer may not be bound by the order.\",\n      \"how_on_hypamoon\": \"Set the button text on the checkout button (get_checkout_snippet html) or the order request button.\",\n      \"checked_by_check_readiness\": false\n    },\n    {\n      \"id\": \"order_confirmation\",\n      \"level\": \"usually_required\",\n      \"description\": \"An order confirmation by email (a durable medium) with what was ordered, the price, the company details and the withdrawal information.\",\n      \"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.\",\n      \"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.\",\n      \"checked_by_check_readiness\": false\n    },\n    {\n      \"id\": \"legal_guarantee\",\n      \"level\": \"recommended\",\n      \"description\": \"A line about the legal guarantee: products must be as promised, and consumers keep their legal rights when something is wrong.\",\n      \"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.\",\n      \"how_on_hypamoon\": \"Add it to /terms or /returns with deploy_site.\",\n      \"checked_by_check_readiness\": false\n    },\n    {\n      \"id\": \"complaints\",\n      \"level\": \"recommended\",\n      \"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).\",\n      \"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.\",\n      \"how_on_hypamoon\": \"Add it to /terms or /contact with deploy_site.\",\n      \"checked_by_check_readiness\": false\n    },\n    {\n      \"id\": \"age_check\",\n      \"level\": \"usually_required\",\n      \"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.\",\n      \"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.\",\n      \"how_on_hypamoon\": \"Only if relevant: add an age statement to the order form and tell the user to arrange the check at delivery.\",\n      \"checked_by_check_readiness\": false\n    },\n    {\n      \"id\": \"vat_registration\",\n      \"level\": \"recommended\",\n      \"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.\",\n      \"why\": \"Above that threshold the VAT of the buyer's country applies.\",\n      \"how_on_hypamoon\": \"Nothing to build; mention it to the user so they check it with their accountant.\",\n      \"checked_by_check_readiness\": false\n    },\n    {\n      \"id\": \"checkout_real\",\n      \"level\": \"usually_required\",\n      \"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.\",\n      \"why\": \"A button that does nothing loses the sale and makes the shop look fake.\",\n      \"how_on_hypamoon\": \"get_checkout_snippet when payments are ready, otherwise an order request form (create_collection, get_form_snippet).\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"structured_data\",\n      \"level\": \"recommended\",\n      \"description\": \"Structured data (JSON-LD from schema.org) that tells search engines what the business or product is: name, address, phone, opening hours, prices.\",\n      \"why\": \"Search engines can then show richer results, like opening hours, a price or a location on the map, instead of a plain link.\",\n      \"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.\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"forms_saved\",\n      \"level\": \"usually_required\",\n      \"description\": \"Every form really saves what a visitor sends (in a collection or through an app), and the owner sees or gets each entry.\",\n      \"why\": \"A form that goes nowhere loses customers without anyone noticing. This is not a law; it is what makes the site work.\",\n      \"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.\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"no_placeholders\",\n      \"level\": \"usually_required\",\n      \"description\": \"No lorem ipsum, example.com, \\\"your text here\\\" or other placeholder text, and no invented reviews, customers or numbers presented as real.\",\n      \"why\": \"Placeholders look unfinished; invented reviews or customers are misleading and, for consumers, an unfair commercial practice in the EU and UK.\",\n      \"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).\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"no_test_wording\",\n      \"level\": \"usually_required\",\n      \"description\": \"No test wording on the live site, like \\\"this is a test form\\\" or \\\"test environment\\\", once it is meant for real visitors.\",\n      \"why\": \"Visitors stop trusting a form that says it is a test, and they will not send real requests.\",\n      \"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.\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"internal_links\",\n      \"level\": \"recommended\",\n      \"description\": \"Every link inside the site leads to a page that exists, and buttons go somewhere real.\",\n      \"why\": \"Broken links and dead buttons make a site look like a mockup.\",\n      \"how_on_hypamoon\": \"Add the missing pages or fix the links with deploy_site.\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"not_found_page\",\n      \"level\": \"recommended\",\n      \"description\": \"A friendly not-found page (/404.html) with a link back home and the contact details.\",\n      \"why\": \"Old links and typos end there; a plain error page loses the visitor.\",\n      \"how_on_hypamoon\": \"Add /404.html with deploy_site.\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"no_fake_login\",\n      \"level\": \"usually_required\",\n      \"description\": \"No login that checks a password in JavaScript, and no member area that only hides content in the browser.\",\n      \"why\": \"Anyone can read the password in the page source; it protects nothing and gives the owner a false sense of safety.\",\n      \"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.\",\n      \"checked_by_check_readiness\": true\n    },\n    {\n      \"id\": \"no_local_storage_db\",\n      \"level\": \"usually_required\",\n      \"description\": \"Orders, bookings, sign-ups and messages are not kept in the visitor's browser (localStorage) as if it were a database.\",\n      \"why\": \"localStorage stays on the visitor's own device: the owner never sees it, and it is gone when they clear their browser.\",\n      \"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.\",\n      \"checked_by_check_readiness\": true\n    }\n  ],\n  \"pitfalls\": [\n    {\n      \"id\": \"fake_checkout\",\n      \"pitfall\": \"A \\\"Pay now\\\" or \\\"Add to cart\\\" button that does nothing, or a cart that only lives in the browser.\",\n      \"instead\": \"The real checkout when payments are ready, otherwise an honest order request that says payment follows later.\"\n    },\n    {\n      \"id\": \"price_in_page\",\n      \"pitfall\": \"Trusting a price that the page sends to the server.\",\n      \"instead\": \"The checkout always reads price_cents from the product collection; show the price as text on the page.\"\n    },\n    {\n      \"id\": \"no_shipping_costs\",\n      \"pitfall\": \"Shipping costs that only appear at the end.\",\n      \"instead\": \"Show shipping costs on the product page and before the order button.\"\n    },\n    {\n      \"id\": \"no_withdrawal\",\n      \"pitfall\": \"No returns page or withdrawal form for consumers.\",\n      \"instead\": \"A /returns page with the 14-day rules and the model form, linked in the footer.\"\n    },\n    {\n      \"id\": \"invented_reviews\",\n      \"pitfall\": \"Invented reviews, star ratings or \\\"1,000 happy customers\\\".\",\n      \"instead\": \"Only real reviews from the user, or none.\"\n    },\n    {\n      \"id\": \"variants_as_one\",\n      \"pitfall\": \"Variants with different prices in one product.\",\n      \"instead\": \"Each variant with its own price as its own product.\"\n    },\n    {\n      \"id\": \"forgot_order_email\",\n      \"pitfall\": \"Orders that nobody sees.\",\n      \"instead\": \"email_on_insert on order requests, list_orders for paid orders, and a clear owner email.\"\n    },\n    {\n      \"id\": \"users_round_trip\",\n      \"pitfall\": \"App code that fetches /app-users itself on every page to show the people of the app.\",\n      \"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.\"\n    }\n  ],\n  \"structured_data\": {\n    \"schema_type\": \"Product\",\n    \"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.\",\n    \"snippet\": \"<script type=\\\"application/ld+json\\\">\\n{\\n  \\\"@context\\\": \\\"https://schema.org\\\",\\n  \\\"@type\\\": \\\"Product\\\",\\n  \\\"name\\\": \\\"YOUR PRODUCT NAME\\\",\\n  \\\"description\\\": \\\"YOUR PRODUCT DESCRIPTION\\\",\\n  \\\"image\\\": \\\"YOUR PRODUCT IMAGE URL (absolute, https)\\\",\\n  \\\"offers\\\": {\\n    \\\"@type\\\": \\\"Offer\\\",\\n    \\\"price\\\": \\\"YOUR PRICE (like 19.95)\\\",\\n    \\\"priceCurrency\\\": \\\"YOUR CURRENCY CODE (like EUR)\\\",\\n    \\\"availability\\\": \\\"https://schema.org/InStock\\\",\\n    \\\"url\\\": \\\"YOUR PRODUCT PAGE URL\\\"\\n  }\\n}\\n</script>\",\n    \"required\": [\n      \"name\",\n      \"offers.price\",\n      \"offers.priceCurrency\"\n    ]\n  },\n  \"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).\",\n  \"other_types\": {\n    \"available\": [\n      \"webshop\",\n      \"bookings\",\n      \"restaurant\",\n      \"course_membership\",\n      \"wordpress_portal\"\n    ],\n    \"planned\": [\n      \"events\",\n      \"portfolio\",\n      \"internal_tool\",\n      \"saas\"\n    ]\n  },\n  \"known_problems\": [\n    {\n      \"id\": \"sandbox_blocks_upload\",\n      \"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.\",\n      \"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.\",\n      \"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.\"\n    },\n    {\n      \"id\": \"image_too_large\",\n      \"symptom\": \"site_too_large, or one request over 4 MB because of an image.\",\n      \"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.\",\n      \"tell_user\": \"That image is bigger than a website needs. I will make a lighter copy that looks the same on screen.\"\n    },\n    {\n      \"id\": \"version_conflict\",\n      \"symptom\": \"version_conflict (409 or 412) on deploy_site or set_call_plan.\",\n      \"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.\",\n      \"tell_user\": \"The site changed in the meantime, so I did not overwrite it. I will apply your change to the latest version.\"\n    },\n    {\n      \"id\": \"phone_verification_required\",\n      \"symptom\": \"phone_verification_required (403) when making a site public or connecting a domain.\",\n      \"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.\",\n      \"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.\"\n    },\n    {\n      \"id\": \"site_not_visible\",\n      \"symptom\": \"The user says the site works for them but not for others, or it does not show up in Google.\",\n      \"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.\",\n      \"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.\"\n    },\n    {\n      \"id\": \"is_hypamoon_down\",\n      \"symptom\": \"A step failed and it looks like Hypamoon might be down.\",\n      \"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.\",\n      \"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.)\"\n    }\n  ],\n  \"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)._\"\n}"
            }
          ]
        },
        {
          "name": "Move sites from the test project to the live project",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/project/move-to-live",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "project",
                "move-to-live"
              ]
            },
            "description": "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.\n\nScope: project:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"sites\": [\n    \"bakkerij-jansen\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 What moved, what stayed, and the next step.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/project/move-to-live",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "project",
                    "move-to-live"
                  ]
                },
                "description": "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.\n\nScope: project:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"sites\": [\n    \"bakkerij-jansen\"\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "What moved, what stayed, and the next step.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"0f6c2a8e-7d3b-4c1e-9a55-2b8f6d4e1c90\",\n  \"status\": \"done\",\n  \"from_project\": {\n    \"id\": \"6b1d2c3e-4f50-4a6b-8c7d-9e0f1a2b3c4d\",\n    \"name\": \"Bakkerij Jansen (test)\",\n    \"mode\": \"test\"\n  },\n  \"to_project\": {\n    \"id\": \"1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n    \"name\": \"Bakkerij Jansen\",\n    \"mode\": \"live\"\n  },\n  \"moved\": {\n    \"sites\": [\n      {\n        \"id\": \"3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f\",\n        \"slug\": \"bakkerij-jansen\",\n        \"url\": \"https://bakkerij-jansen.hypamoon.app\",\n        \"new_version\": 7,\n        \"form_token_replaced\": true\n      }\n    ],\n    \"collections\": [\n      {\n        \"id\": \"7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d\",\n        \"name\": \"contact\",\n        \"documents\": 12\n      }\n    ],\n    \"copied_collections\": [],\n    \"apps\": [],\n    \"agents\": [],\n    \"domains\": [],\n    \"form_tokens\": [\n      {\n        \"name\": \"Website form\",\n        \"live_token_id\": \"9c0d1e2f-3a4b-4c5d-8e6f-7a8b9c0d1e2f\",\n        \"replaces_test_token_id\": \"2f3a4b5c-6d7e-4f8a-9b0c-1d2e3f4a5b6c\"\n      }\n    ],\n    \"webhooks\": 0\n  },\n  \"stayed_in_test\": {\n    \"sites\": [],\n    \"collections\": [],\n    \"simulated_domains\": []\n  },\n  \"warnings\": [],\n  \"errors\": [],\n  \"undo\": {\n    \"until\": \"2026-10-10T09:00:00.000Z\",\n    \"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.\"\n  },\n  \"connection\": {\n    \"mode\": \"test\",\n    \"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.\"\n  },\n  \"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.\",\n  \"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).\"\n}"
            }
          ]
        },
        {
          "name": "Undo a move to live",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/project/move-to-live/undo",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "project",
                "move-to-live",
                "undo"
              ]
            },
            "description": "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.\n\nScope: project:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"move_id\": \"0f6c2a8e-7d3b-4c1e-9a55-2b8f6d4e1c90\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 What went back.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/project/move-to-live/undo",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "project",
                    "move-to-live",
                    "undo"
                  ]
                },
                "description": "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.\n\nScope: project:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"move_id\": \"0f6c2a8e-7d3b-4c1e-9a55-2b8f6d4e1c90\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "What went back.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"0f6c2a8e-7d3b-4c1e-9a55-2b8f6d4e1c90\",\n  \"status\": \"undone\",\n  \"sites\": [\n    \"3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f\"\n  ],\n  \"collections\": 1,\n  \"apps\": 0,\n  \"agents\": 0,\n  \"domains\": 0,\n  \"errors\": [],\n  \"summary\": \"Moved 1 site and 1 collection back from Bakkerij Jansen to Bakkerij Jansen (test).\",\n  \"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.\"\n}"
            }
          ]
        },
        {
          "name": "List recent errors",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/errors",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "errors"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "20",
                  "description": "Errors per page, 1 to 100.",
                  "disabled": true
                },
                {
                  "key": "before",
                  "value": "",
                  "description": "next_before from the previous page, for older errors.",
                  "disabled": true
                },
                {
                  "key": "request_id",
                  "value": "",
                  "description": "Only the errors of this request (the request id from an error message or the X-Request-Id header).",
                  "disabled": true
                },
                {
                  "key": "tool",
                  "value": "",
                  "description": "Only the errors of this MCP tool, like deploy_site.",
                  "disabled": true
                }
              ]
            },
            "description": "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.\n\nScope: project:read"
          },
          "response": [
            {
              "name": "200 The errors, newest first.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/errors",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "errors"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "20",
                      "description": "Errors per page, 1 to 100.",
                      "disabled": true
                    },
                    {
                      "key": "before",
                      "value": "",
                      "description": "next_before from the previous page, for older errors.",
                      "disabled": true
                    },
                    {
                      "key": "request_id",
                      "value": "",
                      "description": "Only the errors of this request (the request id from an error message or the X-Request-Id header).",
                      "disabled": true
                    },
                    {
                      "key": "tool",
                      "value": "",
                      "description": "Only the errors of this MCP tool, like deploy_site.",
                      "disabled": true
                    }
                  ]
                },
                "description": "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.\n\nScope: project:read"
              },
              "status": "The errors, newest first.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"48213\",\n      \"at\": \"2026-10-12T14:03:11.402Z\",\n      \"source\": \"mcp\",\n      \"client\": \"chatgpt\",\n      \"tool\": \"deploy_site\",\n      \"route\": null,\n      \"error\": \"validation_failed\",\n      \"status\": 422,\n      \"detail\": \"Some files could not be fetched. See 'errors'.\",\n      \"duration_ms\": 8123,\n      \"request_id\": \"req_7Hq2LmX9pRt4VwZ1aB3c\",\n      \"mode\": \"live\",\n      \"arguments\": {\n        \"name\": \"string(12)\",\n        \"files\": {\n          \"type\": \"array\",\n          \"n\": 14,\n          \"fields\": {\n            \"path\": 14,\n            \"content\": 11,\n            \"source_url\": 3\n          },\n          \"chars\": 48211\n        }\n      },\n      \"field_errors\": [\n        {\n          \"path\": \"files.12.source_url\",\n          \"message\": \"images.example.com answered HTTP 403: it blocks downloads by servers. Upload the file with create_upload_urls instead.\"\n        }\n      ]\n    }\n  ],\n  \"next_before\": null,\n  \"dropped_today\": 0\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Billing",
      "description": "The prepaid balance: every SMS, call minute and number is deducted from it, and AI usage (chat replies, agents, the portal assistant) is billed per use. Top up in the portal.",
      "item": [
        {
          "name": "Get the prepaid balance",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/balance",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "balance"
              ]
            },
            "description": "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.\n\nScope: project:read"
          },
          "response": [
            {
              "name": "200 The balance with the latest transactions.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/balance",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "balance"
                  ]
                },
                "description": "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.\n\nScope: project:read"
              },
              "status": "The balance with the latest transactions.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"balance_eur\": 24.46,\n  \"currency\": \"EUR\",\n  \"spent_this_month_eur\": 0.3,\n  \"spend_cap_eur\": 100,\n  \"auto_reload\": {\n    \"enabled\": true,\n    \"active\": true,\n    \"threshold_eur\": 5,\n    \"amount_eur\": 20,\n    \"max_per_month\": 10,\n    \"used_this_month\": 1,\n    \"disabled_reason\": null,\n    \"card\": {\n      \"brand\": \"Visa\",\n      \"last4\": \"4242\",\n      \"expiry\": \"2028-12-31\"\n    }\n  },\n  \"transactions\": [\n    {\n      \"id\": \"6e5d4c3b-2a19-4f8e-8d7c-6b5a4e3d2c1b\",\n      \"type\": \"charge\",\n      \"source\": \"sms\",\n      \"amount_eur\": -0.3,\n      \"balance_after_eur\": 24.46,\n      \"description\": \"SMS, 2 segments\",\n      \"quantity\": 2,\n      \"unit\": \"segment\",\n      \"reference_id\": \"8f7e6d5c-4b3a-4291-8f0e-1d2c3b4a5968\",\n      \"created_at\": \"2026-10-01T10:02:00.000Z\"\n    },\n    {\n      \"id\": \"4c3b2a19-0f8e-4d7c-9b6a-5e4d3c2b1a09\",\n      \"type\": \"top_up\",\n      \"source\": \"payment\",\n      \"amount_eur\": 20,\n      \"balance_after_eur\": 24.76,\n      \"description\": \"Top-up\",\n      \"quantity\": null,\n      \"unit\": null,\n      \"reference_id\": \"b7e5a1c4-2d93-4f60-8a1e-9c3d5b7f2e10\",\n      \"created_at\": \"2026-10-01T09:30:00.000Z\"\n    }\n  ],\n  \"test\": false\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Numbers",
      "description": "Search for, order and list phone numbers.",
      "item": [
        {
          "name": "List the numbers",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/numbers",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "numbers"
              ]
            },
            "description": "The phone numbers of the project, oldest first. Released numbers are left out.\n\nScope: numbers:read"
          },
          "response": [
            {
              "name": "200 The numbers.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/numbers",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "numbers"
                  ]
                },
                "description": "The phone numbers of the project, oldest first. Released numbers are left out.\n\nScope: numbers:read"
              },
              "status": "The numbers.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"5f0c9a2e-8b1d-4c3f-a6e7-2d9b0c1e4f53\",\n      \"e164\": \"+•••••••••42\",\n      \"type\": \"national\",\n      \"status\": \"active\",\n      \"created_at\": \"2026-10-01T09:00:00.000Z\",\n      \"test\": false\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Order a number",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "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."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/numbers",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "numbers"
              ]
            },
            "description": "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).\n\nScope: numbers:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"area\": \"020\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The number was ordered. status is active (it works now) or pending (the registration is with the carrier).",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "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."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/numbers",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "numbers"
                  ]
                },
                "description": "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).\n\nScope: numbers:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"area\": \"020\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The number was ordered. status is active (it works now) or pending (the registration is with the carrier).",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"5f0c9a2e-8b1d-4c3f-a6e7-2d9b0c1e4f53\",\n  \"e164\": \"+•••••••••42\",\n  \"type\": \"national\",\n  \"status\": \"active\",\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"test\": false,\n  \"from_stock\": true\n}"
            }
          ]
        },
        {
          "name": "Search for numbers to order",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/numbers/available",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "numbers",
                "available"
              ],
              "query": [
                {
                  "key": "type",
                  "value": "national",
                  "description": "national or geographic. Without it, the type follows from area: the national prefix or nothing is national.",
                  "disabled": true
                },
                {
                  "key": "area",
                  "value": "",
                  "description": "The area code. Required for geographic; leave it out for national.",
                  "disabled": true
                }
              ]
            },
            "description": "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.\n\nScope: numbers:read"
          },
          "response": [
            {
              "name": "200 The available numbers.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/numbers/available",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "numbers",
                    "available"
                  ],
                  "query": [
                    {
                      "key": "type",
                      "value": "national",
                      "description": "national or geographic. Without it, the type follows from area: the national prefix or nothing is national.",
                      "disabled": true
                    },
                    {
                      "key": "area",
                      "value": "",
                      "description": "The area code. Required for geographic; leave it out for national.",
                      "disabled": true
                    }
                  ]
                },
                "description": "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.\n\nScope: numbers:read"
              },
              "status": "The available numbers.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"e164\": \"+•••••••••42\",\n      \"type\": \"national\",\n      \"from_stock\": true,\n      \"test\": false\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Release a test number",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/numbers/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "numbers",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the number."
                }
              ]
            },
            "description": "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.\n\nScope: numbers:write"
          },
          "response": [
            {
              "name": "200 The test number was released.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/numbers/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "numbers",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the number."
                    }
                  ]
                },
                "description": "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.\n\nScope: numbers:write"
              },
              "status": "The test number was released.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"0d1e2f3a-4b5c-4d6e-8f70-8192a3b4c5d6\",\n  \"e164\": \"+447700900042\",\n  \"released\": true,\n  \"test\": true\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Assistant",
      "description": "The voice AI that answers your calls. One per project.",
      "item": [
        {
          "name": "Get the assistant",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/assistant",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "assistant"
              ]
            },
            "description": "The assistant of the project: its voice, languages, instructions, website and fixed greeting.\n\nScope: assistant:write"
          },
          "response": [
            {
              "name": "200 The assistant.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/assistant",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "assistant"
                  ]
                },
                "description": "The assistant of the project: its voice, languages, instructions, website and fixed greeting.\n\nScope: assistant:write"
              },
              "status": "The assistant.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"configured\": true,\n  \"voice\": \"ava\",\n  \"language\": \"en\",\n  \"languages\": [\n    \"en\"\n  ],\n  \"speech_recognition\": \"en\",\n  \"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.\",\n  \"website\": \"https://acme-plumbing.example/\",\n  \"greeting\": \"You are speaking with the digital assistant of Acme Plumbing. How can I help you?\",\n  \"synced_at\": \"2026-10-01T09:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Set the assistant",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/assistant",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "assistant"
              ]
            },
            "description": "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.\n\nScope: assistant:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"language\": \"en\",\n  \"voice\": \"ava\",\n  \"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.\",\n  \"website\": \"https://acme-plumbing.example\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The assistant as saved.",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/assistant",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "assistant"
                  ]
                },
                "description": "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.\n\nScope: assistant:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"language\": \"en\",\n  \"voice\": \"ava\",\n  \"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.\",\n  \"website\": \"https://acme-plumbing.example\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The assistant as saved.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"configured\": true,\n  \"voice\": \"ava\",\n  \"language\": \"en\",\n  \"languages\": [\n    \"en\"\n  ],\n  \"speech_recognition\": \"en\",\n  \"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.\",\n  \"website\": \"https://acme-plumbing.example/\",\n  \"greeting\": \"You are speaking with the digital assistant of Acme Plumbing. How can I help you?\",\n  \"synced_at\": \"2026-10-01T09:00:00.000Z\",\n  \"sync\": \"synced\",\n  \"call_plan\": \"added\",\n  \"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.\",\n  \"user_message\": \"Your assistant is ready and answers every call. If it is ever not available, callers can leave a message.\"\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Call plans",
      "description": "What happens when your number is called: ring your team, read out a text, hand over to the assistant.",
      "item": [
        {
          "name": "Get the call plan of a number",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/numbers/:id/call-plan",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "numbers",
                ":id",
                "call-plan"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the number."
                }
              ]
            },
            "description": "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.\n\nScope: numbers:read"
          },
          "response": [
            {
              "name": "200 The call plan that calls to this number follow: its own plan (source number) or, without one, the plan of the projec",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/numbers/:id/call-plan",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "numbers",
                    ":id",
                    "call-plan"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the number."
                    }
                  ]
                },
                "description": "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.\n\nScope: numbers:read"
              },
              "status": "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).",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"number_id\": \"5f0c9a2e-8b1d-4c3f-a6e7-2d9b0c1e4f53\",\n  \"version\": 1,\n  \"source\": \"number\",\n  \"plan\": {\n    \"entry\": \"team\",\n    \"nodes\": {\n      \"team\": {\n        \"id\": \"team\",\n        \"type\": \"forward\",\n        \"strategy\": \"sequential\",\n        \"targets\": [\n          {\n            \"kind\": \"external\",\n            \"e164\": \"+447700900123\"\n          }\n        ],\n        \"ringSeconds\": 20,\n        \"onNoAnswer\": \"ai\"\n      },\n      \"ai\": {\n        \"id\": \"ai\",\n        \"type\": \"ai_agent\"\n      }\n    }\n  }\n}"
            }
          ]
        },
        {
          "name": "Set the call plan of a number",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "If-Match",
                "value": "\"1\"",
                "description": "The version from the ETag of GET, like \"3\". An outdated version returns 412 version_conflict.",
                "disabled": true
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/numbers/:id/call-plan",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "numbers",
                ":id",
                "call-plan"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the number."
                }
              ]
            },
            "description": "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.\n\nScope: numbers:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"entry\": \"team\",\n  \"nodes\": {\n    \"team\": {\n      \"id\": \"team\",\n      \"type\": \"forward\",\n      \"strategy\": \"sequential\",\n      \"targets\": [\n        {\n          \"kind\": \"external\",\n          \"e164\": \"+447700900123\"\n        }\n      ],\n      \"ringSeconds\": 20,\n      \"onNoAnswer\": \"ai\"\n    },\n    \"ai\": {\n      \"id\": \"ai\",\n      \"type\": \"ai_agent\"\n    }\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The call plan as saved, with its new version.",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "If-Match",
                    "value": "\"1\"",
                    "description": "The version from the ETag of GET, like \"3\". An outdated version returns 412 version_conflict.",
                    "disabled": true
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/numbers/:id/call-plan",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "numbers",
                    ":id",
                    "call-plan"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the number."
                    }
                  ]
                },
                "description": "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.\n\nScope: numbers:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"entry\": \"team\",\n  \"nodes\": {\n    \"team\": {\n      \"id\": \"team\",\n      \"type\": \"forward\",\n      \"strategy\": \"sequential\",\n      \"targets\": [\n        {\n          \"kind\": \"external\",\n          \"e164\": \"+447700900123\"\n        }\n      ],\n      \"ringSeconds\": 20,\n      \"onNoAnswer\": \"ai\"\n    },\n    \"ai\": {\n      \"id\": \"ai\",\n      \"type\": \"ai_agent\"\n    }\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The call plan as saved, with its new version.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"number_id\": \"5f0c9a2e-8b1d-4c3f-a6e7-2d9b0c1e4f53\",\n  \"version\": 1,\n  \"plan\": {\n    \"entry\": \"team\",\n    \"nodes\": {\n      \"team\": {\n        \"id\": \"team\",\n        \"type\": \"forward\",\n        \"strategy\": \"sequential\",\n        \"targets\": [\n          {\n            \"kind\": \"external\",\n            \"e164\": \"+447700900123\"\n          }\n        ],\n        \"ringSeconds\": 20,\n        \"onNoAnswer\": \"ai\"\n      },\n      \"ai\": {\n        \"id\": \"ai\",\n        \"type\": \"ai_agent\"\n      }\n    }\n  },\n  \"assistant_sync\": \"synced\"\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Calls",
      "description": "Your calls, inbound and outbound, with transcript and summary. Your assistant can also call one person who expects it, like a customer with an appointment.",
      "item": [
        {
          "name": "List the calls",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/calls",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "calls"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "25",
                  "description": "Calls per page, default 25. A value above 100 is capped at 100.",
                  "disabled": true
                },
                {
                  "key": "cursor",
                  "value": "",
                  "description": "The next_cursor of the previous page.",
                  "disabled": true
                },
                {
                  "key": "direction",
                  "value": "outbound",
                  "description": "Only inbound calls, or only outbound calls (the ones your assistant made with POST /calls). Without it, both.",
                  "disabled": true
                }
              ]
            },
            "description": "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.\n\nScope: calls:read"
          },
          "response": [
            {
              "name": "200 A page of calls.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/calls",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "calls"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "25",
                      "description": "Calls per page, default 25. A value above 100 is capped at 100.",
                      "disabled": true
                    },
                    {
                      "key": "cursor",
                      "value": "",
                      "description": "The next_cursor of the previous page.",
                      "disabled": true
                    },
                    {
                      "key": "direction",
                      "value": "outbound",
                      "description": "Only inbound calls, or only outbound calls (the ones your assistant made with POST /calls). Without it, both.",
                      "disabled": true
                    }
                  ]
                },
                "description": "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.\n\nScope: calls:read"
              },
              "status": "A page of calls.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"c1a2b3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d\",\n      \"direction\": \"inbound\",\n      \"from\": \"+447700900456\",\n      \"to\": \"+•••••••••42\",\n      \"status\": \"ai_handled\",\n      \"created_at\": \"2026-10-01T09:14:02.000Z\",\n      \"started_at\": \"2026-10-01T09:14:02.000Z\",\n      \"answered_at\": \"2026-10-01T09:14:03.000Z\",\n      \"ended_at\": \"2026-10-01T09:15:37.000Z\",\n      \"duration_sec\": 94,\n      \"hangup_cause\": \"normal_clearing\",\n      \"has_summary\": true,\n      \"summary_ready_at\": null,\n      \"test\": false,\n      \"outbound\": null\n    }\n  ],\n  \"has_more\": false,\n  \"next_cursor\": null\n}"
            }
          ]
        },
        {
          "name": "Call someone with your assistant",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "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."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/calls",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "calls"
              ]
            },
            "description": "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.\n\nScope: calls:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"to\": \"+447700900321\",\n  \"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.\",\n  \"on_voicemail\": \"leave_message\",\n  \"voicemail_message\": \"We called about your appointment tomorrow at 10:00. Call us back on this number if you cannot make it.\",\n  \"max_duration_sec\": 300,\n  \"reference\": \"appointment-8812\",\n  \"metadata\": {\n    \"customer_id\": \"cus_4821\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The call was started (live: outbound.status queued or ringing) or, in test mode, simulated to its end result. The sa",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "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."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/calls",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "calls"
                  ]
                },
                "description": "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.\n\nScope: calls:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"to\": \"+447700900321\",\n  \"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.\",\n  \"on_voicemail\": \"leave_message\",\n  \"voicemail_message\": \"We called about your appointment tomorrow at 10:00. Call us back on this number if you cannot make it.\",\n  \"max_duration_sec\": 300,\n  \"reference\": \"appointment-8812\",\n  \"metadata\": {\n    \"customer_id\": \"cus_4821\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "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}.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"b7c8d9e0-f1a2-4b3c-8d4e-5f6a7b8c9d0e\",\n  \"direction\": \"outbound\",\n  \"from\": \"+•••••••••42\",\n  \"to\": \"+447700900321\",\n  \"status\": \"ringing\",\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"started_at\": \"2026-10-01T09:00:00.000Z\",\n  \"answered_at\": null,\n  \"ended_at\": null,\n  \"duration_sec\": null,\n  \"hangup_cause\": null,\n  \"has_summary\": false,\n  \"summary_ready_at\": null,\n  \"test\": false,\n  \"outbound\": {\n    \"status\": \"ringing\",\n    \"answered_by\": null,\n    \"voicemail_left\": false,\n    \"on_voicemail\": \"leave_message\",\n    \"max_duration_sec\": 300,\n    \"reference\": \"appointment-8812\",\n    \"metadata\": {\n      \"customer_id\": \"cus_4821\"\n    },\n    \"error\": null,\n    \"purpose\": \"transactional\"\n  },\n  \"transcript\": null,\n  \"summary\": null\n}"
            }
          ]
        },
        {
          "name": "Get a call with transcript and summary",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/calls/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "calls",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the call."
                }
              ]
            },
            "description": "One call, with its transcript and summary. The transcript is only here, never in a webhook.\n\nScope: calls:read"
          },
          "response": [
            {
              "name": "200 The call.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/calls/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "calls",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the call."
                    }
                  ]
                },
                "description": "One call, with its transcript and summary. The transcript is only here, never in a webhook.\n\nScope: calls:read"
              },
              "status": "The call.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"c1a2b3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d\",\n  \"direction\": \"inbound\",\n  \"from\": \"+447700900456\",\n  \"to\": \"+•••••••••42\",\n  \"status\": \"ai_handled\",\n  \"created_at\": \"2026-10-01T09:14:02.000Z\",\n  \"started_at\": \"2026-10-01T09:14:02.000Z\",\n  \"answered_at\": \"2026-10-01T09:14:03.000Z\",\n  \"ended_at\": \"2026-10-01T09:15:37.000Z\",\n  \"duration_sec\": 94,\n  \"hangup_cause\": \"normal_clearing\",\n  \"has_summary\": true,\n  \"summary_ready_at\": null,\n  \"test\": false,\n  \"outbound\": null,\n  \"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?\",\n  \"summary\": {\n    \"text\": \"The caller has a leaking kitchen tap and wants someone to come today.\",\n    \"topics\": [\n      \"leak\",\n      \"appointment\"\n    ],\n    \"sentiment\": \"neutral\",\n    \"action_items\": [\n      \"Call back within the hour to plan a visit.\"\n    ],\n    \"next_step\": \"Plan a visit today.\"\n  }\n}"
            }
          ]
        },
        {
          "name": "List recorded consent for marketing calls",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/call-consents",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "call-consents"
              ],
              "query": [
                {
                  "key": "phone_number",
                  "value": "+447700900321",
                  "description": "Optional: only the consents for this number, in E.164.",
                  "disabled": true
                }
              ]
            },
            "description": "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).\n\nScope: calls:read"
          },
          "response": [
            {
              "name": "200 The consents.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/call-consents",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "call-consents"
                  ],
                  "query": [
                    {
                      "key": "phone_number",
                      "value": "+447700900321",
                      "description": "Optional: only the consents for this number, in E.164.",
                      "disabled": true
                    }
                  ]
                },
                "description": "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).\n\nScope: calls:read"
              },
              "status": "The consents.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"5b8e2c1d-7a4f-4e3b-9c6d-1f2a3b4c5d6e\",\n      \"phone_number_ending\": \"21\",\n      \"source\": \"form\",\n      \"evidence\": \"Newsletter sign-up form, entry 7f3c2a, box 'call me about offers' ticked\",\n      \"consented_at\": \"2026-10-01T09:30:00.000Z\",\n      \"recorded_at\": \"2026-10-01T09:30:02.000Z\",\n      \"revoked_at\": null,\n      \"status\": \"active\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Record consent for marketing calls",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/call-consents",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "call-consents"
              ]
            },
            "description": "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.\n\nScope: calls:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"phone_number\": \"+447700900321\",\n  \"source\": \"form\",\n  \"evidence\": \"Newsletter sign-up form, entry 7f3c2a, box 'call me about offers' ticked\",\n  \"consented_at\": \"2026-10-01T09:30:00Z\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Valid consent for this number already existed; it is returned unchanged.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/call-consents",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "call-consents"
                  ]
                },
                "description": "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.\n\nScope: calls:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"phone_number\": \"+447700900321\",\n  \"source\": \"form\",\n  \"evidence\": \"Newsletter sign-up form, entry 7f3c2a, box 'call me about offers' ticked\",\n  \"consented_at\": \"2026-10-01T09:30:00Z\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Valid consent for this number already existed; it is returned unchanged.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"5b8e2c1d-7a4f-4e3b-9c6d-1f2a3b4c5d6e\",\n  \"phone_number_ending\": \"21\",\n  \"source\": \"form\",\n  \"evidence\": \"Newsletter sign-up form, entry 7f3c2a, box 'call me about offers' ticked\",\n  \"consented_at\": \"2026-10-01T09:30:00.000Z\",\n  \"recorded_at\": \"2026-10-01T09:30:02.000Z\",\n  \"revoked_at\": null,\n  \"status\": \"active\"\n}"
            },
            {
              "name": "201 Recorded.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/call-consents",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "call-consents"
                  ]
                },
                "description": "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.\n\nScope: calls:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"phone_number\": \"+447700900321\",\n  \"source\": \"form\",\n  \"evidence\": \"Newsletter sign-up form, entry 7f3c2a, box 'call me about offers' ticked\",\n  \"consented_at\": \"2026-10-01T09:30:00Z\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Recorded.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"5b8e2c1d-7a4f-4e3b-9c6d-1f2a3b4c5d6e\",\n  \"phone_number_ending\": \"21\",\n  \"source\": \"form\",\n  \"evidence\": \"Newsletter sign-up form, entry 7f3c2a, box 'call me about offers' ticked\",\n  \"consented_at\": \"2026-10-01T09:30:00.000Z\",\n  \"recorded_at\": \"2026-10-01T09:30:02.000Z\",\n  \"revoked_at\": null,\n  \"status\": \"active\"\n}"
            }
          ]
        },
        {
          "name": "Revoke consent for marketing calls",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/call-consents/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "call-consents",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the consent."
                }
              ]
            },
            "description": "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.\n\nScope: calls:write"
          },
          "response": [
            {
              "name": "200 The consent, now revoked.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/call-consents/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "call-consents",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the consent."
                    }
                  ]
                },
                "description": "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.\n\nScope: calls:write"
              },
              "status": "The consent, now revoked.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"5b8e2c1d-7a4f-4e3b-9c6d-1f2a3b4c5d6e\",\n  \"phone_number_ending\": \"21\",\n  \"source\": \"form\",\n  \"evidence\": \"Newsletter sign-up form, entry 7f3c2a, box 'call me about offers' ticked\",\n  \"consented_at\": \"2026-10-01T09:30:00.000Z\",\n  \"recorded_at\": \"2026-10-01T09:30:02.000Z\",\n  \"revoked_at\": \"2026-10-12T14:00:00.000Z\",\n  \"status\": \"revoked\"\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Webhooks",
      "description": "Receive an event for every call, signed with Hypamoon-Signature.",
      "item": [
        {
          "name": "List the webhook endpoints",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/webhooks",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "webhooks"
              ]
            },
            "description": "The project's webhook endpoints, oldest first. The secret is never included.\n\nScope: webhooks:write"
          },
          "response": [
            {
              "name": "200 The endpoints.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/webhooks",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "webhooks"
                  ]
                },
                "description": "The project's webhook endpoints, oldest first. The secret is never included.\n\nScope: webhooks:write"
              },
              "status": "The endpoints.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"9e8d7c6b-5a49-4382-9716-a5b4c3d2e1f0\",\n      \"url\": \"https://your-app.example/hooks/hypamoon\",\n      \"events\": [\n        \"call.completed\",\n        \"call.summary.ready\"\n      ],\n      \"active\": true,\n      \"secret_prefix\": \"whsec_Xk3p\",\n      \"created_at\": \"2026-10-01T09:00:00.000Z\",\n      \"last_success_at\": null,\n      \"last_failure_at\": null,\n      \"consecutive_failures\": 0\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Create a webhook endpoint",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/webhooks",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "webhooks"
              ]
            },
            "description": "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.\n\nScope: webhooks:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://your-app.example/hooks/hypamoon\",\n  \"events\": [\n    \"call.completed\",\n    \"call.summary.ready\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The endpoint, with its secret.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/webhooks",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "webhooks"
                  ]
                },
                "description": "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.\n\nScope: webhooks:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"https://your-app.example/hooks/hypamoon\",\n  \"events\": [\n    \"call.completed\",\n    \"call.summary.ready\"\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The endpoint, with its secret.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"9e8d7c6b-5a49-4382-9716-a5b4c3d2e1f0\",\n  \"url\": \"https://your-app.example/hooks/hypamoon\",\n  \"events\": [\n    \"call.completed\",\n    \"call.summary.ready\"\n  ],\n  \"active\": true,\n  \"secret_prefix\": \"whsec_Xk3p\",\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"last_success_at\": null,\n  \"last_failure_at\": null,\n  \"consecutive_failures\": 0,\n  \"secret\": \"whsec_Xk3pQ9vT2mLr8sYw4nBc6dFh1jKz5aGe7uIo0pRtVxE\"\n}"
            }
          ]
        },
        {
          "name": "Delete a webhook endpoint",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/webhooks/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "webhooks",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the webhook endpoint."
                }
              ]
            },
            "description": "Deletes the endpoint, its pending deliveries and its delivery log.\n\nScope: webhooks:write"
          },
          "response": []
        },
        {
          "name": "Switch a webhook endpoint back on",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/webhooks/:id/enable",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "webhooks",
                ":id",
                "enable"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the webhook endpoint."
                }
              ]
            },
            "description": "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.\n\nScope: webhooks:write"
          },
          "response": [
            {
              "name": "200 The endpoint.",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/webhooks/:id/enable",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "webhooks",
                    ":id",
                    "enable"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the webhook endpoint."
                    }
                  ]
                },
                "description": "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.\n\nScope: webhooks:write"
              },
              "status": "The endpoint.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"9e8d7c6b-5a49-4382-9716-a5b4c3d2e1f0\",\n  \"url\": \"https://your-app.example/hooks/hypamoon\",\n  \"events\": [\n    \"call.completed\",\n    \"call.summary.ready\"\n  ],\n  \"active\": true,\n  \"secret_prefix\": \"whsec_Xk3p\",\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"last_success_at\": null,\n  \"last_failure_at\": null,\n  \"consecutive_failures\": 0\n}"
            }
          ]
        },
        {
          "name": "Send a test event",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/webhooks/:id/test",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "webhooks",
                ":id",
                "test"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the webhook endpoint."
                }
              ]
            },
            "description": "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.\n\nScope: webhooks:write"
          },
          "response": [
            {
              "name": "200 The outcome of the first delivery.",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/webhooks/:id/test",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "webhooks",
                    ":id",
                    "test"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the webhook endpoint."
                    }
                  ]
                },
                "description": "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.\n\nScope: webhooks:write"
              },
              "status": "The outcome of the first delivery.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"delivery_id\": \"3b2a1908-f7e6-4d5c-8b4a-392817160514\",\n  \"event_id\": \"evt_4f1c2a9b8e7d6c5b4a39281706f5e4d3\",\n  \"status\": \"delivered\",\n  \"response_status\": 204,\n  \"error\": null\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Messages",
      "description": "Transactional SMS to mobile numbers: order confirmations, shipping updates, reminders. Not for marketing.",
      "item": [
        {
          "name": "List the messages",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/messages",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "messages"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "25",
                  "description": "Messages per page, default 25. A value above 100 is capped at 100.",
                  "disabled": true
                },
                {
                  "key": "cursor",
                  "value": "",
                  "description": "The next_cursor of the previous page.",
                  "disabled": true
                }
              ]
            },
            "description": "The SMS messages the project sent through the API, newest first, with their status. Follow next_cursor while has_more is true.\n\nScope: messages:read"
          },
          "response": [
            {
              "name": "200 A page of messages.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/messages",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "messages"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "25",
                      "description": "Messages per page, default 25. A value above 100 is capped at 100.",
                      "disabled": true
                    },
                    {
                      "key": "cursor",
                      "value": "",
                      "description": "The next_cursor of the previous page.",
                      "disabled": true
                    }
                  ]
                },
                "description": "The SMS messages the project sent through the API, newest first, with their status. Follow next_cursor while has_more is true.\n\nScope: messages:read"
              },
              "status": "A page of messages.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"8f7e6d5c-4b3a-4291-8f0e-1d2c3b4a5968\",\n      \"to\": \"+447700900321\",\n      \"from\": \"Acme Shop\",\n      \"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.\",\n      \"segments\": 1,\n      \"encoding\": \"gsm7\",\n      \"status\": \"queued\",\n      \"error\": null,\n      \"reference\": \"order-10432\",\n      \"created_at\": \"2026-10-01T09:00:00.000Z\",\n      \"status_updated_at\": \"2026-10-01T09:00:00.000Z\",\n      \"test\": true\n    }\n  ],\n  \"has_more\": false,\n  \"next_cursor\": null\n}"
            }
          ]
        },
        {
          "name": "Send an SMS",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "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."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/messages",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "messages"
              ]
            },
            "description": "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.\n\nScope: messages:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"to\": \"+447700900321\",\n  \"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.\",\n  \"reference\": \"order-10432\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The message was accepted (or, in test mode, simulated).",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "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."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/messages",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "messages"
                  ]
                },
                "description": "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.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"to\": \"+447700900321\",\n  \"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.\",\n  \"reference\": \"order-10432\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The message was accepted (or, in test mode, simulated).",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"8f7e6d5c-4b3a-4291-8f0e-1d2c3b4a5968\",\n  \"to\": \"+447700900321\",\n  \"from\": \"Acme Shop\",\n  \"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.\",\n  \"segments\": 1,\n  \"encoding\": \"gsm7\",\n  \"status\": \"queued\",\n  \"error\": null,\n  \"reference\": \"order-10432\",\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"status_updated_at\": \"2026-10-01T09:00:00.000Z\",\n  \"test\": true\n}"
            }
          ]
        },
        {
          "name": "Get a message",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/messages/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "messages",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the message."
                }
              ]
            },
            "description": "One SMS message with its current status: queued, sent, delivered or failed.\n\nScope: messages:read"
          },
          "response": [
            {
              "name": "200 The message.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/messages/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "messages",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the message."
                    }
                  ]
                },
                "description": "One SMS message with its current status: queued, sent, delivered or failed.\n\nScope: messages:read"
              },
              "status": "The message.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"8f7e6d5c-4b3a-4291-8f0e-1d2c3b4a5968\",\n  \"to\": \"+447700900321\",\n  \"from\": \"Acme Shop\",\n  \"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.\",\n  \"segments\": 1,\n  \"encoding\": \"gsm7\",\n  \"status\": \"queued\",\n  \"error\": null,\n  \"reference\": \"order-10432\",\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"status_updated_at\": \"2026-10-01T09:00:00.000Z\",\n  \"test\": true\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Chat",
      "description": "Text chat with the same assistant that answers your phone: same instructions, languages, opening hours and website. For a chat in your app or website, called from your backend.",
      "item": [
        {
          "name": "List the conversations",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/conversations",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "conversations"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "25",
                  "description": "Conversations per page, default 25. A value above 100 is capped at 100.",
                  "disabled": true
                },
                {
                  "key": "cursor",
                  "value": "",
                  "description": "The next_cursor of the previous page.",
                  "disabled": true
                },
                {
                  "key": "status",
                  "value": "open",
                  "description": "Only open or only closed conversations.",
                  "disabled": true
                }
              ]
            },
            "description": "The project's chat conversations, newest first, without messages. Follow next_cursor while has_more is true.\n\nScope: chat:read"
          },
          "response": [
            {
              "name": "200 A page of conversations.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/conversations",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "conversations"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "25",
                      "description": "Conversations per page, default 25. A value above 100 is capped at 100.",
                      "disabled": true
                    },
                    {
                      "key": "cursor",
                      "value": "",
                      "description": "The next_cursor of the previous page.",
                      "disabled": true
                    },
                    {
                      "key": "status",
                      "value": "open",
                      "description": "Only open or only closed conversations.",
                      "disabled": true
                    }
                  ]
                },
                "description": "The project's chat conversations, newest first, without messages. Follow next_cursor while has_more is true.\n\nScope: chat:read"
              },
              "status": "A page of conversations.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f\",\n      \"channel\": \"chat\",\n      \"status\": \"closed\",\n      \"reference\": \"session-8841\",\n      \"metadata\": {\n        \"page\": \"/pricing\",\n        \"plan\": \"pro\"\n      },\n      \"customer\": {\n        \"name\": \"Sam Jones\",\n        \"phone\": null,\n        \"email\": \"sam@example.com\"\n      },\n      \"language\": \"en\",\n      \"turns\": 2,\n      \"max_turns\": 30,\n      \"created_at\": \"2026-10-01T09:00:00.000Z\",\n      \"last_message_at\": \"2026-10-01T09:01:05.000Z\",\n      \"closed_at\": \"2026-10-01T09:31:05.000Z\",\n      \"close_reason\": \"inactive\",\n      \"has_summary\": true,\n      \"summary_ready_at\": \"2026-10-01T09:31:09.000Z\",\n      \"test\": false\n    }\n  ],\n  \"has_more\": false,\n  \"next_cursor\": null\n}"
            }
          ]
        },
        {
          "name": "Start a chat conversation",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "Optional but recommended: 1 to 255 visible ASCII characters without spaces. The same key within 24 hours returns the same conversation."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/conversations",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "conversations"
              ]
            },
            "description": "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.\n\nScope: chat:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"reference\": \"session-8841\",\n  \"metadata\": {\n    \"page\": \"/pricing\",\n    \"plan\": \"pro\"\n  },\n  \"customer\": {\n    \"name\": \"Sam Jones\",\n    \"email\": \"sam@example.com\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The conversation, with the greeting as its first message.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "Optional but recommended: 1 to 255 visible ASCII characters without spaces. The same key within 24 hours returns the same conversation."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/conversations",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "conversations"
                  ]
                },
                "description": "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.\n\nScope: chat:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"reference\": \"session-8841\",\n  \"metadata\": {\n    \"page\": \"/pricing\",\n    \"plan\": \"pro\"\n  },\n  \"customer\": {\n    \"name\": \"Sam Jones\",\n    \"email\": \"sam@example.com\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The conversation, with the greeting as its first message.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f\",\n  \"channel\": \"chat\",\n  \"status\": \"open\",\n  \"reference\": \"session-8841\",\n  \"metadata\": {\n    \"page\": \"/pricing\",\n    \"plan\": \"pro\"\n  },\n  \"customer\": {\n    \"name\": \"Sam Jones\",\n    \"phone\": null,\n    \"email\": \"sam@example.com\"\n  },\n  \"language\": \"en\",\n  \"turns\": 0,\n  \"max_turns\": 30,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"last_message_at\": \"2026-10-01T09:00:00.000Z\",\n  \"closed_at\": null,\n  \"close_reason\": null,\n  \"has_summary\": false,\n  \"summary_ready_at\": null,\n  \"test\": false,\n  \"messages\": [\n    {\n      \"id\": \"4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8\",\n      \"role\": \"assistant\",\n      \"text\": \"You are chatting with the digital assistant of Acme Plumbing. How can I help you?\",\n      \"created_at\": \"2026-10-01T09:00:00.000Z\"\n    }\n  ],\n  \"summary\": null\n}"
            }
          ]
        },
        {
          "name": "Get a conversation with its messages and summary",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/conversations/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "conversations",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the conversation."
                }
              ]
            },
            "description": "One conversation with all its messages, oldest first, and the summary once it is closed. The messages are only here, never in a webhook.\n\nScope: chat:read"
          },
          "response": [
            {
              "name": "200 The conversation.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/conversations/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "conversations",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the conversation."
                    }
                  ]
                },
                "description": "One conversation with all its messages, oldest first, and the summary once it is closed. The messages are only here, never in a webhook.\n\nScope: chat:read"
              },
              "status": "The conversation.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f\",\n  \"channel\": \"chat\",\n  \"status\": \"closed\",\n  \"reference\": \"session-8841\",\n  \"metadata\": {\n    \"page\": \"/pricing\",\n    \"plan\": \"pro\"\n  },\n  \"customer\": {\n    \"name\": \"Sam Jones\",\n    \"phone\": null,\n    \"email\": \"sam@example.com\"\n  },\n  \"language\": \"en\",\n  \"turns\": 2,\n  \"max_turns\": 30,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"last_message_at\": \"2026-10-01T09:01:05.000Z\",\n  \"closed_at\": \"2026-10-01T09:31:05.000Z\",\n  \"close_reason\": \"inactive\",\n  \"has_summary\": true,\n  \"summary_ready_at\": \"2026-10-01T09:31:09.000Z\",\n  \"test\": false,\n  \"messages\": [\n    {\n      \"id\": \"4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8\",\n      \"role\": \"assistant\",\n      \"text\": \"You are chatting with the digital assistant of Acme Plumbing. How can I help you?\",\n      \"created_at\": \"2026-10-01T09:00:00.000Z\"\n    },\n    {\n      \"id\": \"5f6a7b8c-9d0e-4f1a-b2c3-d4e5f6a7b8c9\",\n      \"role\": \"customer\",\n      \"text\": \"My kitchen tap is leaking. Can someone come today?\",\n      \"created_at\": \"2026-10-01T09:00:20.000Z\"\n    },\n    {\n      \"id\": \"6a7b8c9d-0e1f-4a2b-83c4-e5f6a7b8c9d0\",\n      \"role\": \"assistant\",\n      \"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?\",\n      \"created_at\": \"2026-10-01T09:00:22.000Z\"\n    }\n  ],\n  \"summary\": {\n    \"text\": \"Sam Jones has a leaking kitchen tap and wants someone to come today. Sam left the number +447700900321 for a call back.\",\n    \"topics\": [\n      \"leak\",\n      \"visit\"\n    ],\n    \"sentiment\": \"neutral\",\n    \"action_items\": [\n      \"Call Sam Jones back on +447700900321 within the hour to plan a visit.\"\n    ],\n    \"next_step\": \"Call back within the hour.\"\n  }\n}"
            }
          ]
        },
        {
          "name": "Send a customer message and get the reply",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "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."
              },
              {
                "key": "Accept",
                "value": "application/json",
                "description": "text/event-stream streams the reply as Server-Sent Events; anything else returns a single JSON response.",
                "disabled": true
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/conversations/:id/messages",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "conversations",
                ":id",
                "messages"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the conversation."
                }
              ]
            },
            "description": "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.\n\nScope: chat:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"text\": \"My kitchen tap is leaking. Can someone come today?\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The customer's message and the assistant's reply.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "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."
                  },
                  {
                    "key": "Accept",
                    "value": "application/json",
                    "description": "text/event-stream streams the reply as Server-Sent Events; anything else returns a single JSON response.",
                    "disabled": true
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/conversations/:id/messages",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "conversations",
                    ":id",
                    "messages"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the conversation."
                    }
                  ]
                },
                "description": "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.\n\nScope: chat:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"text\": \"My kitchen tap is leaking. Can someone come today?\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The customer's message and the assistant's reply.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"message\": {\n    \"id\": \"5f6a7b8c-9d0e-4f1a-b2c3-d4e5f6a7b8c9\",\n    \"role\": \"customer\",\n    \"text\": \"My kitchen tap is leaking. Can someone come today?\",\n    \"created_at\": \"2026-10-01T09:00:20.000Z\"\n  },\n  \"reply\": {\n    \"id\": \"6a7b8c9d-0e1f-4a2b-83c4-e5f6a7b8c9d0\",\n    \"role\": \"assistant\",\n    \"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?\",\n    \"created_at\": \"2026-10-01T09:00:22.000Z\"\n  },\n  \"conversation\": {\n    \"id\": \"2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f\",\n    \"channel\": \"chat\",\n    \"status\": \"open\",\n    \"reference\": \"session-8841\",\n    \"metadata\": {\n      \"page\": \"/pricing\",\n      \"plan\": \"pro\"\n    },\n    \"customer\": {\n      \"name\": \"Sam Jones\",\n      \"phone\": null,\n      \"email\": \"sam@example.com\"\n    },\n    \"language\": \"en\",\n    \"turns\": 1,\n    \"max_turns\": 30,\n    \"created_at\": \"2026-10-01T09:00:00.000Z\",\n    \"last_message_at\": \"2026-10-01T09:00:20.000Z\",\n    \"closed_at\": null,\n    \"close_reason\": null,\n    \"has_summary\": false,\n    \"summary_ready_at\": null,\n    \"test\": false\n  }\n}"
            }
          ]
        },
        {
          "name": "Close a conversation",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/conversations/:id/close",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "conversations",
                ":id",
                "close"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the conversation."
                }
              ]
            },
            "description": "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.\n\nScope: chat:write"
          },
          "response": [
            {
              "name": "200 The conversation, closed.",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/conversations/:id/close",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "conversations",
                    ":id",
                    "close"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the conversation."
                    }
                  ]
                },
                "description": "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.\n\nScope: chat:write"
              },
              "status": "The conversation, closed.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f\",\n  \"channel\": \"chat\",\n  \"status\": \"closed\",\n  \"reference\": \"session-8841\",\n  \"metadata\": {\n    \"page\": \"/pricing\",\n    \"plan\": \"pro\"\n  },\n  \"customer\": {\n    \"name\": \"Sam Jones\",\n    \"phone\": null,\n    \"email\": \"sam@example.com\"\n  },\n  \"language\": \"en\",\n  \"turns\": 2,\n  \"max_turns\": 30,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"last_message_at\": \"2026-10-01T09:01:05.000Z\",\n  \"closed_at\": \"2026-10-01T09:31:05.000Z\",\n  \"close_reason\": \"closed\",\n  \"has_summary\": false,\n  \"summary_ready_at\": null,\n  \"test\": false\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Chat widget",
      "description": "A chat window on your website without your own backend: one script tag with a public chat token. Manage tokens with your API key (/chat-tokens); the widget itself calls the /public routes from the visitor's browser.",
      "item": [
        {
          "name": "List the public chat tokens",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/chat-tokens",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "chat-tokens"
              ]
            },
            "description": "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.\n\nScope: chat:read"
          },
          "response": [
            {
              "name": "200 The tokens and the daily spending limit.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/chat-tokens",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "chat-tokens"
                  ]
                },
                "description": "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.\n\nScope: chat:read"
              },
              "status": "The tokens and the daily spending limit.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b\",\n      \"name\": \"acme-plumbing.example\",\n      \"display_name\": \"Acme Plumbing\",\n      \"token\": \"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\",\n      \"mode\": \"live\",\n      \"allowed_origins\": [\n        \"https://www.acme-plumbing.example\",\n        \"https://acme-plumbing.example\"\n      ],\n      \"turnstile\": true,\n      \"max_conversations_per_day\": 200,\n      \"revoked\": false,\n      \"revoked_at\": null,\n      \"last_used_at\": null,\n      \"created_at\": \"2026-10-01T09:00:00.000Z\",\n      \"embed_code\": \"<script src=\\\"https://api.hypamoon.com/widget.js\\\" data-token=\\\"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\\\" async></script>\",\n      \"kind\": \"chat\"\n    }\n  ],\n  \"daily_spend_limit_eur\": 5\n}"
            }
          ]
        },
        {
          "name": "Create a public chat token for the chat widget",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/chat-tokens",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "chat-tokens"
              ]
            },
            "description": "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).\n\nScope: chat:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"acme-plumbing.example\",\n  \"allowed_origins\": [\n    \"https://www.acme-plumbing.example\",\n    \"https://acme-plumbing.example\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The token, with embed_code.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/chat-tokens",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "chat-tokens"
                  ]
                },
                "description": "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).\n\nScope: chat:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"acme-plumbing.example\",\n  \"allowed_origins\": [\n    \"https://www.acme-plumbing.example\",\n    \"https://acme-plumbing.example\"\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The token, with embed_code.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b\",\n  \"name\": \"acme-plumbing.example\",\n  \"display_name\": \"Acme Plumbing\",\n  \"token\": \"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\",\n  \"mode\": \"live\",\n  \"allowed_origins\": [\n    \"https://www.acme-plumbing.example\",\n    \"https://acme-plumbing.example\"\n  ],\n  \"turnstile\": true,\n  \"max_conversations_per_day\": 200,\n  \"revoked\": false,\n  \"revoked_at\": null,\n  \"last_used_at\": null,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"embed_code\": \"<script src=\\\"https://api.hypamoon.com/widget.js\\\" data-token=\\\"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\\\" async></script>\",\n  \"kind\": \"chat\"\n}"
            }
          ]
        },
        {
          "name": "Get a public chat token",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/chat-tokens/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "chat-tokens",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the public chat token."
                }
              ]
            },
            "description": "One public chat token with its embed_code.\n\nScope: chat:read"
          },
          "response": [
            {
              "name": "200 The token.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/chat-tokens/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "chat-tokens",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the public chat token."
                    }
                  ]
                },
                "description": "One public chat token with its embed_code.\n\nScope: chat:read"
              },
              "status": "The token.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b\",\n  \"name\": \"acme-plumbing.example\",\n  \"display_name\": \"Acme Plumbing\",\n  \"token\": \"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\",\n  \"mode\": \"live\",\n  \"allowed_origins\": [\n    \"https://www.acme-plumbing.example\",\n    \"https://acme-plumbing.example\"\n  ],\n  \"turnstile\": true,\n  \"max_conversations_per_day\": 200,\n  \"revoked\": false,\n  \"revoked_at\": null,\n  \"last_used_at\": null,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"embed_code\": \"<script src=\\\"https://api.hypamoon.com/widget.js\\\" data-token=\\\"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\\\" async></script>\",\n  \"kind\": \"chat\"\n}"
            }
          ]
        },
        {
          "name": "Change a public chat token",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/chat-tokens/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "chat-tokens",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the public chat token."
                }
              ]
            },
            "description": "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.\n\nScope: chat:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"allowed_origins\": [\n    \"https://www.acme-plumbing.example\",\n    \"https://acme-plumbing.example\",\n    \"https://shop.acme-plumbing.example\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The token as changed.",
              "originalRequest": {
                "method": "PATCH",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/chat-tokens/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "chat-tokens",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the public chat token."
                    }
                  ]
                },
                "description": "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.\n\nScope: chat:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"allowed_origins\": [\n    \"https://www.acme-plumbing.example\",\n    \"https://acme-plumbing.example\",\n    \"https://shop.acme-plumbing.example\"\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The token as changed.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b\",\n  \"name\": \"acme-plumbing.example\",\n  \"display_name\": \"Acme Plumbing\",\n  \"token\": \"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\",\n  \"mode\": \"live\",\n  \"allowed_origins\": [\n    \"https://www.acme-plumbing.example\",\n    \"https://acme-plumbing.example\",\n    \"https://shop.acme-plumbing.example\"\n  ],\n  \"turnstile\": true,\n  \"max_conversations_per_day\": 200,\n  \"revoked\": false,\n  \"revoked_at\": null,\n  \"last_used_at\": null,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"embed_code\": \"<script src=\\\"https://api.hypamoon.com/widget.js\\\" data-token=\\\"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\\\" async></script>\",\n  \"kind\": \"chat\"\n}"
            }
          ]
        },
        {
          "name": "Revoke a public chat token",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/chat-tokens/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "chat-tokens",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the public chat token."
                }
              ]
            },
            "description": "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.\n\nScope: chat:write"
          },
          "response": [
            {
              "name": "200 The token, revoked.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/chat-tokens/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "chat-tokens",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the public chat token."
                    }
                  ]
                },
                "description": "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.\n\nScope: chat:write"
              },
              "status": "The token, revoked.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b\",\n  \"name\": \"acme-plumbing.example\",\n  \"display_name\": \"Acme Plumbing\",\n  \"token\": \"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\",\n  \"mode\": \"live\",\n  \"allowed_origins\": [\n    \"https://www.acme-plumbing.example\",\n    \"https://acme-plumbing.example\"\n  ],\n  \"turnstile\": true,\n  \"max_conversations_per_day\": 200,\n  \"revoked\": true,\n  \"revoked_at\": \"2026-10-02T08:00:00.000Z\",\n  \"last_used_at\": null,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"embed_code\": \"<script src=\\\"https://api.hypamoon.com/widget.js\\\" data-token=\\\"hm_pub_4mXq9TbR2kLw7NcV1sHd8YzE\\\" async></script>\",\n  \"kind\": \"chat\"\n}"
            }
          ]
        },
        {
          "name": "Get the widget settings (public)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/public/config?token={{publicToken}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "public",
                "config"
              ],
              "query": [
                {
                  "key": "token",
                  "value": "{{publicToken}}",
                  "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                }
              ]
            },
            "description": "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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
            "auth": {
              "type": "noauth"
            }
          },
          "response": [
            {
              "name": "200 The settings of the widget.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/public/config?token={{publicToken}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "public",
                    "config"
                  ],
                  "query": [
                    {
                      "key": "token",
                      "value": "{{publicToken}}",
                      "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                    }
                  ]
                },
                "description": "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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
                "auth": {
                  "type": "noauth"
                }
              },
              "status": "The settings of the widget.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"name\": \"Acme Plumbing\",\n  \"greeting\": \"You are chatting with the digital assistant of Acme Plumbing. How can I help you?\",\n  \"language\": \"en\",\n  \"languages\": [\n    \"en\"\n  ],\n  \"mode\": \"live\",\n  \"turnstile\": true,\n  \"max_message_length\": 2000,\n  \"max_turns\": 30\n}"
            }
          ]
        },
        {
          "name": "Start a conversation from the widget (public)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/public/conversations?token={{publicToken}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "public",
                "conversations"
              ],
              "query": [
                {
                  "key": "token",
                  "value": "{{publicToken}}",
                  "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                }
              ]
            },
            "description": "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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
            "auth": {
              "type": "noauth"
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"turnstile_token\": \"0.zXnA8v1rT3...from-the-bot-check\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The conversation with the greeting and its secret.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/public/conversations?token={{publicToken}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "public",
                    "conversations"
                  ],
                  "query": [
                    {
                      "key": "token",
                      "value": "{{publicToken}}",
                      "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                    }
                  ]
                },
                "description": "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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"turnstile_token\": \"0.zXnA8v1rT3...from-the-bot-check\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The conversation with the greeting and its secret.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"conversation\": {\n    \"id\": \"2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f\",\n    \"channel\": \"widget\",\n    \"status\": \"open\",\n    \"language\": \"en\",\n    \"turns\": 0,\n    \"max_turns\": 30,\n    \"created_at\": \"2026-10-01T09:00:00.000Z\",\n    \"last_message_at\": \"2026-10-01T09:00:00.000Z\",\n    \"closed_at\": null,\n    \"close_reason\": null,\n    \"test\": false\n  },\n  \"messages\": [\n    {\n      \"id\": \"4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8\",\n      \"role\": \"assistant\",\n      \"text\": \"You are chatting with the digital assistant of Acme Plumbing. How can I help you?\",\n      \"created_at\": \"2026-10-01T09:00:00.000Z\"\n    }\n  ],\n  \"conversation_token\": \"v1.2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f.1b2c3d4e-5f60-4718-8a9b-0c1d2e3f4a5b.1790866800.Qm9vZ3N0ZWVuLXZvb3JiZWVsZC1oYW5kdGVrZW5pbmc\",\n  \"expires_at\": \"2026-10-01T15:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Get the visitor's conversation (public)",
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "X-Conversation-Token",
                "value": "",
                "description": "The conversation_token from POST /public/conversations. Only the visitor who started the conversation has it."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/public/conversations/:id?token={{publicToken}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "public",
                "conversations",
                ":id"
              ],
              "query": [
                {
                  "key": "token",
                  "value": "{{publicToken}}",
                  "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the conversation."
                }
              ]
            },
            "description": "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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
            "auth": {
              "type": "noauth"
            }
          },
          "response": [
            {
              "name": "200 The conversation with its messages.",
              "originalRequest": {
                "method": "GET",
                "header": [
                  {
                    "key": "X-Conversation-Token",
                    "value": "",
                    "description": "The conversation_token from POST /public/conversations. Only the visitor who started the conversation has it."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/public/conversations/:id?token={{publicToken}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "public",
                    "conversations",
                    ":id"
                  ],
                  "query": [
                    {
                      "key": "token",
                      "value": "{{publicToken}}",
                      "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the conversation."
                    }
                  ]
                },
                "description": "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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
                "auth": {
                  "type": "noauth"
                }
              },
              "status": "The conversation with its messages.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"conversation\": {\n    \"id\": \"2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f\",\n    \"channel\": \"widget\",\n    \"status\": \"open\",\n    \"language\": \"en\",\n    \"turns\": 1,\n    \"max_turns\": 30,\n    \"created_at\": \"2026-10-01T09:00:00.000Z\",\n    \"last_message_at\": \"2026-10-01T09:00:22.000Z\",\n    \"closed_at\": null,\n    \"close_reason\": null,\n    \"test\": false\n  },\n  \"messages\": [\n    {\n      \"id\": \"4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8\",\n      \"role\": \"assistant\",\n      \"text\": \"You are chatting with the digital assistant of Acme Plumbing. How can I help you?\",\n      \"created_at\": \"2026-10-01T09:00:00.000Z\"\n    },\n    {\n      \"id\": \"5f6a7b8c-9d0e-4f1a-b2c3-d4e5f6a7b8c9\",\n      \"role\": \"customer\",\n      \"text\": \"My kitchen tap is leaking. Can someone come today?\",\n      \"created_at\": \"2026-10-01T09:00:20.000Z\"\n    },\n    {\n      \"id\": \"6a7b8c9d-0e1f-4a2b-83c4-e5f6a7b8c9d0\",\n      \"role\": \"assistant\",\n      \"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?\",\n      \"created_at\": \"2026-10-01T09:00:22.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Send a visitor message and get the reply (public)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "X-Conversation-Token",
                "value": "",
                "description": "The conversation_token from POST /public/conversations. Only the visitor who started the conversation has it."
              },
              {
                "key": "Accept",
                "value": "application/json",
                "description": "text/event-stream streams the reply as Server-Sent Events; anything else returns a single JSON response.",
                "disabled": true
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/public/conversations/:id/messages?token={{publicToken}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "public",
                "conversations",
                ":id",
                "messages"
              ],
              "query": [
                {
                  "key": "token",
                  "value": "{{publicToken}}",
                  "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the conversation."
                }
              ]
            },
            "description": "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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
            "auth": {
              "type": "noauth"
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"text\": \"My kitchen tap is leaking. Can someone come today?\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The visitor's message and the assistant's reply.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "X-Conversation-Token",
                    "value": "",
                    "description": "The conversation_token from POST /public/conversations. Only the visitor who started the conversation has it."
                  },
                  {
                    "key": "Accept",
                    "value": "application/json",
                    "description": "text/event-stream streams the reply as Server-Sent Events; anything else returns a single JSON response.",
                    "disabled": true
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/public/conversations/:id/messages?token={{publicToken}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "public",
                    "conversations",
                    ":id",
                    "messages"
                  ],
                  "query": [
                    {
                      "key": "token",
                      "value": "{{publicToken}}",
                      "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the conversation."
                    }
                  ]
                },
                "description": "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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"text\": \"My kitchen tap is leaking. Can someone come today?\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The visitor's message and the assistant's reply.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"message\": {\n    \"id\": \"5f6a7b8c-9d0e-4f1a-b2c3-d4e5f6a7b8c9\",\n    \"role\": \"customer\",\n    \"text\": \"My kitchen tap is leaking. Can someone come today?\",\n    \"created_at\": \"2026-10-01T09:00:20.000Z\"\n  },\n  \"reply\": {\n    \"id\": \"6a7b8c9d-0e1f-4a2b-83c4-e5f6a7b8c9d0\",\n    \"role\": \"assistant\",\n    \"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?\",\n    \"created_at\": \"2026-10-01T09:00:22.000Z\"\n  },\n  \"conversation\": {\n    \"id\": \"2c9d8e7f-6a5b-4c3d-9e2f-1a0b9c8d7e6f\",\n    \"channel\": \"widget\",\n    \"status\": \"open\",\n    \"language\": \"en\",\n    \"turns\": 1,\n    \"max_turns\": 30,\n    \"created_at\": \"2026-10-01T09:00:00.000Z\",\n    \"last_message_at\": \"2026-10-01T09:00:20.000Z\",\n    \"closed_at\": null,\n    \"close_reason\": null,\n    \"test\": false\n  }\n}"
            }
          ]
        },
        {
          "name": "The bot check page for the widget (public)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/public/challenge?token={{publicToken}}&origin=https://www.acme-plumbing.example",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "public",
                "challenge"
              ],
              "query": [
                {
                  "key": "token",
                  "value": "{{publicToken}}",
                  "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                },
                {
                  "key": "origin",
                  "value": "https://www.acme-plumbing.example",
                  "description": "The origin of the page with the widget, like https://www.example.com."
                },
                {
                  "key": "lang",
                  "value": "en",
                  "description": "The language of the check. Without it, the browser language.",
                  "disabled": true
                }
              ]
            },
            "description": "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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        }
      ]
    },
    {
      "name": "Sites",
      "description": "Static sites at https://<slug>.hypamoon.app: send the files, and the site is live. Free, including in test mode. Pair it with a number and the assistant, or the chat widget. A password or an unlisted address shares a site with a few people; for sign-in per user, add an app with auth to the site.",
      "item": [
        {
          "name": "List the sites",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites"
              ]
            },
            "description": "The project's static sites, newest first, each with its url and its files (without their content). Deleted sites are not listed.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The sites.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites"
                  ]
                },
                "description": "The project's static sites, newest first, each with its url and its files (without their content). Deleted sites are not listed.\n\nScope: sites:read"
              },
              "status": "The sites.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n      \"slug\": \"acme-plumbing\",\n      \"name\": \"Acme Plumbing\",\n      \"url\": \"https://acme-plumbing.hypamoon.app\",\n      \"files\": [\n        {\n          \"path\": \"/index.html\",\n          \"content_type\": \"text/html\",\n          \"bytes\": 272,\n          \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n        },\n        {\n          \"path\": \"/style.css\",\n          \"content_type\": \"text/css\",\n          \"bytes\": 98,\n          \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n        }\n      ],\n      \"created_at\": \"2026-10-01T09:00:00.000Z\",\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n      \"version\": \"k3Q9vXbT2mLc8Rw1\",\n      \"password_protected\": false,\n      \"test\": false,\n      \"taken_offline\": null\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Publish a static site",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites"
              ]
            },
            "description": "Creates a static site and publishes it immediately at https://<slug>.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.<i>.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 <path>.html and <path>/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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"slug\": \"acme-plumbing\",\n  \"name\": \"Acme Plumbing\",\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"content\": \"<!doctype html><html lang=\\\"en\\\"><head><meta charset=\\\"utf-8\\\"><meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\"><title>Acme Plumbing</title><link rel=\\\"stylesheet\\\" href=\\\"style.css\\\"></head><body><h1>Acme Plumbing</h1><p>Leaking tap? Call us.</p></body></html>\"\n    },\n    {\n      \"path\": \"/style.css\",\n      \"content\": \"body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The site, live at url.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites"
                  ]
                },
                "description": "Creates a static site and publishes it immediately at https://<slug>.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.<i>.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 <path>.html and <path>/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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"slug\": \"acme-plumbing\",\n  \"name\": \"Acme Plumbing\",\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"content\": \"<!doctype html><html lang=\\\"en\\\"><head><meta charset=\\\"utf-8\\\"><meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\"><title>Acme Plumbing</title><link rel=\\\"stylesheet\\\" href=\\\"style.css\\\"></head><body><h1>Acme Plumbing</h1><p>Leaking tap? Call us.</p></body></html>\"\n    },\n    {\n      \"path\": \"/style.css\",\n      \"content\": \"body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }\"\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The site, live at url.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n  \"slug\": \"acme-plumbing\",\n  \"name\": \"Acme Plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"content_type\": \"text/html\",\n      \"bytes\": 272,\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    },\n    {\n      \"path\": \"/style.css\",\n      \"content_type\": \"text/css\",\n      \"bytes\": 98,\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    }\n  ],\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n  \"version\": \"k3Q9vXbT2mLc8Rw1\",\n  \"password_protected\": false,\n  \"test\": false,\n  \"taken_offline\": null\n}"
            },
            {
              "name": "202 Four-eyes approval is on: the deploy waits for another owner or builder. Nothing has changed on the live site yet.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites"
                  ]
                },
                "description": "Creates a static site and publishes it immediately at https://<slug>.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.<i>.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 <path>.html and <path>/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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"slug\": \"acme-plumbing\",\n  \"name\": \"Acme Plumbing\",\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"content\": \"<!doctype html><html lang=\\\"en\\\"><head><meta charset=\\\"utf-8\\\"><meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\"><title>Acme Plumbing</title><link rel=\\\"stylesheet\\\" href=\\\"style.css\\\"></head><body><h1>Acme Plumbing</h1><p>Leaking tap? Call us.</p></body></html>\"\n    },\n    {\n      \"path\": \"/style.css\",\n      \"content\": \"body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }\"\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Four-eyes approval is on: the deploy waits for another owner or builder. Nothing has changed on the live site yet.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"deploy_approval\",\n  \"id\": \"8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90\",\n  \"status\": \"pending\",\n  \"kind\": \"new_site\",\n  \"site_id\": null,\n  \"name\": \"Acme Plumbing\",\n  \"slug\": \"acme-plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": 2,\n  \"bytes\": 1840,\n  \"password_change\": \"none\",\n  \"requested_by\": {\n    \"user_id\": \"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\",\n    \"via\": \"api\",\n    \"app\": \"Claude\"\n  },\n  \"requested_at\": \"2026-10-05T10:15:00Z\",\n  \"expires_at\": \"2026-10-12T10:15:00Z\",\n  \"reviewed_by\": null,\n  \"reviewed_at\": null,\n  \"reason\": null,\n  \"result\": null\n}"
            }
          ]
        },
        {
          "name": "Get upload links for large files",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites/uploads",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                "uploads"
              ]
            },
            "description": "For files too large to send inline (one request to the API stays under 4 MB). Send path, sha256 and bytes for each file; the response contains an upload_url for each file. PUT the exact bytes to it within 15 minutes (curl -T file '<upload_url>'); the upload checks size, SHA-256 and the type of the bytes (422 type_mismatch) and stores the file. Video and audio above the inline limit (paid plans: up to the bytes_per_media limit) can only be uploaded this way. Then deploy with POST /sites or PUT /sites/{id}, naming the file with path, sha256 and bytes instead of content. A file another site of this project already has needs no new upload. The size is checked against the limits of the project's plan. At most 50 files, 1 MB per file (also PDF, video and audio) and 5 MB together.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"files\": [\n    {\n      \"path\": \"/img/hero.jpg\",\n      \"sha256\": \"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\",\n      \"bytes\": 2483112\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The upload links.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/uploads",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    "uploads"
                  ]
                },
                "description": "For files too large to send inline (one request to the API stays under 4 MB). Send path, sha256 and bytes for each file; the response contains an upload_url for each file. PUT the exact bytes to it within 15 minutes (curl -T file '<upload_url>'); the upload checks size, SHA-256 and the type of the bytes (422 type_mismatch) and stores the file. Video and audio above the inline limit (paid plans: up to the bytes_per_media limit) can only be uploaded this way. Then deploy with POST /sites or PUT /sites/{id}, naming the file with path, sha256 and bytes instead of content. A file another site of this project already has needs no new upload. The size is checked against the limits of the project's plan. At most 50 files, 1 MB per file (also PDF, video and audio) and 5 MB together.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"files\": [\n    {\n      \"path\": \"/img/hero.jpg\",\n      \"sha256\": \"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\",\n      \"bytes\": 2483112\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The upload links.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"path\": \"/img/hero.jpg\",\n      \"sha256\": \"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\",\n      \"bytes\": 2483112,\n      \"content_type\": \"image/jpeg\",\n      \"upload_url\": \"https://hypamoon.app/_hypamoon/upload?t=eyJvIjoi...\",\n      \"method\": \"PUT\",\n      \"expires_at\": \"2026-10-06T10:15:00.000Z\"\n    }\n  ],\n  \"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.\",\n  \"limits\": {\n    \"files\": 200,\n    \"bytes_per_file\": 5242880,\n    \"bytes_per_site\": 52428800,\n    \"bytes_per_pdf\": 10485760,\n    \"bytes_per_media\": 10485760\n  }\n}"
            }
          ]
        },
        {
          "name": "Create a drop link where the user uploads files in a browser",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites/drops",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                "drops"
              ]
            },
            "description": "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/<token>, 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/<folder>/) 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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The drop link.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/drops",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    "drops"
                  ]
                },
                "description": "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/<token>, 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/<folder>/) 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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The drop link.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"3f1c2a9e-5b7d-4e8f-9a0b-1c2d3e4f5a6b\",\n  \"status\": \"open\",\n  \"site_id\": null,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"expires_at\": \"2026-10-02T09:00:00.000Z\",\n  \"completed_at\": null,\n  \"images_only\": false,\n  \"strip\": \"location\",\n  \"folder\": \"portfolio\",\n  \"max_files\": 100,\n  \"files\": [],\n  \"originals_kept\": 0,\n  \"site_version\": null,\n  \"deploy_files\": [],\n  \"url\": \"https://app.hypamoon.com/drop/hmd_4fT0aQ8xK2mPq7RsV1wYz3Bc5De6Fg9H\",\n  \"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.\",\n  \"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.\"\n}"
            }
          ]
        },
        {
          "name": "Get a drop link and the files the user uploaded",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/drops/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                "drops",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the drop link."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The drop link and its files.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/drops/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    "drops",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the drop link."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The drop link and its files.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"3f1c2a9e-5b7d-4e8f-9a0b-1c2d3e4f5a6b\",\n  \"status\": \"done\",\n  \"site_id\": null,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"expires_at\": \"2026-10-02T09:00:00.000Z\",\n  \"completed_at\": \"2026-10-01T09:04:00.000Z\",\n  \"images_only\": false,\n  \"strip\": \"location\",\n  \"folder\": \"portfolio\",\n  \"max_files\": 100,\n  \"files\": [\n    {\n      \"path\": \"/media/portfolio/project-12/03.webp\",\n      \"sha256\": \"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\",\n      \"bytes\": 384112,\n      \"content_type\": \"image/webp\",\n      \"name\": \"03.jpg\",\n      \"variant\": \"2048\",\n      \"width\": 2048,\n      \"height\": 1365\n    },\n    {\n      \"path\": \"/media/portfolio/project-12/03-640.webp\",\n      \"sha256\": \"3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7\",\n      \"bytes\": 41240,\n      \"content_type\": \"image/webp\",\n      \"name\": \"03.jpg\",\n      \"variant\": \"640\",\n      \"width\": 640,\n      \"height\": 427\n    }\n  ],\n  \"originals_kept\": 0,\n  \"site_version\": null,\n  \"deploy_files\": [\n    {\n      \"path\": \"/media/portfolio/project-12/03.webp\",\n      \"sha256\": \"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\",\n      \"bytes\": 384112\n    },\n    {\n      \"path\": \"/media/portfolio/project-12/03-640.webp\",\n      \"sha256\": \"3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7\",\n      \"bytes\": 41240\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Get a site",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "One site with its url and its files, without their content.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The site.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "One site with its url and its files, without their content.\n\nScope: sites:read"
              },
              "status": "The site.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n  \"slug\": \"acme-plumbing\",\n  \"name\": \"Acme Plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"content_type\": \"text/html\",\n      \"bytes\": 272,\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    },\n    {\n      \"path\": \"/style.css\",\n      \"content_type\": \"text/css\",\n      \"bytes\": 98,\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    }\n  ],\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n  \"version\": \"k3Q9vXbT2mLc8Rw1\",\n  \"password_protected\": false,\n  \"test\": false,\n  \"taken_offline\": null\n}"
            }
          ]
        },
        {
          "name": "Change the files, the name or the password of a site",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.<i>.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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"content\": \"<!doctype html><html lang=\\\"en\\\"><head><meta charset=\\\"utf-8\\\"><meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\"><title>Acme Plumbing</title><link rel=\\\"stylesheet\\\" href=\\\"style.css\\\"></head><body><h1>Acme Plumbing</h1><p>Leaking tap? Call us.</p></body></html>\"\n    },\n    {\n      \"path\": \"/style.css\",\n      \"content\": \"body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }\"\n    }\n  ],\n  \"password\": \"spring-tour-2027\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The site as it is now.",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.<i>.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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"content\": \"<!doctype html><html lang=\\\"en\\\"><head><meta charset=\\\"utf-8\\\"><meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\"><title>Acme Plumbing</title><link rel=\\\"stylesheet\\\" href=\\\"style.css\\\"></head><body><h1>Acme Plumbing</h1><p>Leaking tap? Call us.</p></body></html>\"\n    },\n    {\n      \"path\": \"/style.css\",\n      \"content\": \"body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }\"\n    }\n  ],\n  \"password\": \"spring-tour-2027\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The site as it is now.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n  \"slug\": \"acme-plumbing\",\n  \"name\": \"Acme Plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"content_type\": \"text/html\",\n      \"bytes\": 272,\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    },\n    {\n      \"path\": \"/style.css\",\n      \"content_type\": \"text/css\",\n      \"bytes\": 98,\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    }\n  ],\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n  \"version\": \"k3Q9vXbT2mLc8Rw1\",\n  \"password_protected\": true,\n  \"test\": false,\n  \"taken_offline\": null\n}"
            },
            {
              "name": "202 Four-eyes approval is on: the update waits for another owner or builder. Nothing has changed on the live site yet.",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.<i>.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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"content\": \"<!doctype html><html lang=\\\"en\\\"><head><meta charset=\\\"utf-8\\\"><meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\"><title>Acme Plumbing</title><link rel=\\\"stylesheet\\\" href=\\\"style.css\\\"></head><body><h1>Acme Plumbing</h1><p>Leaking tap? Call us.</p></body></html>\"\n    },\n    {\n      \"path\": \"/style.css\",\n      \"content\": \"body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }\"\n    }\n  ],\n  \"password\": \"spring-tour-2027\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Four-eyes approval is on: the update waits for another owner or builder. Nothing has changed on the live site yet.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"deploy_approval\",\n  \"id\": \"8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90\",\n  \"status\": \"pending\",\n  \"kind\": \"site_update\",\n  \"site_id\": \"3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c\",\n  \"name\": \"Acme Plumbing\",\n  \"slug\": \"acme-plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": 2,\n  \"bytes\": 1840,\n  \"password_change\": \"none\",\n  \"requested_by\": {\n    \"user_id\": \"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\",\n    \"via\": \"api\",\n    \"app\": \"Claude\"\n  },\n  \"requested_at\": \"2026-10-05T10:15:00Z\",\n  \"expires_at\": \"2026-10-12T10:15:00Z\",\n  \"reviewed_by\": null,\n  \"reviewed_at\": null,\n  \"reason\": null,\n  \"result\": null\n}"
            }
          ]
        },
        {
          "name": "Delete a site",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id"
              ],
              "query": [
                {
                  "key": "permanent",
                  "value": "",
                  "description": "true erases the site and its files right away, without the 30 days to restore it. Only when the owner explicitly asks for it.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write"
          },
          "response": [
            {
              "name": "202 Four-eyes approval is on: the deletion waits for another owner or builder. Nothing has changed on the live site yet.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id"
                  ],
                  "query": [
                    {
                      "key": "permanent",
                      "value": "",
                      "description": "true erases the site and its files right away, without the 30 days to restore it. Only when the owner explicitly asks for it.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write"
              },
              "status": "Four-eyes approval is on: the deletion waits for another owner or builder. Nothing has changed on the live site yet.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"deploy_approval\",\n  \"id\": \"8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90\",\n  \"status\": \"pending\",\n  \"kind\": \"site_delete\",\n  \"site_id\": \"3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c\",\n  \"name\": \"Acme Plumbing\",\n  \"slug\": \"acme-plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": 0,\n  \"bytes\": 0,\n  \"password_change\": \"none\",\n  \"requested_by\": {\n    \"user_id\": \"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\",\n    \"via\": \"api\",\n    \"app\": \"Claude\"\n  },\n  \"requested_at\": \"2026-10-05T10:15:00Z\",\n  \"expires_at\": \"2026-10-12T10:15:00Z\",\n  \"reviewed_by\": null,\n  \"reviewed_at\": null,\n  \"reason\": null,\n  \"result\": null\n}"
            }
          ]
        },
        {
          "name": "List the deleted sites that can be restored",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/deleted",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                "deleted"
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The deleted sites.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/deleted",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    "deleted"
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The deleted sites.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n      \"slug\": \"acme-plumbing\",\n      \"name\": \"Acme Plumbing\",\n      \"deleted_at\": \"2026-10-14T09:00:00.000Z\",\n      \"restore_until\": \"2026-11-13T09:00:00.000Z\",\n      \"test\": true\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Restore a deleted site",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/restore",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "restore"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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).\n\nScope: sites:write"
          },
          "response": [
            {
              "name": "200 The site, live again.",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/restore",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "restore"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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).\n\nScope: sites:write"
              },
              "status": "The site, live again.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n  \"slug\": \"acme-plumbing\",\n  \"name\": \"Acme Plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"content_type\": \"text/html\",\n      \"bytes\": 272,\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    },\n    {\n      \"path\": \"/style.css\",\n      \"content_type\": \"text/css\",\n      \"bytes\": 98,\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    }\n  ],\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n  \"version\": \"k3Q9vXbT2mLc8Rw1\",\n  \"password_protected\": false,\n  \"test\": false,\n  \"taken_offline\": null,\n  \"restore_notes\": [\n    \"A custom domain was disconnected from the site; connect it again with connect_domain.\"\n  ]\n}"
            }
          ]
        },
        {
          "name": "Suggest an address for a site",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/address-suggestions",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                "address-suggestions"
              ],
              "query": [
                {
                  "key": "name",
                  "value": "Acme Plumbing",
                  "description": "The name of the site or business, like Acme Plumbing.",
                  "disabled": true
                },
                {
                  "key": "slug",
                  "value": "acme",
                  "description": "The address the user would like, like acme-plumbing.",
                  "disabled": true
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The requested address, checked, and available suggestions.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/address-suggestions",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    "address-suggestions"
                  ],
                  "query": [
                    {
                      "key": "name",
                      "value": "Acme Plumbing",
                      "description": "The name of the site or business, like Acme Plumbing.",
                      "disabled": true
                    },
                    {
                      "key": "slug",
                      "value": "acme",
                      "description": "The address the user would like, like acme-plumbing.",
                      "disabled": true
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The requested address, checked, and available suggestions.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"requested\": {\n    \"slug\": \"acme\",\n    \"available\": false,\n    \"reason\": \"Taken by another site.\",\n    \"url\": \"https://acme.hypamoon.app\"\n  },\n  \"suggestions\": [\n    {\n      \"slug\": \"acme-plumbing\",\n      \"url\": \"https://acme-plumbing.hypamoon.app\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Change the address or visibility of a site",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/address",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "address"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"slug\": \"acme-plumbing-rotterdam\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The site with its new address.",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/address",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "address"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"slug\": \"acme-plumbing-rotterdam\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The site with its new address.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n  \"slug\": \"acme-plumbing-rotterdam\",\n  \"name\": \"Acme Plumbing\",\n  \"url\": \"https://acme-plumbing-rotterdam.hypamoon.app\",\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"content_type\": \"text/html\",\n      \"bytes\": 272,\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    },\n    {\n      \"path\": \"/style.css\",\n      \"content_type\": \"text/css\",\n      \"bytes\": 98,\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    }\n  ],\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n  \"version\": \"k3Q9vXbT2mLc8Rw1\",\n  \"password_protected\": false,\n  \"test\": false,\n  \"taken_offline\": null,\n  \"visibility\": \"public\"\n}"
            },
            {
              "name": "202 Four-eyes approval is on: the change waits for another owner or builder. Nothing has changed yet.",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/address",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "address"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"slug\": \"acme-plumbing-rotterdam\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Four-eyes approval is on: the change waits for another owner or builder. Nothing has changed yet.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"deploy_approval\",\n  \"id\": \"8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90\",\n  \"status\": \"pending\",\n  \"kind\": \"site_update\",\n  \"site_id\": \"3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c\",\n  \"name\": \"Acme Plumbing\",\n  \"slug\": \"acme-plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": 2,\n  \"bytes\": 1840,\n  \"password_change\": \"none\",\n  \"requested_by\": {\n    \"user_id\": \"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\",\n    \"via\": \"api\",\n    \"app\": \"Claude\"\n  },\n  \"requested_at\": \"2026-10-05T10:15:00Z\",\n  \"expires_at\": \"2026-10-12T10:15:00Z\",\n  \"reviewed_by\": null,\n  \"reviewed_at\": null,\n  \"reason\": null,\n  \"result\": null\n}"
            }
          ]
        },
        {
          "name": "Make a new secret link for an unlisted site",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/rotate-secret-url",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "rotate-secret-url"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write"
          },
          "response": [
            {
              "name": "200 The site with its new secret link in url.",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/rotate-secret-url",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "rotate-secret-url"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write"
              },
              "status": "The site with its new secret link in url.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n  \"slug\": \"acme-plumbing\",\n  \"name\": \"Acme Plumbing\",\n  \"url\": \"https://k7q2m4x9r3t6w8y2b5c7d9f3gh.hypamoon.app\",\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"content_type\": \"text/html\",\n      \"bytes\": 272,\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    },\n    {\n      \"path\": \"/style.css\",\n      \"content_type\": \"text/css\",\n      \"bytes\": 98,\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    }\n  ],\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n  \"version\": \"k3Q9vXbT2mLc8Rw1\",\n  \"password_protected\": false,\n  \"test\": false,\n  \"taken_offline\": null,\n  \"visibility\": \"unlisted\"\n}"
            }
          ]
        },
        {
          "name": "List the versions of a site",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/versions",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "versions"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "",
                  "description": "How many versions per page (default 20).",
                  "disabled": true
                },
                {
                  "key": "cursor",
                  "value": "",
                  "description": "The next_cursor of the previous page.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The versions, newest first.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/versions",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "versions"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "",
                      "description": "How many versions per page (default 20).",
                      "disabled": true
                    },
                    {
                      "key": "cursor",
                      "value": "",
                      "description": "The next_cursor of the previous page.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The versions, newest first.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"list\",\n  \"data\": [\n    {\n      \"object\": \"site_version\",\n      \"id\": \"8d0f5c1e-2b7a-4c55-9a51-3f1f2f6c9e10\",\n      \"number\": 4,\n      \"site_id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n      \"created_at\": \"2026-10-06T09:12:00Z\",\n      \"cause\": \"partial_deploy\",\n      \"message\": \"New opening hours\",\n      \"author\": {\n        \"user_id\": \"5b1d6f3e-9a0c-4f2e-8d7b-2c4a1e9f0b33\",\n        \"via\": \"connected_app\",\n        \"client\": \"claude\",\n        \"client_name\": \"Claude\"\n      },\n      \"changes\": {\n        \"added\": [],\n        \"modified\": [\n          \"/index.html\"\n        ],\n        \"removed\": [],\n        \"counts\": {\n          \"added\": 0,\n          \"modified\": 1,\n          \"removed\": 0\n        },\n        \"truncated\": false\n      },\n      \"files\": 3,\n      \"bytes\": 18240,\n      \"current\": true,\n      \"preview\": false,\n      \"preview_url\": null,\n      \"label\": null,\n      \"labeled_at\": null,\n      \"restored_from\": null,\n      \"expires_at\": null,\n      \"auto_label\": null\n    }\n  ],\n  \"has_more\": false,\n  \"next_cursor\": null,\n  \"retention_days\": 30,\n  \"labels_max\": 5\n}"
            }
          ]
        },
        {
          "name": "Show what changed between two versions",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/diff",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "diff"
              ],
              "query": [
                {
                  "key": "from",
                  "value": "",
                  "description": "The id or number of the older version.",
                  "disabled": true
                },
                {
                  "key": "to",
                  "value": "",
                  "description": "The id or number of the newer version.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 What changed.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/diff",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "diff"
                  ],
                  "query": [
                    {
                      "key": "from",
                      "value": "",
                      "description": "The id or number of the older version.",
                      "disabled": true
                    },
                    {
                      "key": "to",
                      "value": "",
                      "description": "The id or number of the newer version.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "What changed.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"site_diff\",\n  \"site_id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n  \"from\": {\n    \"id\": \"0b6c7e2a-1f3d-4e5a-9c8b-7d6e5f4a3b21\",\n    \"number\": 3\n  },\n  \"to\": {\n    \"id\": \"8d0f5c1e-2b7a-4c55-9a51-3f1f2f6c9e10\",\n    \"number\": 4\n  },\n  \"summary\": {\n    \"added\": 0,\n    \"modified\": 1,\n    \"removed\": 0,\n    \"replaced\": 0\n  },\n  \"files\": [\n    {\n      \"path\": \"/index.html\",\n      \"status\": \"modified\",\n      \"kind\": \"text\",\n      \"old_bytes\": 2310,\n      \"new_bytes\": 2342,\n      \"diff\": \"--- a/index.html\\n+++ b/index.html\\n@@ -12,1 +12,1 @@\\n-<p>Open 9 to 5</p>\\n+<p>Open 8 to 6</p>\\n\"\n    }\n  ],\n  \"truncated\": false\n}"
            }
          ]
        },
        {
          "name": "Roll a site back to an earlier version",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/rollback",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "rollback"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"version_id\": \"0b6c7e2a-1f3d-4e5a-9c8b-7d6e5f4a3b21\",\n  \"message\": \"Back to the old opening hours\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The site as it is now live, with the new version.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/rollback",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "rollback"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"version_id\": \"0b6c7e2a-1f3d-4e5a-9c8b-7d6e5f4a3b21\",\n  \"message\": \"Back to the old opening hours\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The site as it is now live, with the new version.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"site\": {\n    \"id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n    \"slug\": \"acme-plumbing\",\n    \"name\": \"Acme Plumbing\",\n    \"url\": \"https://acme-plumbing.hypamoon.app\",\n    \"files\": [\n      {\n        \"path\": \"/index.html\",\n        \"content_type\": \"text/html\",\n        \"bytes\": 272,\n        \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n      },\n      {\n        \"path\": \"/style.css\",\n        \"content_type\": \"text/css\",\n        \"bytes\": 98,\n        \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n      }\n    ],\n    \"created_at\": \"2026-10-01T09:00:00.000Z\",\n    \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n    \"version\": \"k3Q9vXbT2mLc8Rw1\",\n    \"password_protected\": false,\n    \"test\": false,\n    \"taken_offline\": null\n  },\n  \"version\": {\n    \"object\": \"site_version\",\n    \"id\": \"8d0f5c1e-2b7a-4c55-9a51-3f1f2f6c9e10\",\n    \"number\": 5,\n    \"site_id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n    \"created_at\": \"2026-10-06T09:12:00Z\",\n    \"cause\": \"rollback\",\n    \"message\": \"New opening hours\",\n    \"author\": {\n      \"user_id\": \"5b1d6f3e-9a0c-4f2e-8d7b-2c4a1e9f0b33\",\n      \"via\": \"connected_app\",\n      \"client\": \"claude\",\n      \"client_name\": \"Claude\"\n    },\n    \"changes\": {\n      \"added\": [],\n      \"modified\": [\n        \"/index.html\"\n      ],\n      \"removed\": [],\n      \"counts\": {\n        \"added\": 0,\n        \"modified\": 1,\n        \"removed\": 0\n      },\n      \"truncated\": false\n    },\n    \"files\": 3,\n    \"bytes\": 18240,\n    \"current\": true,\n    \"preview\": false,\n    \"preview_url\": null,\n    \"label\": null,\n    \"labeled_at\": null,\n    \"restored_from\": \"0b6c7e2a-1f3d-4e5a-9c8b-7d6e5f4a3b21\",\n    \"expires_at\": null,\n    \"auto_label\": null\n  },\n  \"unchanged\": false\n}"
            },
            {
              "name": "202 Four-eyes approval is on: the rollback waits for another owner or builder. Nothing has changed yet.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/rollback",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "rollback"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"version_id\": \"0b6c7e2a-1f3d-4e5a-9c8b-7d6e5f4a3b21\",\n  \"message\": \"Back to the old opening hours\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Four-eyes approval is on: the rollback waits for another owner or builder. Nothing has changed yet.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"deploy_approval\",\n  \"id\": \"8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90\",\n  \"status\": \"pending\",\n  \"kind\": \"site_update\",\n  \"site_id\": \"3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c\",\n  \"name\": \"Acme Plumbing\",\n  \"slug\": \"acme-plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": 2,\n  \"bytes\": 1840,\n  \"password_change\": \"none\",\n  \"requested_by\": {\n    \"user_id\": \"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\",\n    \"via\": \"api\",\n    \"app\": \"Claude\"\n  },\n  \"requested_at\": \"2026-10-05T10:15:00Z\",\n  \"expires_at\": \"2026-10-12T10:15:00Z\",\n  \"reviewed_by\": null,\n  \"reviewed_at\": null,\n  \"reason\": null,\n  \"result\": null\n}"
            }
          ]
        },
        {
          "name": "Restore a few files from an earlier version",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/restore-files",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "restore-files"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"number\": 4,\n  \"paths\": [\n    \"/app.js\",\n    \"/style.css\"\n  ],\n  \"message\": \"Back to the working app.js and style.css\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The site as it is now live, with the new version.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/restore-files",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "restore-files"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"number\": 4,\n  \"paths\": [\n    \"/app.js\",\n    \"/style.css\"\n  ],\n  \"message\": \"Back to the working app.js and style.css\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The site as it is now live, with the new version.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"site\": {\n    \"id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n    \"slug\": \"acme-plumbing\",\n    \"name\": \"Acme Plumbing\",\n    \"url\": \"https://acme-plumbing.hypamoon.app\",\n    \"files\": [\n      {\n        \"path\": \"/index.html\",\n        \"content_type\": \"text/html\",\n        \"bytes\": 272,\n        \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n      },\n      {\n        \"path\": \"/style.css\",\n        \"content_type\": \"text/css\",\n        \"bytes\": 98,\n        \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n      }\n    ],\n    \"created_at\": \"2026-10-01T09:00:00.000Z\",\n    \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n    \"version\": \"k3Q9vXbT2mLc8Rw1\",\n    \"password_protected\": false,\n    \"test\": false,\n    \"taken_offline\": null\n  },\n  \"version\": {\n    \"object\": \"site_version\",\n    \"id\": \"8d0f5c1e-2b7a-4c55-9a51-3f1f2f6c9e10\",\n    \"number\": 7,\n    \"site_id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n    \"created_at\": \"2026-10-06T09:12:00Z\",\n    \"cause\": \"file_restore\",\n    \"message\": \"Back to the working app.js and style.css\",\n    \"author\": {\n      \"user_id\": \"5b1d6f3e-9a0c-4f2e-8d7b-2c4a1e9f0b33\",\n      \"via\": \"connected_app\",\n      \"client\": \"claude\",\n      \"client_name\": \"Claude\"\n    },\n    \"changes\": {\n      \"added\": [],\n      \"modified\": [\n        \"/index.html\"\n      ],\n      \"removed\": [],\n      \"counts\": {\n        \"added\": 0,\n        \"modified\": 1,\n        \"removed\": 0\n      },\n      \"truncated\": false\n    },\n    \"files\": 3,\n    \"bytes\": 18240,\n    \"current\": true,\n    \"preview\": false,\n    \"preview_url\": null,\n    \"label\": null,\n    \"labeled_at\": null,\n    \"restored_from\": \"0b6c7e2a-1f3d-4e5a-9c8b-7d6e5f4a3b21\",\n    \"expires_at\": null,\n    \"auto_label\": null\n  },\n  \"unchanged\": false\n}"
            },
            {
              "name": "202 Four-eyes approval is on: the restore waits for another owner or builder. Nothing has changed yet.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/restore-files",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "restore-files"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"number\": 4,\n  \"paths\": [\n    \"/app.js\",\n    \"/style.css\"\n  ],\n  \"message\": \"Back to the working app.js and style.css\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Four-eyes approval is on: the restore waits for another owner or builder. Nothing has changed yet.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"deploy_approval\",\n  \"id\": \"8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90\",\n  \"status\": \"pending\",\n  \"kind\": \"site_update\",\n  \"site_id\": \"3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c\",\n  \"name\": \"Acme Plumbing\",\n  \"slug\": \"acme-plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": 2,\n  \"bytes\": 1840,\n  \"password_change\": \"none\",\n  \"requested_by\": {\n    \"user_id\": \"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\",\n    \"via\": \"api\",\n    \"app\": \"Claude\"\n  },\n  \"requested_at\": \"2026-10-05T10:15:00Z\",\n  \"expires_at\": \"2026-10-12T10:15:00Z\",\n  \"reviewed_by\": null,\n  \"reviewed_at\": null,\n  \"reason\": null,\n  \"result\": null\n}"
            }
          ]
        },
        {
          "name": "Publish a preview",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/versions/:versionId/promote",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "versions",
                ":versionId",
                "promote"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                },
                {
                  "key": "versionId",
                  "value": "",
                  "description": "The id of the version, from list_site_versions."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"message\": \"Spring menu\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The site as it is now live, with the new version.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/versions/:versionId/promote",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "versions",
                    ":versionId",
                    "promote"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    },
                    {
                      "key": "versionId",
                      "value": "",
                      "description": "The id of the version, from list_site_versions."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"message\": \"Spring menu\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The site as it is now live, with the new version.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"site\": {\n    \"id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n    \"slug\": \"acme-plumbing\",\n    \"name\": \"Acme Plumbing\",\n    \"url\": \"https://acme-plumbing.hypamoon.app\",\n    \"files\": [\n      {\n        \"path\": \"/index.html\",\n        \"content_type\": \"text/html\",\n        \"bytes\": 272,\n        \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n      },\n      {\n        \"path\": \"/style.css\",\n        \"content_type\": \"text/css\",\n        \"bytes\": 98,\n        \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n      }\n    ],\n    \"created_at\": \"2026-10-01T09:00:00.000Z\",\n    \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n    \"version\": \"k3Q9vXbT2mLc8Rw1\",\n    \"password_protected\": false,\n    \"test\": false,\n    \"taken_offline\": null\n  },\n  \"version\": {\n    \"object\": \"site_version\",\n    \"id\": \"8d0f5c1e-2b7a-4c55-9a51-3f1f2f6c9e10\",\n    \"number\": 6,\n    \"site_id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n    \"created_at\": \"2026-10-06T09:12:00Z\",\n    \"cause\": \"promote\",\n    \"message\": \"New opening hours\",\n    \"author\": {\n      \"user_id\": \"5b1d6f3e-9a0c-4f2e-8d7b-2c4a1e9f0b33\",\n      \"via\": \"connected_app\",\n      \"client\": \"claude\",\n      \"client_name\": \"Claude\"\n    },\n    \"changes\": {\n      \"added\": [],\n      \"modified\": [\n        \"/index.html\"\n      ],\n      \"removed\": [],\n      \"counts\": {\n        \"added\": 0,\n        \"modified\": 1,\n        \"removed\": 0\n      },\n      \"truncated\": false\n    },\n    \"files\": 3,\n    \"bytes\": 18240,\n    \"current\": true,\n    \"preview\": false,\n    \"preview_url\": null,\n    \"label\": null,\n    \"labeled_at\": null,\n    \"restored_from\": null,\n    \"expires_at\": null,\n    \"auto_label\": null\n  },\n  \"unchanged\": false\n}"
            },
            {
              "name": "202 Four-eyes approval is on: publishing the preview waits for another owner or builder.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/versions/:versionId/promote",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "versions",
                    ":versionId",
                    "promote"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    },
                    {
                      "key": "versionId",
                      "value": "",
                      "description": "The id of the version, from list_site_versions."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"message\": \"Spring menu\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Four-eyes approval is on: publishing the preview waits for another owner or builder.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"deploy_approval\",\n  \"id\": \"8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90\",\n  \"status\": \"pending\",\n  \"kind\": \"site_update\",\n  \"site_id\": \"3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c\",\n  \"name\": \"Acme Plumbing\",\n  \"slug\": \"acme-plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": 2,\n  \"bytes\": 1840,\n  \"password_change\": \"none\",\n  \"requested_by\": {\n    \"user_id\": \"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\",\n    \"via\": \"api\",\n    \"app\": \"Claude\"\n  },\n  \"requested_at\": \"2026-10-05T10:15:00Z\",\n  \"expires_at\": \"2026-10-12T10:15:00Z\",\n  \"reviewed_by\": null,\n  \"reviewed_at\": null,\n  \"reason\": null,\n  \"result\": null\n}"
            }
          ]
        },
        {
          "name": "Label a version",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/versions/:versionId/label",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "versions",
                ":versionId",
                "label"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                },
                {
                  "key": "versionId",
                  "value": "",
                  "description": "The id of the version, from list_site_versions."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"label\": \"before launch\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The version with its label.",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/versions/:versionId/label",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "versions",
                    ":versionId",
                    "label"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    },
                    {
                      "key": "versionId",
                      "value": "",
                      "description": "The id of the version, from list_site_versions."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"label\": \"before launch\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The version with its label.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"site_version\",\n  \"id\": \"8d0f5c1e-2b7a-4c55-9a51-3f1f2f6c9e10\",\n  \"number\": 4,\n  \"site_id\": \"6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d\",\n  \"created_at\": \"2026-10-06T09:12:00Z\",\n  \"cause\": \"partial_deploy\",\n  \"message\": \"New opening hours\",\n  \"author\": {\n    \"user_id\": \"5b1d6f3e-9a0c-4f2e-8d7b-2c4a1e9f0b33\",\n    \"via\": \"connected_app\",\n    \"client\": \"claude\",\n    \"client_name\": \"Claude\"\n  },\n  \"changes\": {\n    \"added\": [],\n    \"modified\": [\n      \"/index.html\"\n    ],\n    \"removed\": [],\n    \"counts\": {\n      \"added\": 0,\n      \"modified\": 1,\n      \"removed\": 0\n    },\n    \"truncated\": false\n  },\n  \"files\": 3,\n  \"bytes\": 18240,\n  \"current\": true,\n  \"preview\": false,\n  \"preview_url\": null,\n  \"label\": \"before launch\",\n  \"labeled_at\": \"2026-10-06T10:00:00Z\",\n  \"restored_from\": null,\n  \"expires_at\": null,\n  \"auto_label\": null\n}"
            }
          ]
        },
        {
          "name": "Check whether a site is ready for the App Store and Google Play",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/store-readiness",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "store-readiness"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The verdict per store, every check and the next steps.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/store-readiness",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "store-readiness"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The verdict per store, every check and the next steps.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"site\": {\n    \"id\": \"8d2c4b1e-3f5a-4c6d-9e7f-1a2b3c4d5e6f\",\n    \"name\": \"Acme Plumbing\",\n    \"app_url\": \"https://acme-plumbing.hypamoon.app\",\n    \"test\": false\n  },\n  \"stores\": {\n    \"apple\": {\n      \"ready\": true,\n      \"blocking\": [],\n      \"warnings\": [\n        \"native_value\"\n      ],\n      \"next_steps\": [\n        \"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.\"\n      ]\n    },\n    \"google_play\": {\n      \"ready\": true,\n      \"blocking\": [],\n      \"warnings\": [],\n      \"next_steps\": []\n    }\n  },\n  \"checks\": [\n    {\n      \"id\": \"site_online\",\n      \"title\": \"The site is online\",\n      \"status\": \"pass\",\n      \"stores\": [\n        \"apple\",\n        \"google_play\"\n      ],\n      \"detail\": \"The app loads https://acme-plumbing.hypamoon.app.\"\n    },\n    {\n      \"id\": \"content_complete\",\n      \"title\": \"Content is complete\",\n      \"status\": \"pass\",\n      \"stores\": [\n        \"apple\",\n        \"google_play\"\n      ],\n      \"detail\": \"3 pages with real content.\"\n    },\n    {\n      \"id\": \"privacy_policy\",\n      \"title\": \"Privacy policy URL\",\n      \"status\": \"pass\",\n      \"stores\": [\n        \"apple\",\n        \"google_play\"\n      ],\n      \"detail\": \"Found: /privacy.html. Make sure it also covers the app (push tokens, what the forms send).\"\n    },\n    {\n      \"id\": \"support_contact\",\n      \"title\": \"Support contact\",\n      \"status\": \"pass\",\n      \"stores\": [\n        \"apple\",\n        \"google_play\"\n      ],\n      \"detail\": \"Found contact page /contact.html, an email address, a phone number. Apple needs a support URL; Google Play needs a contact email.\"\n    },\n    {\n      \"id\": \"app_icon\",\n      \"title\": \"App icon\",\n      \"status\": \"pass\",\n      \"stores\": [\n        \"apple\",\n        \"google_play\"\n      ],\n      \"detail\": \"/icon-1024.png is 1024x1024: good for every size.\"\n    },\n    {\n      \"id\": \"native_value\",\n      \"title\": \"More than a website in a shell\",\n      \"status\": \"warn\",\n      \"stores\": [\n        \"apple\"\n      ],\n      \"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.\",\n      \"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.\"\n    },\n    {\n      \"id\": \"native_value_play\",\n      \"title\": \"Useful beyond a website\",\n      \"status\": \"pass\",\n      \"stores\": [\n        \"google_play\"\n      ],\n      \"detail\": \"Present: forms that send data (contact, booking or sign-up); live chat with the assistant.\"\n    },\n    {\n      \"id\": \"digital_payments\",\n      \"title\": \"Digital goods use the store's billing\",\n      \"status\": \"pass\",\n      \"stores\": [\n        \"apple\",\n        \"google_play\"\n      ],\n      \"detail\": \"No payment links or checkout found.\"\n    },\n    {\n      \"id\": \"tracking\",\n      \"title\": \"No tracking without permission\",\n      \"status\": \"pass\",\n      \"stores\": [\n        \"apple\",\n        \"google_play\"\n      ],\n      \"detail\": \"No third-party trackers found.\"\n    }\n  ],\n  \"native_value\": {\n    \"present\": [\n      \"forms that send data (contact, booking or sign-up)\",\n      \"live chat with the assistant\"\n    ],\n    \"can_add\": [\n      \"push notifications: the exported app registers each device with Hypamoon; sending notifications from the rails comes in phase 2\",\n      \"offline: the exported app shows its own offline screen; a service worker on the site makes pages work without a connection\",\n      \"sharing: a share button that calls navigator.share opens the phone's share sheet\"\n    ],\n    \"summary\": \"2 interactive parts. Apple judges guideline 4.2 per app; the more the app does that a browser does not, the better.\"\n  },\n  \"accounts\": {\n    \"apple\": {\n      \"steps\": [\n        \"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).\",\n        \"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.\",\n        \"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.\",\n        \"Later phases connect through an App Store Connect API key that the user adds in the Hypamoon portal (secret vault), never in this chat.\"\n      ],\n      \"fee\": \"99 USD per year (Apple Developer Program)\",\n      \"enroll_url\": \"https://developer.apple.com/programs/enroll/\"\n    },\n    \"google_play\": {\n      \"steps\": [\n        \"The app is published under the user's own Google Play developer account. Google verifies the identity of the account holder.\",\n        \"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.\",\n        \"Data safety and the content rating questionnaire are filled in by the user in the Play Console.\",\n        \"Later phases connect through a Google Play service account that the user adds in the Hypamoon portal (secret vault), never in this chat.\"\n      ],\n      \"fee\": \"25 USD once (Google Play Console registration)\",\n      \"enroll_url\": \"https://play.google.com/console/signup\"\n    }\n  },\n  \"unknown\": [],\n  \"next_steps\": [\n    \"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.\",\n    \"Ask the user to set up the developer accounts (see accounts); nobody can do that for them.\",\n    \"get_privacy_answers for App Privacy and Data safety.\",\n    \"generate_store_listing for the texts and the list of screenshots.\",\n    \"export_app_project for the app project.\"\n  ]\n}"
            }
          ]
        },
        {
          "name": "Draft the store listing for a site's app",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/store-listing",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "store-listing"
              ],
              "query": [
                {
                  "key": "app_name",
                  "value": "Acme Plumbing",
                  "description": "The name of the app, if the user chose one. Leave it out to use the site's name.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The draft listing.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/store-listing",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "store-listing"
                  ],
                  "query": [
                    {
                      "key": "app_name",
                      "value": "Acme Plumbing",
                      "description": "The name of the app, if the user chose one. Leave it out to use the site's name.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The draft listing.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"site\": {\n    \"id\": \"8d2c4b1e-3f5a-4c6d-9e7f-1a2b3c4d5e6f\",\n    \"app_url\": \"https://acme-plumbing.hypamoon.app\"\n  },\n  \"source_language\": \"en\",\n  \"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.\",\n  \"apple\": {\n    \"name\": \"Acme Plumbing\",\n    \"subtitle\": \"Book a visit\",\n    \"promotional_text\": \"Fast, friendly plumbing in Rotterdam. Book a visit online or call us, day and night.\",\n    \"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..\",\n    \"keywords\": \"call,day,visit,book,rotterdam,across,blocked,boiler,come,confirm,contact,drain,emergency,every\",\n    \"primary_category\": \"Business\",\n    \"secondary_category\": \"Productivity\",\n    \"support_url\": \"https://acme-plumbing.hypamoon.app/contact.html\",\n    \"privacy_policy_url\": \"https://acme-plumbing.hypamoon.app/privacy.html\"\n  },\n  \"google_play\": {\n    \"title\": \"Acme Plumbing\",\n    \"short_description\": \"Fast, friendly plumbing in Rotterdam. Book a visit online or call us, day and…\",\n    \"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..\",\n    \"category\": \"Business\",\n    \"contact_email\": \"hello@acme-plumbing.example\",\n    \"privacy_policy_url\": \"https://acme-plumbing.hypamoon.app/privacy.html\"\n  },\n  \"category_reason\": \"Nothing specific found; Business fits most company apps. Change it if another category describes the app better.\",\n  \"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.\",\n  \"screenshots\": {\n    \"required\": [\n      {\n        \"store\": \"apple\",\n        \"device\": \"iPhone 6.9-inch display\",\n        \"size\": \"1320 x 2868 or 1290 x 2796 pixels (portrait)\",\n        \"count\": \"3 to 10\",\n        \"required\": true\n      },\n      {\n        \"store\": \"apple\",\n        \"device\": \"iPad 13-inch display\",\n        \"size\": \"2064 x 2752 pixels (portrait)\",\n        \"count\": \"3 to 10\",\n        \"required\": false\n      },\n      {\n        \"store\": \"google_play\",\n        \"device\": \"Phone\",\n        \"size\": \"1080 x 1920 pixels or larger, 9:16 (each side 320 to 3840 pixels)\",\n        \"count\": \"2 to 8 (4 or more recommended)\",\n        \"required\": true\n      },\n      {\n        \"store\": \"google_play\",\n        \"device\": \"7-inch and 10-inch tablet\",\n        \"size\": \"1200 x 1920 and 1600 x 2560 pixels\",\n        \"count\": \"up to 8 each\",\n        \"required\": false\n      }\n    ],\n    \"graphics\": [\n      {\n        \"store\": \"apple\",\n        \"what\": \"App icon\",\n        \"size\": \"1024 x 1024 PNG without transparency (comes from the app project)\",\n        \"required\": true\n      },\n      {\n        \"store\": \"google_play\",\n        \"what\": \"App icon\",\n        \"size\": \"512 x 512 PNG, 32-bit\",\n        \"required\": true\n      },\n      {\n        \"store\": \"google_play\",\n        \"what\": \"Feature graphic\",\n        \"size\": \"1024 x 500 PNG or JPG\",\n        \"required\": true\n      }\n    ],\n    \"screens_to_capture\": [\n      \"The home screen (first page of the app)\",\n      \"Privacy (/privacy.html)\",\n      \"Contact (/contact.html)\",\n      \"A form filled in, just before sending\",\n      \"The chat open with a question and its answer\",\n      \"The notification permission prompt or a notification on the lock screen\"\n    ],\n    \"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).\"\n  },\n  \"limits\": {\n    \"apple\": {\n      \"name\": 30,\n      \"subtitle\": 30,\n      \"promotional_text\": 170,\n      \"description\": 4000,\n      \"keywords\": 100\n    },\n    \"google_play\": {\n      \"title\": 30,\n      \"short_description\": 80,\n      \"full_description\": 4000\n    }\n  }\n}"
            }
          ]
        },
        {
          "name": "Answers for Apple App Privacy and Google Play Data safety",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/privacy-answers",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "privacy-answers"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The answers per store, with reasons and console steps.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/privacy-answers",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "privacy-answers"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The answers per store, with reasons and console steps.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"site\": {\n    \"id\": \"8d2c4b1e-3f5a-4c6d-9e7f-1a2b3c4d5e6f\",\n    \"name\": \"Acme Plumbing\",\n    \"app_url\": \"https://acme-plumbing.hypamoon.app\"\n  },\n  \"sources\": [\n    \"forms\",\n    \"chat_widget\",\n    \"bot_check\",\n    \"push\"\n  ],\n  \"apple\": {\n    \"data_collected\": true,\n    \"used_for_tracking\": false,\n    \"data_types\": [\n      {\n        \"category\": \"Contact Info\",\n        \"type\": \"Name\",\n        \"linked_to_user\": true,\n        \"used_for_tracking\": false,\n        \"purposes\": [\n          \"App Functionality\"\n        ],\n        \"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.\",\n        \"source\": \"forms\"\n      },\n      {\n        \"category\": \"Contact Info\",\n        \"type\": \"Email Address\",\n        \"linked_to_user\": true,\n        \"used_for_tracking\": false,\n        \"purposes\": [\n          \"App Functionality\"\n        ],\n        \"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.\",\n        \"source\": \"forms\"\n      },\n      {\n        \"category\": \"Contact Info\",\n        \"type\": \"Phone Number\",\n        \"linked_to_user\": true,\n        \"used_for_tracking\": false,\n        \"purposes\": [\n          \"App Functionality\"\n        ],\n        \"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.\",\n        \"source\": \"forms\"\n      },\n      {\n        \"category\": \"User Content\",\n        \"type\": \"Other User Content\",\n        \"linked_to_user\": true,\n        \"used_for_tracking\": false,\n        \"purposes\": [\n          \"App Functionality\"\n        ],\n        \"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.\",\n        \"source\": \"forms, chat_widget\"\n      },\n      {\n        \"category\": \"User Content\",\n        \"type\": \"Customer Support\",\n        \"linked_to_user\": true,\n        \"used_for_tracking\": false,\n        \"purposes\": [\n          \"App Functionality\"\n        ],\n        \"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.\",\n        \"source\": \"chat_widget\"\n      },\n      {\n        \"category\": \"Diagnostics\",\n        \"type\": \"Other Diagnostic Data\",\n        \"linked_to_user\": false,\n        \"used_for_tracking\": false,\n        \"purposes\": [\n          \"App Functionality\"\n        ],\n        \"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.\",\n        \"source\": \"bot_check\"\n      },\n      {\n        \"category\": \"Identifiers\",\n        \"type\": \"Device ID\",\n        \"linked_to_user\": false,\n        \"used_for_tracking\": false,\n        \"purposes\": [\n          \"App Functionality\"\n        ],\n        \"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.\",\n        \"source\": \"push\"\n      }\n    ],\n    \"console_steps\": [\n      \"Open App Store Connect, choose the app, then App Privacy in the sidebar.\",\n      \"Next to Privacy Policy, click Edit and paste the privacy policy URL of the site. Save.\",\n      \"Under Data Types, click Get Started (or Edit).\",\n      \"\\\"Do you or your third-party partners collect data from this app?\\\" Answer Yes, we collect data from this app.\",\n      \"Tick every data type below, then Save. App Store Connect then asks about each one in turn.\",\n      \"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.\",\n      \"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.\",\n      \"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.\",\n      \"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.\",\n      \"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.\",\n      \"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.\",\n      \"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.\",\n      \"Click Publish at the top right. The labels go live with the next version you submit.\"\n    ]\n  },\n  \"google_play\": {\n    \"collects_or_shares\": true,\n    \"encrypted_in_transit\": true,\n    \"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.\",\n    \"data_types\": [\n      {\n        \"category\": \"Personal info\",\n        \"type\": \"Name\",\n        \"collected\": true,\n        \"shared\": false,\n        \"processed_ephemerally\": false,\n        \"required\": false,\n        \"purposes\": [\n          \"App functionality\"\n        ],\n        \"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.\",\n        \"source\": \"forms\"\n      },\n      {\n        \"category\": \"Personal info\",\n        \"type\": \"Email address\",\n        \"collected\": true,\n        \"shared\": false,\n        \"processed_ephemerally\": false,\n        \"required\": false,\n        \"purposes\": [\n          \"App functionality\"\n        ],\n        \"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.\",\n        \"source\": \"forms\"\n      },\n      {\n        \"category\": \"Personal info\",\n        \"type\": \"Phone number\",\n        \"collected\": true,\n        \"shared\": false,\n        \"processed_ephemerally\": false,\n        \"required\": false,\n        \"purposes\": [\n          \"App functionality\"\n        ],\n        \"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.\",\n        \"source\": \"forms\"\n      },\n      {\n        \"category\": \"App activity\",\n        \"type\": \"Other user-generated content\",\n        \"collected\": true,\n        \"shared\": false,\n        \"processed_ephemerally\": false,\n        \"required\": false,\n        \"purposes\": [\n          \"App functionality\"\n        ],\n        \"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.\",\n        \"source\": \"forms\"\n      },\n      {\n        \"category\": \"Messages\",\n        \"type\": \"Other in-app messages\",\n        \"collected\": true,\n        \"shared\": false,\n        \"processed_ephemerally\": false,\n        \"required\": false,\n        \"purposes\": [\n          \"App functionality\"\n        ],\n        \"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.\",\n        \"source\": \"chat_widget\"\n      },\n      {\n        \"category\": \"Device or other IDs\",\n        \"type\": \"Device or other IDs\",\n        \"collected\": true,\n        \"shared\": false,\n        \"processed_ephemerally\": false,\n        \"required\": true,\n        \"purposes\": [\n          \"Fraud prevention, security, and compliance\",\n          \"App functionality\"\n        ],\n        \"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.\",\n        \"source\": \"bot_check, push\"\n      }\n    ],\n    \"console_steps\": [\n      \"Open the Play Console, choose the app, then Policy and programs > App content > Data safety, and click Start.\",\n      \"\\\"Does your app collect or share any of the required user data types?\\\" Answer Yes.\",\n      \"\\\"Is all of the user data collected by your app encrypted in transit?\\\" Answer Yes (every page and API call uses HTTPS).\",\n      \"\\\"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\\\".\",\n      \"\\\"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).\",\n      \"Next. Tick each data type below, then Next again and answer the questions per type.\",\n      \"Personal info > Name: tick Collected; \\\"Is this data processed ephemerally?\\\" No; \\\"Is this data required?\\\" Users can choose; purposes: App functionality.\",\n      \"Personal info > Email address: tick Collected; \\\"Is this data processed ephemerally?\\\" No; \\\"Is this data required?\\\" Users can choose; purposes: App functionality.\",\n      \"Personal info > Phone number: tick Collected; \\\"Is this data processed ephemerally?\\\" No; \\\"Is this data required?\\\" Users can choose; purposes: App functionality.\",\n      \"App activity > Other user-generated content: tick Collected; \\\"Is this data processed ephemerally?\\\" No; \\\"Is this data required?\\\" Users can choose; purposes: App functionality.\",\n      \"Messages > Other in-app messages: tick Collected; \\\"Is this data processed ephemerally?\\\" No; \\\"Is this data required?\\\" Users can choose; purposes: App functionality.\",\n      \"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.\",\n      \"Check the preview, then Save and submit for review.\"\n    ]\n  },\n  \"not_collected\": [\n    {\n      \"what\": \"Audio and call recordings\",\n      \"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.\"\n    },\n    {\n      \"what\": \"Usage analytics by Hypamoon\",\n      \"reason\": \"The site host counts requests per site for capacity, without IP addresses, cookies or any identifier of the visitor.\"\n    },\n    {\n      \"what\": \"Location\",\n      \"reason\": \"The site does not ask for the location.\"\n    },\n    {\n      \"what\": \"Financial info\",\n      \"reason\": \"No checkout, payment links or paid access found.\"\n    }\n  ],\n  \"notes\": [\n    \"Run this again after every change to the site: new forms, scripts or payments change the answers.\",\n    \"These answers are a careful starting point. The user is responsible for them and submits them in the console.\"\n  ]\n}"
            }
          ]
        },
        {
          "name": "Export a Capacitor app project for a site",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/app-export",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "app-export"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"app_id\": \"com.acmeplumbing.app\",\n  \"icon_path\": \"/icon-1024.png\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The project is ready to download.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/app-export",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "app-export"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"app_id\": \"com.acmeplumbing.app\",\n  \"icon_path\": \"/icon-1024.png\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The project is ready to download.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"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\",\n  \"expires_at\": \"2026-10-06T12:20:00.000Z\",\n  \"app_id\": \"com.acmeplumbing.app\",\n  \"app_id_reason\": \"Chosen by the user.\",\n  \"app_name\": \"Acme Plumbing\",\n  \"app_url\": \"https://acme-plumbing.hypamoon.app\",\n  \"icon\": \"assets/icon-only.png\",\n  \"background_color\": \"#0b5cff\",\n  \"files\": [\n    \"assets/icon-only.png\",\n    \"package.json\",\n    \"capacitor.config.json\",\n    \"www/index.html\",\n    \"www/offline.html\",\n    \"www/app.js\",\n    \".gitignore\",\n    \"hypamoon.json\",\n    \"README.md\"\n  ],\n  \"bytes\": 9214,\n  \"warnings\": [],\n  \"test\": false\n}"
            }
          ]
        },
        {
          "name": "Check what works on a site and what is missing before going live",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/readiness",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "readiness"
              ],
              "query": [
                {
                  "key": "type",
                  "value": "webshop",
                  "description": "The kind of application; leave it out to detect it from the site.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 What works, what is missing, and what to check with the owner.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/readiness",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "readiness"
                  ],
                  "query": [
                    {
                      "key": "type",
                      "value": "webshop",
                      "description": "The kind of application; leave it out to detect it from the site.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "What works, what is missing, and what to check with the owner.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"site\": {\n    \"id\": \"7a1c2e0f-3b4d-4e5f-8a9b-0c1d2e3f4a5b\",\n    \"url\": \"https://bakkerij-jansen.hypamoon.app\",\n    \"live\": true\n  },\n  \"type\": \"webshop\",\n  \"type_source\": \"given\",\n  \"region\": \"eu\",\n  \"country\": \"NL\",\n  \"works\": [\n    {\n      \"id\": \"privacy_page\",\n      \"title\": \"Privacy statement\",\n      \"detail\": \"Found /privacy, linked from the site.\"\n    },\n    {\n      \"id\": \"contact_details\",\n      \"title\": \"Contact and company details\",\n      \"detail\": \"Found an email address, an address, the KvK number.\"\n    },\n    {\n      \"id\": \"forms_saved\",\n      \"title\": \"Forms save what visitors send\",\n      \"detail\": \"1 form found, each sent to collection contact.\"\n    },\n    {\n      \"id\": \"no_fake_login\",\n      \"title\": \"No password checked in JavaScript\",\n      \"detail\": \"No password is checked in JavaScript.\"\n    },\n    {\n      \"id\": \"no_local_storage_db\",\n      \"title\": \"No visitor data kept in localStorage\",\n      \"detail\": \"localStorage is not used for visitor data.\"\n    },\n    {\n      \"id\": \"no_test_wording\",\n      \"title\": \"No test wording on the live site\",\n      \"detail\": \"No test wording found on the live site.\"\n    },\n    {\n      \"id\": \"prices_currency\",\n      \"title\": \"Prices with currency\",\n      \"detail\": \"Prices are shown with their currency.\"\n    },\n    {\n      \"id\": \"no_placeholders\",\n      \"title\": \"No placeholder text\",\n      \"detail\": \"No placeholder text found.\"\n    },\n    {\n      \"id\": \"seo_h1\",\n      \"title\": \"One main heading per page\",\n      \"detail\": \"Every page has exactly one h1 heading (all 4 pages checked).\"\n    },\n    {\n      \"id\": \"seo_image_alt\",\n      \"title\": \"Alt text on images\",\n      \"detail\": \"The pages have no <img> elements.\"\n    },\n    {\n      \"id\": \"seo_image_size\",\n      \"title\": \"Image file sizes\",\n      \"detail\": \"The site has no image files of its own.\"\n    }\n  ],\n  \"missing\": [\n    {\n      \"id\": \"terms_page\",\n      \"title\": \"Terms or policy page\",\n      \"explanation\": \"No terms and conditions page was found. Before you go live, you might want to add one and link it in the footer.\",\n      \"next_step\": {\n        \"tool\": \"deploy_site\",\n        \"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.\"\n      }\n    },\n    {\n      \"id\": \"returns_page\",\n      \"title\": \"Returns and withdrawal page\",\n      \"explanation\": \"No returns or withdrawal page was found. Before you go live, you might want to add one and link it in the footer.\",\n      \"next_step\": {\n        \"tool\": \"deploy_site\",\n        \"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.\"\n      }\n    },\n    {\n      \"id\": \"checkout_real\",\n      \"title\": \"Buttons and cart lead somewhere real\",\n      \"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.\",\n      \"next_step\": {\n        \"tool\": \"get_checkout_snippet\",\n        \"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.\"\n      },\n      \"paths\": [\n        \"/index.html\",\n        \"/producten.html\"\n      ]\n    },\n    {\n      \"id\": \"internal_links\",\n      \"title\": \"Internal links work\",\n      \"explanation\": \"1 internal link lead to a page that does not exist: /voorwaarden (on /producten).\",\n      \"next_step\": {\n        \"tool\": \"deploy_site\",\n        \"how\": \"Add the missing pages or fix the links, with keep_existing: true.\"\n      },\n      \"paths\": [\n        \"/producten.html\"\n      ]\n    },\n    {\n      \"id\": \"not_found_page\",\n      \"title\": \"Not-found page\",\n      \"explanation\": \"The site has no own not-found page; visitors who follow an old link see a plain error.\",\n      \"next_step\": {\n        \"tool\": \"deploy_site\",\n        \"how\": \"Add /404.html with a link back home and the contact details, with keep_existing: true.\"\n      }\n    },\n    {\n      \"id\": \"seo_title\",\n      \"title\": \"Page titles for search\",\n      \"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.\",\n      \"next_step\": {\n        \"tool\": \"deploy_site\",\n        \"how\": \"Add a <title> of 10 to 60 characters to the head of each page, saying what that page is about (like \\\"Fresh sourdough bread | Bakery name\\\"), and deploy with keep_existing: true.\"\n      },\n      \"paths\": [\n        \"/producten.html\",\n        \"/contact.html\",\n        \"/privacy.html\"\n      ]\n    },\n    {\n      \"id\": \"seo_description\",\n      \"title\": \"Meta descriptions for search\",\n      \"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.\",\n      \"next_step\": {\n        \"tool\": \"deploy_site\",\n        \"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.\"\n      },\n      \"paths\": [\n        \"/index.html\",\n        \"/producten.html\",\n        \"/contact.html\",\n        \"/privacy.html\"\n      ]\n    },\n    {\n      \"id\": \"seo_lang\",\n      \"title\": \"Page language set\",\n      \"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.\",\n      \"next_step\": {\n        \"tool\": \"deploy_site\",\n        \"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.\"\n      },\n      \"paths\": [\n        \"/producten.html\",\n        \"/contact.html\",\n        \"/privacy.html\"\n      ]\n    },\n    {\n      \"id\": \"seo_open_graph\",\n      \"title\": \"Share preview (Open Graph)\",\n      \"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.\",\n      \"next_step\": {\n        \"tool\": \"deploy_site\",\n        \"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.\"\n      },\n      \"paths\": [\n        \"/index.html\",\n        \"/producten.html\",\n        \"/contact.html\",\n        \"/privacy.html\"\n      ]\n    },\n    {\n      \"id\": \"seo_viewport\",\n      \"title\": \"Mobile viewport\",\n      \"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.\",\n      \"next_step\": {\n        \"tool\": \"deploy_site\",\n        \"how\": \"Add <meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\"> to the head of each page and deploy with keep_existing: true.\"\n      },\n      \"paths\": [\n        \"/index.html\",\n        \"/producten.html\",\n        \"/contact.html\",\n        \"/privacy.html\"\n      ]\n    },\n    {\n      \"id\": \"seo_favicon\",\n      \"title\": \"Favicon\",\n      \"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.\",\n      \"next_step\": {\n        \"tool\": \"deploy_site\",\n        \"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.\"\n      }\n    },\n    {\n      \"id\": \"structured_data\",\n      \"title\": \"Structured data for search\",\n      \"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.\",\n      \"next_step\": {\n        \"tool\": \"deploy_site\",\n        \"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>\"\n      }\n    }\n  ],\n  \"check_with_owner\": [\n    {\n      \"id\": \"vat_number\",\n      \"level\": \"usually_required\",\n      \"description\": \"The VAT number on the site, if the business has one (not every small business or private seller does).\",\n      \"why\": \"Businesses with a VAT number are usually expected to show it on their site.\",\n      \"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.\"\n    },\n    {\n      \"id\": \"cookie_notice\",\n      \"level\": \"recommended\",\n      \"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.\",\n      \"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.\",\n      \"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.\"\n    },\n    {\n      \"id\": \"accessibility\",\n      \"level\": \"recommended\",\n      \"description\": \"Basic accessibility: readable contrast, alt text on images, labels on form fields, keyboard navigation, and an accessibility statement for online shops and services.\",\n      \"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.\",\n      \"how_on_hypamoon\": \"Build the pages with labels, alt text and enough contrast; add /accessibility if the business is not a micro-enterprise.\"\n    },\n    {\n      \"id\": \"withdrawal_function\",\n      \"level\": \"usually_required\",\n      \"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.\",\n      \"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.\",\n      \"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.\"\n    },\n    {\n      \"id\": \"shipping_costs_upfront\",\n      \"level\": \"usually_required\",\n      \"description\": \"Shipping costs, delivery restrictions (countries) and the expected delivery time shown before the customer starts ordering.\",\n      \"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.\",\n      \"how_on_hypamoon\": \"Show shipping costs on the product pages and next to the order button (deploy_site).\"\n    },\n    {\n      \"id\": \"payment_methods_upfront\",\n      \"level\": \"usually_required\",\n      \"description\": \"The accepted payment methods shown at the latest at the start of the ordering process.\",\n      \"why\": \"The Consumer Rights Directive asks for this at the start of the order.\",\n      \"how_on_hypamoon\": \"List the methods of the user's payment account on the product page or in the footer.\"\n    },\n    {\n      \"id\": \"order_button_text\",\n      \"level\": \"usually_required\",\n      \"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\\\".\",\n      \"why\": \"The EU Consumer Rights Directive and the UK rules require this; without it the customer may not be bound by the order.\",\n      \"how_on_hypamoon\": \"Set the button text on the checkout button (get_checkout_snippet html) or the order request button.\"\n    },\n    {\n      \"id\": \"order_confirmation\",\n      \"level\": \"usually_required\",\n      \"description\": \"An order confirmation by email (a durable medium) with what was ordered, the price, the company details and the withdrawal information.\",\n      \"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.\",\n      \"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.\"\n    },\n    {\n      \"id\": \"legal_guarantee\",\n      \"level\": \"recommended\",\n      \"description\": \"A line about the legal guarantee: products must be as promised, and consumers keep their legal rights when something is wrong.\",\n      \"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.\",\n      \"how_on_hypamoon\": \"Add it to /terms or /returns with deploy_site.\"\n    },\n    {\n      \"id\": \"complaints\",\n      \"level\": \"recommended\",\n      \"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).\",\n      \"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.\",\n      \"how_on_hypamoon\": \"Add it to /terms or /contact with deploy_site.\"\n    },\n    {\n      \"id\": \"age_check\",\n      \"level\": \"usually_required\",\n      \"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.\",\n      \"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.\",\n      \"how_on_hypamoon\": \"Only if relevant: add an age statement to the order form and tell the user to arrange the check at delivery.\"\n    },\n    {\n      \"id\": \"vat_registration\",\n      \"level\": \"recommended\",\n      \"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.\",\n      \"why\": \"Above that threshold the VAT of the buyer's country applies.\",\n      \"how_on_hypamoon\": \"Nothing to build; mention it to the user so they check it with their accountant.\"\n    }\n  ],\n  \"pages_checked\": 4,\n  \"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).\",\n  \"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)._\"\n}"
            }
          ]
        },
        {
          "name": "Verify a site or domain in Google Search Console",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/search-console/verify",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "search-console",
                "verify"
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"domain\": \"bakkerijjansen.nl\",\n  \"method\": \"dns\",\n  \"token\": \"Abc123dEf456gHi789jKl012mNo345pQr678sTu901v\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 What was added and what the user does next.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/search-console/verify",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "search-console",
                    "verify"
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"domain\": \"bakkerijjansen.nl\",\n  \"method\": \"dns\",\n  \"token\": \"Abc123dEf456gHi789jKl012mNo345pQr678sTu901v\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "What was added and what the user does next.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"method\": \"dns\",\n  \"status\": \"added\",\n  \"site_id\": \"5b1f3c2a-8d4e-4f6a-9b7c-1d2e3f4a5b6c\",\n  \"domain\": \"bakkerijjansen.nl\",\n  \"property\": \"sc-domain:bakkerijjansen.nl\",\n  \"record\": {\n    \"type\": \"TXT\",\n    \"name\": \"bakkerijjansen.nl\",\n    \"content\": \"google-site-verification=Abc123dEf456gHi789jKl012mNo345pQr678sTu901v\"\n  },\n  \"sitemap\": \"https://bakkerijjansen.nl/sitemap.xml\",\n  \"next_steps\": [\n    \"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.\",\n    \"Keep the TXT record: removing it ends the verification.\",\n    \"After verifying, go to Sitemaps and submit https://bakkerijjansen.nl/sitemap.xml.\"\n  ]\n}"
            }
          ]
        },
        {
          "name": "Get the visits of a site",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/stats",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "stats"
              ],
              "query": [
                {
                  "key": "days",
                  "value": "7",
                  "description": "How many days back, today included (default 30).",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 Page views per day, top pages and origins.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/stats",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "stats"
                  ],
                  "query": [
                    {
                      "key": "days",
                      "value": "7",
                      "description": "How many days back, today included (default 30).",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "Page views per day, top pages and origins.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"site_id\": \"5b1f3c2a-8d4e-4f6a-9b7c-1d2e3f4a5b6c\",\n  \"slug\": \"bakkerij-jansen\",\n  \"days\": 7,\n  \"from\": \"2026-10-03\",\n  \"to\": \"2026-10-09\",\n  \"totals\": {\n    \"page_views\": 60,\n    \"bot_page_views_excluded\": 41\n  },\n  \"per_day\": [\n    {\n      \"date\": \"2026-10-03\",\n      \"page_views\": 0\n    },\n    {\n      \"date\": \"2026-10-04\",\n      \"page_views\": 0\n    },\n    {\n      \"date\": \"2026-10-05\",\n      \"page_views\": 12\n    },\n    {\n      \"date\": \"2026-10-06\",\n      \"page_views\": 0\n    },\n    {\n      \"date\": \"2026-10-07\",\n      \"page_views\": 30\n    },\n    {\n      \"date\": \"2026-10-08\",\n      \"page_views\": 0\n    },\n    {\n      \"date\": \"2026-10-09\",\n      \"page_views\": 18\n    }\n  ],\n  \"top_pages\": [\n    {\n      \"path\": \"/\",\n      \"page_views\": 34\n    },\n    {\n      \"path\": \"/menu\",\n      \"page_views\": 19\n    },\n    {\n      \"path\": \"/contact\",\n      \"page_views\": 7\n    }\n  ],\n  \"origins\": [\n    {\n      \"origin\": \"search\",\n      \"page_views\": 28\n    },\n    {\n      \"origin\": \"direct\",\n      \"page_views\": 20\n    },\n    {\n      \"origin\": \"social\",\n      \"page_views\": 12\n    }\n  ],\n  \"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.\"\n}"
            }
          ]
        },
        {
          "name": "Fetch a page of a site or app, live",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/sites/:id/fetch",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                ":id",
                "fetch"
              ],
              "query": [
                {
                  "key": "path",
                  "value": "/contact",
                  "description": "The path on the site's address, like / or /contact or /api/health, with a query if needed. Give this or url.",
                  "disabled": true
                },
                {
                  "key": "url",
                  "value": "https://bakkerijjansen.nl/menu",
                  "description": "A full https URL on an address of this site, like a connected domain. Give this or path.",
                  "disabled": true
                },
                {
                  "key": "method",
                  "value": "GET",
                  "description": "GET (default) or HEAD.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the site."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 What the site answered.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/sites/:id/fetch",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    ":id",
                    "fetch"
                  ],
                  "query": [
                    {
                      "key": "path",
                      "value": "/contact",
                      "description": "The path on the site's address, like / or /contact or /api/health, with a query if needed. Give this or url.",
                      "disabled": true
                    },
                    {
                      "key": "url",
                      "value": "https://bakkerijjansen.nl/menu",
                      "description": "A full https URL on an address of this site, like a connected domain. Give this or path.",
                      "disabled": true
                    },
                    {
                      "key": "method",
                      "value": "GET",
                      "description": "GET (default) or HEAD.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the site."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "What the site answered.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"url\": \"https://bakkerij-jansen.hypamoon.app/contact\",\n  \"final_url\": \"https://bakkerij-jansen.hypamoon.app/contact\",\n  \"method\": \"GET\",\n  \"status\": 200,\n  \"ok\": true,\n  \"headers\": {\n    \"content-type\": \"text/html; charset=utf-8\",\n    \"cache-control\": \"public, max-age=60\"\n  },\n  \"redirects\": [],\n  \"content_type\": \"text/html\",\n  \"body\": \"<!doctype html><html lang=\\\"nl\\\"><head><title>Contact | Bakkerij Jansen</title>\",\n  \"body_bytes\": 4210,\n  \"truncated\": false,\n  \"elapsed_ms\": 182\n}"
            }
          ]
        },
        {
          "name": "Import an existing site",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites/import",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                "import"
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://www.example.com\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "202 Four-eyes approval is on: the imported site waits for another owner or builder, with import. Nothing has changed on ",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/import",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    "import"
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"https://www.example.com\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Four-eyes approval is on: the imported site waits for another owner or builder, with import. Nothing has changed on the live site yet.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"deploy_approval\",\n  \"id\": \"8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90\",\n  \"status\": \"pending\",\n  \"kind\": \"new_site\",\n  \"site_id\": null,\n  \"name\": \"Acme Plumbing\",\n  \"slug\": \"acme-plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": 2,\n  \"bytes\": 1840,\n  \"password_change\": \"none\",\n  \"requested_by\": {\n    \"user_id\": \"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\",\n    \"via\": \"api\",\n    \"app\": \"Claude\"\n  },\n  \"requested_at\": \"2026-10-05T10:15:00Z\",\n  \"expires_at\": \"2026-10-12T10:15:00Z\",\n  \"reviewed_by\": null,\n  \"reviewed_at\": null,\n  \"reason\": null,\n  \"result\": null\n}"
            }
          ]
        },
        {
          "name": "Inspect a WordPress site",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites/inspect-wordpress",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                "inspect-wordpress"
              ]
            },
            "description": "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.\n\nScope: sites:read",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://www.example.com\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Import a WordPress site",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/sites/import-wordpress",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "sites",
                "import-wordpress"
              ]
            },
            "description": "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).\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://www.example.com\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "202 Four-eyes approval is on: the imported site waits for another owner or builder. Nothing has changed on the live site",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/sites/import-wordpress",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "sites",
                    "import-wordpress"
                  ]
                },
                "description": "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).\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"https://www.example.com\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Four-eyes approval is on: the imported site waits for another owner or builder. Nothing has changed on the live site yet.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"deploy_approval\",\n  \"id\": \"8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90\",\n  \"status\": \"pending\",\n  \"kind\": \"new_site\",\n  \"site_id\": null,\n  \"name\": \"Acme Plumbing\",\n  \"slug\": \"acme-plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": 2,\n  \"bytes\": 1840,\n  \"password_change\": \"none\",\n  \"requested_by\": {\n    \"user_id\": \"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\",\n    \"via\": \"api\",\n    \"app\": \"Claude\"\n  },\n  \"requested_at\": \"2026-10-05T10:15:00Z\",\n  \"expires_at\": \"2026-10-12T10:15:00Z\",\n  \"reviewed_by\": null,\n  \"reviewed_at\": null,\n  \"reason\": null,\n  \"result\": null\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Media",
      "description": "The project's media library: photos and files the user uploads once (in the portal or with an upload page), private until you put them on a site with media in POST /sites or PUT /sites/{id}. No bytes go through a chat.",
      "item": [
        {
          "name": "List the media library",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/media",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "media"
              ],
              "query": [
                {
                  "key": "folder",
                  "value": "",
                  "description": "Only this folder under /media/, with its subfolders, like portfolio.",
                  "disabled": true
                },
                {
                  "key": "path",
                  "value": "",
                  "description": "Only the file with this path, like /media/logo.webp.",
                  "disabled": true
                },
                {
                  "key": "site_id",
                  "value": "",
                  "description": "Only the files this site uses.",
                  "disabled": true
                },
                {
                  "key": "limit",
                  "value": "",
                  "description": "At most this many files (default 100).",
                  "disabled": true
                },
                {
                  "key": "cursor",
                  "value": "",
                  "description": "next_cursor from the previous page.",
                  "disabled": true
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The files and the folders.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/media",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "media"
                  ],
                  "query": [
                    {
                      "key": "folder",
                      "value": "",
                      "description": "Only this folder under /media/, with its subfolders, like portfolio.",
                      "disabled": true
                    },
                    {
                      "key": "path",
                      "value": "",
                      "description": "Only the file with this path, like /media/logo.webp.",
                      "disabled": true
                    },
                    {
                      "key": "site_id",
                      "value": "",
                      "description": "Only the files this site uses.",
                      "disabled": true
                    },
                    {
                      "key": "limit",
                      "value": "",
                      "description": "At most this many files (default 100).",
                      "disabled": true
                    },
                    {
                      "key": "cursor",
                      "value": "",
                      "description": "next_cursor from the previous page.",
                      "disabled": true
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The files and the folders.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"6c0e3a5b-2f1d-4a8e-9b7c-1d2e3f4a5b6c\",\n      \"path\": \"/media/portfolio/project-12/03.webp\",\n      \"folder\": \"portfolio/project-12\",\n      \"name\": \"03.webp\",\n      \"original_name\": \"03.jpg\",\n      \"content_type\": \"image/webp\",\n      \"bytes\": 384112,\n      \"width\": 2048,\n      \"height\": 1365,\n      \"alt\": null,\n      \"url\": null,\n      \"variants\": {\n        \"640\": {\n          \"path\": \"/media/portfolio/project-12/03-640.webp\",\n          \"bytes\": 41240,\n          \"width\": 640,\n          \"height\": 427,\n          \"url\": null\n        }\n      },\n      \"srcset\": null,\n      \"original_kept\": true,\n      \"sites\": [],\n      \"source\": \"portaal\",\n      \"created_at\": \"2026-10-01T09:00:00.000Z\",\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n    }\n  ],\n  \"folders\": [\n    {\n      \"folder\": \"portfolio/project-12\",\n      \"files\": 1\n    }\n  ],\n  \"next_cursor\": null\n}"
            }
          ]
        },
        {
          "name": "Create an upload page for the media library",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/media/links",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "media",
                "links"
              ]
            },
            "description": "Returns an upload page, https://app.hypamoon.com/drop/<token>, 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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"folder\": \"portfolio\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The upload page.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/media/links",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "media",
                    "links"
                  ]
                },
                "description": "Returns an upload page, https://app.hypamoon.com/drop/<token>, 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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"folder\": \"portfolio\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The upload page.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"3f1c2a9e-5b7d-4e8f-9a0b-1c2d3e4f5a6b\",\n  \"status\": \"open\",\n  \"site_id\": null,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"expires_at\": \"2026-10-02T09:00:00.000Z\",\n  \"completed_at\": null,\n  \"images_only\": false,\n  \"strip\": \"location\",\n  \"folder\": \"portfolio\",\n  \"max_files\": 100,\n  \"files\": [],\n  \"originals_kept\": 0,\n  \"site_version\": null,\n  \"deploy_files\": [],\n  \"url\": \"https://app.hypamoon.com/drop/hmd_4fT0aQ8xK2mPq7RsV1wYz3Bc5De6Fg9H\",\n  \"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.\",\n  \"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.\"\n}"
            }
          ]
        },
        {
          "name": "Get a file from the media library",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/media/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "media",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the file."
                }
              ]
            },
            "description": "One file with its versions, alt text and the sites that use it.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The file.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/media/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "media",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the file."
                    }
                  ]
                },
                "description": "One file with its versions, alt text and the sites that use it.\n\nScope: sites:read"
              },
              "status": "The file.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6c0e3a5b-2f1d-4a8e-9b7c-1d2e3f4a5b6c\",\n  \"path\": \"/media/portfolio/project-12/03.webp\",\n  \"folder\": \"portfolio/project-12\",\n  \"name\": \"03.webp\",\n  \"original_name\": \"03.jpg\",\n  \"content_type\": \"image/webp\",\n  \"bytes\": 384112,\n  \"width\": 2048,\n  \"height\": 1365,\n  \"alt\": null,\n  \"url\": null,\n  \"variants\": {\n    \"640\": {\n      \"path\": \"/media/portfolio/project-12/03-640.webp\",\n      \"bytes\": 41240,\n      \"width\": 640,\n      \"height\": 427,\n      \"url\": null\n    }\n  },\n  \"srcset\": null,\n  \"original_kept\": true,\n  \"sites\": [],\n  \"source\": \"portaal\",\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Rename or move a file, or set its alt text",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/media/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "media",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the file."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"alt\": \"Apple pie on a wooden board\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The file after the change.",
              "originalRequest": {
                "method": "PATCH",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/media/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "media",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the file."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"alt\": \"Apple pie on a wooden board\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The file after the change.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6c0e3a5b-2f1d-4a8e-9b7c-1d2e3f4a5b6c\",\n  \"path\": \"/media/portfolio/project-12/03.webp\",\n  \"folder\": \"portfolio/project-12\",\n  \"name\": \"03.webp\",\n  \"original_name\": \"03.jpg\",\n  \"content_type\": \"image/webp\",\n  \"bytes\": 384112,\n  \"width\": 2048,\n  \"height\": 1365,\n  \"alt\": \"Apple pie on a wooden board\",\n  \"url\": null,\n  \"variants\": {\n    \"640\": {\n      \"path\": \"/media/portfolio/project-12/03-640.webp\",\n      \"bytes\": 41240,\n      \"width\": 640,\n      \"height\": 427,\n      \"url\": null\n    }\n  },\n  \"srcset\": null,\n  \"original_kept\": true,\n  \"sites\": [],\n  \"source\": \"portaal\",\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Delete a file from the media library",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/media/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "media",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the file."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write"
          },
          "response": []
        }
      ]
    },
    {
      "name": "Data",
      "description": "The data layer of a project: collections of JSON documents that your agent shapes itself. A contact form on your site, sign-ups, reservations, or facts for the assistant. Free, including in test mode. Forms post to /public/collections/{name}/documents with form.js and the public token.",
      "item": [
        {
          "name": "List the collections",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/collections",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "collections"
              ]
            },
            "description": "The project's collections, newest first, each with its access, triggers, schema and document_count. Deleted collections are not listed.\n\nScope: data:read"
          },
          "response": [
            {
              "name": "200 The collections.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/collections",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "collections"
                  ]
                },
                "description": "The project's collections, newest first, each with its access, triggers, schema and document_count. Deleted collections are not listed.\n\nScope: data:read"
              },
              "status": "The collections.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f\",\n      \"name\": \"contact\",\n      \"description\": \"The contact form on the website.\",\n      \"access\": {\n        \"public_write\": true,\n        \"public_read\": false,\n        \"assistant_read\": false\n      },\n      \"triggers\": {\n        \"email_on_insert\": true,\n        \"webhook_on_insert\": false\n      },\n      \"email_routing\": null,\n      \"schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\",\n            \"minLength\": 1,\n            \"maxLength\": 100\n          },\n          \"email\": {\n            \"type\": \"string\",\n            \"format\": \"email\"\n          },\n          \"message\": {\n            \"type\": \"string\",\n            \"minLength\": 1,\n            \"maxLength\": 5000\n          }\n        },\n        \"required\": [\n          \"name\",\n          \"email\",\n          \"message\"\n        ]\n      },\n      \"personal_data\": false,\n      \"document_count\": 1,\n      \"created_at\": \"2026-10-01T09:00:00.000Z\",\n      \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n      \"test\": false\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Create a collection",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/collections",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "collections"
              ]
            },
            "description": "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.\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"contact\",\n  \"description\": \"The contact form on the website.\",\n  \"access\": {\n    \"public_write\": true\n  },\n  \"triggers\": {\n    \"email_on_insert\": true\n  },\n  \"schema\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"name\": {\n        \"type\": \"string\",\n        \"minLength\": 1,\n        \"maxLength\": 100\n      },\n      \"email\": {\n        \"type\": \"string\",\n        \"format\": \"email\"\n      },\n      \"message\": {\n        \"type\": \"string\",\n        \"minLength\": 1,\n        \"maxLength\": 5000\n      }\n    },\n    \"required\": [\n      \"name\",\n      \"email\",\n      \"message\"\n    ]\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The new collection.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/collections",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "collections"
                  ]
                },
                "description": "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.\n\nScope: data:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"contact\",\n  \"description\": \"The contact form on the website.\",\n  \"access\": {\n    \"public_write\": true\n  },\n  \"triggers\": {\n    \"email_on_insert\": true\n  },\n  \"schema\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"name\": {\n        \"type\": \"string\",\n        \"minLength\": 1,\n        \"maxLength\": 100\n      },\n      \"email\": {\n        \"type\": \"string\",\n        \"format\": \"email\"\n      },\n      \"message\": {\n        \"type\": \"string\",\n        \"minLength\": 1,\n        \"maxLength\": 5000\n      }\n    },\n    \"required\": [\n      \"name\",\n      \"email\",\n      \"message\"\n    ]\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The new collection.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f\",\n  \"name\": \"contact\",\n  \"description\": \"The contact form on the website.\",\n  \"access\": {\n    \"public_write\": true,\n    \"public_read\": false,\n    \"assistant_read\": false\n  },\n  \"triggers\": {\n    \"email_on_insert\": true,\n    \"webhook_on_insert\": false\n  },\n  \"email_routing\": null,\n  \"schema\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"name\": {\n        \"type\": \"string\",\n        \"minLength\": 1,\n        \"maxLength\": 100\n      },\n      \"email\": {\n        \"type\": \"string\",\n        \"format\": \"email\"\n      },\n      \"message\": {\n        \"type\": \"string\",\n        \"minLength\": 1,\n        \"maxLength\": 5000\n      }\n    },\n    \"required\": [\n      \"name\",\n      \"email\",\n      \"message\"\n    ]\n  },\n  \"personal_data\": false,\n  \"document_count\": 0,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n  \"test\": false\n}"
            }
          ]
        },
        {
          "name": "Get a collection",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/collections/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "collections",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the collection, or its name."
                }
              ]
            },
            "description": "One collection with its access, triggers, schema and document_count. The path takes the id or the name.\n\nScope: data:read"
          },
          "response": [
            {
              "name": "200 The collection.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/collections/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "collections",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the collection, or its name."
                    }
                  ]
                },
                "description": "One collection with its access, triggers, schema and document_count. The path takes the id or the name.\n\nScope: data:read"
              },
              "status": "The collection.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f\",\n  \"name\": \"contact\",\n  \"description\": \"The contact form on the website.\",\n  \"access\": {\n    \"public_write\": true,\n    \"public_read\": false,\n    \"assistant_read\": false\n  },\n  \"triggers\": {\n    \"email_on_insert\": true,\n    \"webhook_on_insert\": false\n  },\n  \"email_routing\": null,\n  \"schema\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"name\": {\n        \"type\": \"string\",\n        \"minLength\": 1,\n        \"maxLength\": 100\n      },\n      \"email\": {\n        \"type\": \"string\",\n        \"format\": \"email\"\n      },\n      \"message\": {\n        \"type\": \"string\",\n        \"minLength\": 1,\n        \"maxLength\": 5000\n      }\n    },\n    \"required\": [\n      \"name\",\n      \"email\",\n      \"message\"\n    ]\n  },\n  \"personal_data\": false,\n  \"document_count\": 1,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n  \"test\": false\n}"
            }
          ]
        },
        {
          "name": "Change a collection",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/collections/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "collections",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the collection, or its name."
                }
              ]
            },
            "description": "Changes the name, description, access, triggers or schema. Fields you leave out stay as they are, including inside access and triggers. A new schema applies to new and changed documents; existing documents are not checked again. A new name breaks a form that posts to the old one.\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"access\": {\n    \"public_write\": true\n  },\n  \"triggers\": {\n    \"webhook_on_insert\": true\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The collection as it is now.",
              "originalRequest": {
                "method": "PATCH",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/collections/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "collections",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the collection, or its name."
                    }
                  ]
                },
                "description": "Changes the name, description, access, triggers or schema. Fields you leave out stay as they are, including inside access and triggers. A new schema applies to new and changed documents; existing documents are not checked again. A new name breaks a form that posts to the old one.\n\nScope: data:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"access\": {\n    \"public_write\": true\n  },\n  \"triggers\": {\n    \"webhook_on_insert\": true\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The collection as it is now.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f\",\n  \"name\": \"contact\",\n  \"description\": \"The contact form on the website.\",\n  \"access\": {\n    \"public_write\": true,\n    \"public_read\": false,\n    \"assistant_read\": false\n  },\n  \"triggers\": {\n    \"email_on_insert\": true,\n    \"webhook_on_insert\": false\n  },\n  \"email_routing\": null,\n  \"schema\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"name\": {\n        \"type\": \"string\",\n        \"minLength\": 1,\n        \"maxLength\": 100\n      },\n      \"email\": {\n        \"type\": \"string\",\n        \"format\": \"email\"\n      },\n      \"message\": {\n        \"type\": \"string\",\n        \"minLength\": 1,\n        \"maxLength\": 5000\n      }\n    },\n    \"required\": [\n      \"name\",\n      \"email\",\n      \"message\"\n    ]\n  },\n  \"personal_data\": false,\n  \"document_count\": 1,\n  \"created_at\": \"2026-10-01T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:00:00.000Z\",\n  \"test\": false\n}"
            }
          ]
        },
        {
          "name": "Delete a collection",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/collections/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "collections",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the collection, or its name."
                }
              ]
            },
            "description": "Deletes the collection: its documents can no longer be reached, a form that posts to it receives 404, and the name becomes available again. Deleting it again returns 204 as well.\n\nScope: data:write"
          },
          "response": []
        },
        {
          "name": "List the documents",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/collections/:id/documents",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "collections",
                ":id",
                "documents"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "25",
                  "description": "Documents per page, default 25. A value above 100 is capped at 100.",
                  "disabled": true
                },
                {
                  "key": "cursor",
                  "value": "",
                  "description": "The next_cursor of the previous page.",
                  "disabled": true
                },
                {
                  "key": "before",
                  "value": "",
                  "description": "Only documents added before this moment, in ISO 8601.",
                  "disabled": true
                },
                {
                  "key": "filter",
                  "value": "",
                  "description": "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.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the collection, or its name."
                }
              ]
            },
            "description": "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.\n\nScope: data:read"
          },
          "response": [
            {
              "name": "200 A page of documents.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/collections/:id/documents",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "collections",
                    ":id",
                    "documents"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "25",
                      "description": "Documents per page, default 25. A value above 100 is capped at 100.",
                      "disabled": true
                    },
                    {
                      "key": "cursor",
                      "value": "",
                      "description": "The next_cursor of the previous page.",
                      "disabled": true
                    },
                    {
                      "key": "before",
                      "value": "",
                      "description": "Only documents added before this moment, in ISO 8601.",
                      "disabled": true
                    },
                    {
                      "key": "filter",
                      "value": "",
                      "description": "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.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the collection, or its name."
                    }
                  ]
                },
                "description": "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.\n\nScope: data:read"
              },
              "status": "A page of documents.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918\",\n      \"collection_id\": \"3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f\",\n      \"data\": {\n        \"name\": \"Anna de Vries\",\n        \"email\": \"anna@example.com\",\n        \"message\": \"Can you come and look at a leaking tap on Friday?\"\n      },\n      \"source\": \"public\",\n      \"created_at\": \"2026-10-01T09:30:00.000Z\",\n      \"updated_at\": \"2026-10-01T09:30:00.000Z\",\n      \"test\": false\n    }\n  ],\n  \"has_more\": false,\n  \"next_cursor\": null\n}"
            }
          ]
        },
        {
          "name": "Add a document",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/collections/:id/documents",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "collections",
                ":id",
                "documents"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the collection, or its name."
                }
              ]
            },
            "description": "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).\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": {\n    \"name\": \"Anna de Vries\",\n    \"email\": \"anna@example.com\",\n    \"message\": \"Can you come and look at a leaking tap on Friday?\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The new document.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/collections/:id/documents",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "collections",
                    ":id",
                    "documents"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the collection, or its name."
                    }
                  ]
                },
                "description": "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).\n\nScope: data:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"data\": {\n    \"name\": \"Anna de Vries\",\n    \"email\": \"anna@example.com\",\n    \"message\": \"Can you come and look at a leaking tap on Friday?\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The new document.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918\",\n  \"collection_id\": \"3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f\",\n  \"data\": {\n    \"name\": \"Anna de Vries\",\n    \"email\": \"anna@example.com\",\n    \"message\": \"Can you come and look at a leaking tap on Friday?\"\n  },\n  \"source\": \"api\",\n  \"created_at\": \"2026-10-01T09:30:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:30:00.000Z\",\n  \"test\": false\n}"
            }
          ]
        },
        {
          "name": "Get a document",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/collections/:id/documents/:docId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "collections",
                ":id",
                "documents",
                ":docId"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the collection, or its name."
                },
                {
                  "key": "docId",
                  "value": "",
                  "description": "The id of the document."
                }
              ]
            },
            "description": "One document from the collection.\n\nScope: data:read"
          },
          "response": [
            {
              "name": "200 The document.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/collections/:id/documents/:docId",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "collections",
                    ":id",
                    "documents",
                    ":docId"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the collection, or its name."
                    },
                    {
                      "key": "docId",
                      "value": "",
                      "description": "The id of the document."
                    }
                  ]
                },
                "description": "One document from the collection.\n\nScope: data:read"
              },
              "status": "The document.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918\",\n  \"collection_id\": \"3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f\",\n  \"data\": {\n    \"name\": \"Anna de Vries\",\n    \"email\": \"anna@example.com\",\n    \"message\": \"Can you come and look at a leaking tap on Friday?\"\n  },\n  \"source\": \"public\",\n  \"created_at\": \"2026-10-01T09:30:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:30:00.000Z\",\n  \"test\": false\n}"
            }
          ]
        },
        {
          "name": "Change a document",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/collections/:id/documents/:docId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "collections",
                ":id",
                "documents",
                ":docId"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the collection, or its name."
                },
                {
                  "key": "docId",
                  "value": "",
                  "description": "The id of the document."
                }
              ]
            },
            "description": "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.\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"merge\": {\n    \"status\": \"answered\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The document as it is now.",
              "originalRequest": {
                "method": "PATCH",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/collections/:id/documents/:docId",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "collections",
                    ":id",
                    "documents",
                    ":docId"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the collection, or its name."
                    },
                    {
                      "key": "docId",
                      "value": "",
                      "description": "The id of the document."
                    }
                  ]
                },
                "description": "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.\n\nScope: data:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"merge\": {\n    \"status\": \"answered\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The document as it is now.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918\",\n  \"collection_id\": \"3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f\",\n  \"data\": {\n    \"name\": \"Anna de Vries\",\n    \"email\": \"anna@example.com\",\n    \"message\": \"Can you come and look at a leaking tap on Friday?\",\n    \"status\": \"answered\"\n  },\n  \"source\": \"public\",\n  \"created_at\": \"2026-10-01T09:30:00.000Z\",\n  \"updated_at\": \"2026-10-01T09:30:00.000Z\",\n  \"test\": false\n}"
            }
          ]
        },
        {
          "name": "Delete a document",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/collections/:id/documents/:docId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "collections",
                ":id",
                "documents",
                ":docId"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the collection, or its name."
                },
                {
                  "key": "docId",
                  "value": "",
                  "description": "The id of the document."
                }
              ]
            },
            "description": "Deletes the document immediately and permanently. A second delete returns 404.\n\nScope: data:write"
          },
          "response": []
        },
        {
          "name": "Read the newest documents (public)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/public/collections/:name/documents?token={{publicToken}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "public",
                "collections",
                ":name",
                "documents"
              ],
              "query": [
                {
                  "key": "token",
                  "value": "{{publicToken}}",
                  "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                },
                {
                  "key": "limit",
                  "value": "25",
                  "description": "How many, default 25, at most 100.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "name",
                  "value": "menu",
                  "description": "The name of the collection."
                }
              ]
            },
            "description": "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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
            "auth": {
              "type": "noauth"
            }
          },
          "response": [
            {
              "name": "200 The newest documents.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/public/collections/:name/documents?token={{publicToken}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "public",
                    "collections",
                    ":name",
                    "documents"
                  ],
                  "query": [
                    {
                      "key": "token",
                      "value": "{{publicToken}}",
                      "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                    },
                    {
                      "key": "limit",
                      "value": "25",
                      "description": "How many, default 25, at most 100.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "name",
                      "value": "menu",
                      "description": "The name of the collection."
                    }
                  ]
                },
                "description": "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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
                "auth": {
                  "type": "noauth"
                }
              },
              "status": "The newest documents.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"data\": {\n        \"dish\": \"Soup of the day\",\n        \"price_eur\": 6.5\n      },\n      \"created_at\": \"2026-10-01T09:00:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Send a form entry (public)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/public/collections/:name/documents?token={{publicToken}}",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "public",
                "collections",
                ":name",
                "documents"
              ],
              "query": [
                {
                  "key": "token",
                  "value": "{{publicToken}}",
                  "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                }
              ],
              "variable": [
                {
                  "key": "name",
                  "value": "contact",
                  "description": "The name of the collection, like contact."
                }
              ]
            },
            "description": "Called from a form on a website by form.js (<script src=\"https://api.hypamoon.com/form.js\" defer></script>); 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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
            "auth": {
              "type": "noauth"
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": {\n    \"name\": \"Anna de Vries\",\n    \"email\": \"anna@example.com\",\n    \"message\": \"Can you come and look at a leaking tap on Friday?\"\n  },\n  \"turnstile_token\": \"0.zXq3...from-the-challenge-frame\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Stored.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/public/collections/:name/documents?token={{publicToken}}",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "public",
                    "collections",
                    ":name",
                    "documents"
                  ],
                  "query": [
                    {
                      "key": "token",
                      "value": "{{publicToken}}",
                      "description": "The public chat token (hm_pub_... or hm_pubtest_...)."
                    }
                  ],
                  "variable": [
                    {
                      "key": "name",
                      "value": "contact",
                      "description": "The name of the collection, like contact."
                    }
                  ]
                },
                "description": "Called from a form on a website by form.js (<script src=\"https://api.hypamoon.com/form.js\" defer></script>); 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.\n\nPublic widget route: no API key, the public chat token goes in the query string (variable publicToken).",
                "auth": {
                  "type": "noauth"
                },
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"data\": {\n    \"name\": \"Anna de Vries\",\n    \"email\": \"anna@example.com\",\n    \"message\": \"Can you come and look at a leaking tap on Friday?\"\n  },\n  \"turnstile_token\": \"0.zXq3...from-the-challenge-frame\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Stored.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918\",\n  \"created_at\": \"2026-10-01T09:30:00.000Z\",\n  \"test\": false\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Test mode",
      "description": "Build and test without a real number or real costs: a test key (hm_test_) works on a test project with test numbers and simulated calls.",
      "item": [
        {
          "name": "Simulate a call",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/test/calls",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "test",
                "calls"
              ]
            },
            "description": "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.\n\nScope: calls:read",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"number_id\": \"0d1e2f3a-4b5c-4d6e-8f70-8192a3b4c5d6\",\n  \"scenario\": \"question\",\n  \"caller_goal\": \"Ask whether you are open on Sunday.\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The simulated call, as GET /calls/{id} returns it.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/test/calls",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "test",
                    "calls"
                  ]
                },
                "description": "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.\n\nScope: calls:read",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"number_id\": \"0d1e2f3a-4b5c-4d6e-8f70-8192a3b4c5d6\",\n  \"scenario\": \"question\",\n  \"caller_goal\": \"Ask whether you are open on Sunday.\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The simulated call, as GET /calls/{id} returns it.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d\",\n  \"direction\": \"inbound\",\n  \"from\": \"+447700900456\",\n  \"to\": \"+447700900042\",\n  \"status\": \"ai_handled\",\n  \"created_at\": \"2026-10-01T09:20:00.000Z\",\n  \"started_at\": \"2026-10-01T09:18:52.000Z\",\n  \"answered_at\": \"2026-10-01T09:18:56.000Z\",\n  \"ended_at\": \"2026-10-01T09:20:00.000Z\",\n  \"duration_sec\": 64,\n  \"hangup_cause\": \"normal_clearing\",\n  \"has_summary\": true,\n  \"summary_ready_at\": \"2026-10-01T09:20:00.000Z\",\n  \"test\": true,\n  \"outbound\": null,\n  \"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.\",\n  \"summary\": {\n    \"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.\",\n    \"topics\": [\n      \"leak\",\n      \"callback request\"\n    ],\n    \"sentiment\": \"neutral\",\n    \"action_items\": [\n      \"Call Tom Baker back within the hour about the leak at 40 Main Street\"\n    ],\n    \"next_step\": null\n  }\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "WordPress",
      "description": "Data behind the WordPress login, without passwords: the Hypamoon Connect plugin sends an inventory, the user chooses what moves (in the chat, the portal or the plugin), and the plugin sends only that into private collections of the project.",
      "item": [
        {
          "name": "Create a code to connect a WordPress site",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connect-codes",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connect-codes"
              ]
            },
            "description": "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.\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "List connected WordPress sites",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connections",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connections"
              ]
            },
            "description": "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.\n\nScope: data:read"
          },
          "response": []
        },
        {
          "name": "Get the inventory of a connected WordPress site",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connections/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connections",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the WordPress connection."
                }
              ]
            },
            "description": "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.\n\nScope: data:read"
          },
          "response": []
        },
        {
          "name": "Revoke a WordPress connection",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connections/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connections",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the WordPress connection."
                }
              ]
            },
            "description": "The plugin's token stops working right away. The collections and their documents stay.\n\nScope: data:write"
          },
          "response": []
        },
        {
          "name": "Choose what moves from a WordPress site",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connections/:id/selection",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connections",
                ":id",
                "selection"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the WordPress connection."
                }
              ]
            },
            "description": "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.\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"items\": {\n    \"orders\": {\n      \"enabled\": true,\n      \"since\": \"2024-10-12\",\n      \"omit_fields\": [\n        \"phone\"\n      ]\n    }\n  },\n  \"version\": 2\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Read the compact inventory and the open suggestion",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connections/:id/suggestion",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connections",
                ":id",
                "suggestion"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the WordPress connection."
                }
              ]
            },
            "description": "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.\n\nScope: data:read"
          },
          "response": []
        },
        {
          "name": "Suggest a selection for a WordPress site",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connections/:id/suggestion",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connections",
                ":id",
                "suggestion"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the WordPress connection."
                }
              ]
            },
            "description": "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.\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"goal\": \"Move my hours portal\",\n  \"items\": {\n    \"table:uren\": {\n      \"enabled\": true,\n      \"reason\": \"The hour history, linked to users via opdrachtgever_id.\"\n    },\n    \"content:page\": {\n      \"enabled\": false,\n      \"reason\": \"The pages stay on the WordPress site.\"\n    }\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Read the chat and forms on a WordPress site",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connections/:id/embeds",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connections",
                ":id",
                "embeds"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the WordPress connection."
                }
              ]
            },
            "description": "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.\n\nScope: data:read"
          },
          "response": []
        },
        {
          "name": "Put the chat and forms on a WordPress site",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connections/:id/embeds",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connections",
                ":id",
                "embeds"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the WordPress connection."
                }
              ]
            },
            "description": "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).\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"chat\": {\n    \"enabled\": true,\n    \"color\": \"#0f766e\"\n  },\n  \"forms\": [\n    {\n      \"collection\": \"contact\",\n      \"title\": \"Contact us\"\n    }\n  ],\n  \"version\": 0\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Connect the plugin with a code (plugin)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connect",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connect"
              ]
            },
            "description": "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.",
            "auth": {
              "type": "noauth"
            },
            "body": {
              "mode": "raw",
              "raw": "{\n  \"code\": \"ABCD-EF23\",\n  \"site_url\": \"https://www.example.com\",\n  \"site_name\": \"Example\",\n  \"wordpress_version\": \"6.6.2\",\n  \"plugin_version\": \"1.0.0\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Read the connection (plugin)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connection",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connection"
              ]
            },
            "description": "Called by the plugin: the project, the site, when the token expires, the selection and the last export round."
          },
          "response": []
        },
        {
          "name": "Disconnect (plugin)",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connection",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connection"
              ]
            },
            "description": "Called by the plugin when the admin disconnects: the token stops working. The collections stay."
          },
          "response": []
        },
        {
          "name": "Send the inventory (plugin)",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connection/inventory",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connection",
                "inventory"
              ]
            },
            "description": "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).",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"items\": [\n    {\n      \"key\": \"content:post\",\n      \"type\": \"content\",\n      \"label\": \"Posts\",\n      \"count\": 120,\n      \"oldest\": \"2019-03-01T10:00:00Z\",\n      \"newest\": \"2026-10-01T09:00:00.000Z\",\n      \"statuses\": {\n        \"publish\": 100,\n        \"draft\": 20\n      },\n      \"post_type\": \"post\"\n    },\n    {\n      \"key\": \"orders\",\n      \"type\": \"orders\",\n      \"label\": \"Orders\",\n      \"count\": 830,\n      \"oldest\": \"2021-01-04T09:00:00Z\",\n      \"newest\": \"2026-10-01T09:00:00.000Z\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Pull the selection (plugin)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connection/selection",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connection",
                "selection"
              ]
            },
            "description": "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."
          },
          "response": []
        },
        {
          "name": "Change the selection (plugin)",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connection/selection",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connection",
                "selection"
              ]
            },
            "description": "Called by the plugin when the admin changes the checkboxes in WordPress. Send the version it showed; a newer one gives 409.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"items\": {\n    \"users\": {\n      \"enabled\": true,\n      \"omit_fields\": null\n    }\n  },\n  \"version\": 2\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Close the suggestion of the chat (plugin)",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connection/suggestion",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connection",
                "suggestion"
              ],
              "query": [
                {
                  "key": "outcome",
                  "value": "applied",
                  "description": "applied or dismissed.",
                  "disabled": true
                }
              ]
            },
            "description": "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."
          },
          "response": []
        },
        {
          "name": "Pull the chat and forms for the site (plugin)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connection/embeds",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connection",
                "embeds"
              ]
            },
            "description": "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."
          },
          "response": []
        },
        {
          "name": "Turn the chat on or off (plugin)",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/wordpress/connection/embeds",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "connection",
                "embeds"
              ]
            },
            "description": "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.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"chat\": {\n    \"enabled\": true\n  },\n  \"version\": 1\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Start an export round (plugin)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/wordpress/exports",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "exports"
              ]
            },
            "description": "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.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"selection_version\": 2\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Send a batch (plugin)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/wordpress/exports/:id/batches",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "exports",
                ":id",
                "batches"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the export round."
                }
              ]
            },
            "description": "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.",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"batch\": \"content:post:after:0\",\n  \"key\": \"content:post\",\n  \"type\": \"content\",\n  \"records\": [\n    {\n      \"wp_id\": 12,\n      \"post_type\": \"post\",\n      \"status\": \"draft\",\n      \"title\": \"Spring menu\",\n      \"content_html\": \"<p>...</p>\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": []
        },
        {
          "name": "Finish an export round (plugin)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/wordpress/exports/:id/finish",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "wordpress",
                "exports",
                ":id",
                "finish"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the export round."
                }
              ]
            },
            "description": "Called by the plugin at the end of a round: the collections with their document counts and next_step for the admin."
          },
          "response": []
        }
      ]
    },
    {
      "name": "MCP",
      "description": "The Model Context Protocol server, for agents and AI tools.",
      "item": [
        {
          "name": "Model Context Protocol endpoint",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/mcp",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "mcp"
              ]
            },
            "description": "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.\n\nScope: project:read",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1,\n  \"method\": \"tools/list\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The JSON-RPC response.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/mcp",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "mcp"
                  ]
                },
                "description": "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.\n\nScope: project:read",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1,\n  \"method\": \"tools/list\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The JSON-RPC response.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 1,\n  \"result\": {\n    \"tools\": []\n  }\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Meta",
      "description": "About the API itself.",
      "item": [
        {
          "name": "This OpenAPI document",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/openapi.json",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "openapi.json"
              ]
            },
            "description": "The OpenAPI 3.1 document of the API. Public: no key needed.",
            "auth": {
              "type": "noauth"
            }
          },
          "response": []
        },
        {
          "name": "Check whether Hypamoon is working",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/status",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "status"
              ]
            },
            "description": "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.",
            "auth": {
              "type": "noauth"
            }
          },
          "response": [
            {
              "name": "200 The status.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/status",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "status"
                  ]
                },
                "description": "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.",
                "auth": {
                  "type": "noauth"
                }
              },
              "status": "The status.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"status\": \"operational\",\n  \"source\": \"status_page\",\n  \"components\": [\n    {\n      \"id\": \"sites\",\n      \"name\": \"Sites and apps\",\n      \"status\": \"operational\"\n    },\n    {\n      \"id\": \"api\",\n      \"name\": \"API and chat connection\",\n      \"status\": \"operational\"\n    },\n    {\n      \"id\": \"portal\",\n      \"name\": \"Portal\",\n      \"status\": \"operational\"\n    },\n    {\n      \"id\": \"phone\",\n      \"name\": \"Phone\",\n      \"status\": \"operational\"\n    },\n    {\n      \"id\": \"ai\",\n      \"name\": \"AI receptionist\",\n      \"status\": \"operational\"\n    },\n    {\n      \"id\": \"mail\",\n      \"name\": \"Email\",\n      \"status\": \"operational\"\n    },\n    {\n      \"id\": \"payments\",\n      \"name\": \"Payments\",\n      \"status\": \"operational\"\n    }\n  ],\n  \"incidents\": [],\n  \"status_page\": \"https://status.hypamoon.com\",\n  \"checked_at\": \"2026-10-01T09:00:00.000Z\",\n  \"user_message\": \"Hypamoon is working normally. The problem is likely in your chat app.\",\n  \"next_step\": \"If a step failed, read its error and user_message.\"\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Agents",
      "description": "Agents that keep working for your project: a trigger, instructions in plain language, a small set of actions on Hypamoon, and limits. Actions that involve money or have an outside effect wait for the owner's approval by default.",
      "item": [
        {
          "name": "List the agents",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/agents",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "agents"
              ]
            },
            "description": "The agents of the project, newest first, each with pending_approvals: the actions waiting for the owner.\n\nScope: project:read"
          },
          "response": [
            {
              "name": "200 The agents.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/agents",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "agents"
                  ]
                },
                "description": "The agents of the project, newest first, each with pending_approvals: the actions waiting for the owner.\n\nScope: project:read"
              },
              "status": "The agents.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7\",\n      \"name\": \"Contact request follow-up\",\n      \"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.\",\n      \"trigger\": {\n        \"type\": \"document.created\",\n        \"collection\": \"contact_requests\",\n        \"collection_id\": \"0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5\",\n        \"phone_field\": \"phone\"\n      },\n      \"actions\": {\n        \"email_owner\": {\n          \"mode\": \"auto\"\n        },\n        \"start_call\": {\n          \"mode\": \"approve\"\n        }\n      },\n      \"limits\": {\n        \"runs_per_day\": 20,\n        \"actions_per_run\": 3\n      },\n      \"status\": \"active\",\n      \"mode\": \"test\",\n      \"next_run_at\": null,\n      \"pending_approvals\": 1,\n      \"created_by\": \"key:c16a7b81-92a3-44b5-8fc6-f708192a3b4d\",\n      \"created_at\": \"2026-10-05T09:00:00.000Z\",\n      \"updated_at\": \"2026-10-05T09:00:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Create an agent",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/agents",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "agents"
              ]
            },
            "description": "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.\n\nScope: project:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Contact request follow-up\",\n  \"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.\",\n  \"trigger\": {\n    \"type\": \"document.created\",\n    \"collection\": \"contact_requests\",\n    \"phone_field\": \"phone\"\n  },\n  \"actions\": {\n    \"email_owner\": {\n      \"mode\": \"auto\"\n    },\n    \"start_call\": {\n      \"mode\": \"approve\"\n    }\n  },\n  \"limits\": {\n    \"runs_per_day\": 20,\n    \"actions_per_run\": 3\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The new agent.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/agents",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "agents"
                  ]
                },
                "description": "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.\n\nScope: project:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Contact request follow-up\",\n  \"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.\",\n  \"trigger\": {\n    \"type\": \"document.created\",\n    \"collection\": \"contact_requests\",\n    \"phone_field\": \"phone\"\n  },\n  \"actions\": {\n    \"email_owner\": {\n      \"mode\": \"auto\"\n    },\n    \"start_call\": {\n      \"mode\": \"approve\"\n    }\n  },\n  \"limits\": {\n    \"runs_per_day\": 20,\n    \"actions_per_run\": 3\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The new agent.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7\",\n  \"name\": \"Contact request follow-up\",\n  \"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.\",\n  \"trigger\": {\n    \"type\": \"document.created\",\n    \"collection\": \"contact_requests\",\n    \"collection_id\": \"0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5\",\n    \"phone_field\": \"phone\"\n  },\n  \"actions\": {\n    \"email_owner\": {\n      \"mode\": \"auto\"\n    },\n    \"start_call\": {\n      \"mode\": \"approve\"\n    }\n  },\n  \"limits\": {\n    \"runs_per_day\": 20,\n    \"actions_per_run\": 3\n  },\n  \"status\": \"active\",\n  \"mode\": \"test\",\n  \"next_run_at\": null,\n  \"pending_approvals\": 0,\n  \"created_by\": \"key:c16a7b81-92a3-44b5-8fc6-f708192a3b4d\",\n  \"created_at\": \"2026-10-05T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-05T09:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Get an agent",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/agents/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "agents",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the agent."
                }
              ]
            },
            "description": "One agent with its five newest runs, each with its actions (including those waiting for approval).\n\nScope: project:read"
          },
          "response": [
            {
              "name": "200 The agent.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/agents/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "agents",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the agent."
                    }
                  ]
                },
                "description": "One agent with its five newest runs, each with its actions (including those waiting for approval).\n\nScope: project:read"
              },
              "status": "The agent.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7\",\n  \"name\": \"Contact request follow-up\",\n  \"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.\",\n  \"trigger\": {\n    \"type\": \"document.created\",\n    \"collection\": \"contact_requests\",\n    \"collection_id\": \"0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5\",\n    \"phone_field\": \"phone\"\n  },\n  \"actions\": {\n    \"email_owner\": {\n      \"mode\": \"auto\"\n    },\n    \"start_call\": {\n      \"mode\": \"approve\"\n    }\n  },\n  \"limits\": {\n    \"runs_per_day\": 20,\n    \"actions_per_run\": 3\n  },\n  \"status\": \"active\",\n  \"mode\": \"test\",\n  \"next_run_at\": null,\n  \"pending_approvals\": 1,\n  \"created_by\": \"key:c16a7b81-92a3-44b5-8fc6-f708192a3b4d\",\n  \"created_at\": \"2026-10-05T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-05T09:00:00.000Z\",\n  \"recent_runs\": [\n    {\n      \"id\": \"7a2e3c4d-5b6f-4071-8b82-a3c4d5e6f708\",\n      \"agent_id\": \"6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7\",\n      \"status\": \"done\",\n      \"trigger\": {\n        \"type\": \"document.created\",\n        \"id\": \"9c40e5f6-7d81-4293-ad04-c5e6f708192a\",\n        \"scheduled_for\": null\n      },\n      \"thought\": \"A new contact request from Sam about a kitchen quote: I email the owner a summary and propose a call back.\",\n      \"outcome\": \"email_owner simulated; start_call waiting for approval. The owner has the summary; the call waits for approval.\",\n      \"error\": null,\n      \"model_turns\": 2,\n      \"input_tokens\": 4210,\n      \"output_tokens\": 312,\n      \"created_at\": \"2026-10-05T09:01:00.000Z\",\n      \"started_at\": \"2026-10-05T09:01:02.000Z\",\n      \"finished_at\": \"2026-10-05T09:01:06.000Z\",\n      \"actions\": [\n        {\n          \"id\": \"a94f5e6f-7081-4293-8da4-d5e6f708192b\",\n          \"run_id\": \"7a2e3c4d-5b6f-4071-8b82-a3c4d5e6f708\",\n          \"agent_id\": \"6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7\",\n          \"type\": \"email_owner\",\n          \"input\": {\n            \"subject\": \"New contact request: Sam, kitchen quote\",\n            \"body\": \"Sam asks for a quote for a new kitchen. Phone +447700900123.\"\n          },\n          \"status\": \"simulated\",\n          \"reason\": null,\n          \"result\": {\n            \"to\": \"owner\",\n            \"subject\": \"New contact request: Sam, kitchen quote\"\n          },\n          \"decided_by\": null,\n          \"decided_at\": null,\n          \"expires_at\": null,\n          \"created_at\": \"2026-10-05T09:01:04.000Z\",\n          \"executed_at\": \"2026-10-05T09:01:04.000Z\"\n        },\n        {\n          \"id\": \"8b3f4d5e-6c70-4182-9c93-b4d5e6f70819\",\n          \"run_id\": \"7a2e3c4d-5b6f-4071-8b82-a3c4d5e6f708\",\n          \"agent_id\": \"6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7\",\n          \"type\": \"start_call\",\n          \"input\": {\n            \"to\": \"+447700900123\",\n            \"assistant_instructions\": \"Call Sam back: we received the contact request about a quote for a new kitchen and ask when we can help.\"\n          },\n          \"status\": \"pending_approval\",\n          \"reason\": null,\n          \"result\": null,\n          \"decided_by\": null,\n          \"decided_at\": null,\n          \"expires_at\": \"2026-10-08T09:01:05.000Z\",\n          \"created_at\": \"2026-10-05T09:01:05.000Z\",\n          \"executed_at\": null\n        }\n      ]\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Change an agent",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/agents/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "agents",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the agent."
                }
              ]
            },
            "description": "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.\n\nScope: project:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"actions\": {\n    \"email_owner\": {\n      \"mode\": \"auto\"\n    },\n    \"start_call\": {\n      \"mode\": \"approve\"\n    },\n    \"send_sms\": {\n      \"mode\": \"approve\"\n    }\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The agent as it is now.",
              "originalRequest": {
                "method": "PATCH",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/agents/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "agents",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the agent."
                    }
                  ]
                },
                "description": "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.\n\nScope: project:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"actions\": {\n    \"email_owner\": {\n      \"mode\": \"auto\"\n    },\n    \"start_call\": {\n      \"mode\": \"approve\"\n    },\n    \"send_sms\": {\n      \"mode\": \"approve\"\n    }\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The agent as it is now.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7\",\n  \"name\": \"Contact request follow-up\",\n  \"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.\",\n  \"trigger\": {\n    \"type\": \"document.created\",\n    \"collection\": \"contact_requests\",\n    \"collection_id\": \"0c1d2e3f-4a5b-4c6d-8e7f-8091a2b3c4d5\",\n    \"phone_field\": \"phone\"\n  },\n  \"actions\": {\n    \"email_owner\": {\n      \"mode\": \"auto\"\n    },\n    \"start_call\": {\n      \"mode\": \"approve\"\n    }\n  },\n  \"limits\": {\n    \"runs_per_day\": 20,\n    \"actions_per_run\": 3\n  },\n  \"status\": \"active\",\n  \"mode\": \"test\",\n  \"next_run_at\": null,\n  \"pending_approvals\": 1,\n  \"created_by\": \"key:c16a7b81-92a3-44b5-8fc6-f708192a3b4d\",\n  \"created_at\": \"2026-10-05T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-05T09:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Delete an agent",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/agents/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "agents",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the agent."
                }
              ]
            },
            "description": "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.\n\nScope: project:write"
          },
          "response": []
        },
        {
          "name": "List the runs of an agent",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/agents/:id/runs",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "agents",
                ":id",
                "runs"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "20",
                  "description": "Runs per page, 1 to 50.",
                  "disabled": true
                },
                {
                  "key": "before",
                  "value": "",
                  "description": "Only runs created before this moment.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the agent."
                }
              ]
            },
            "description": "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).\n\nScope: project:read"
          },
          "response": [
            {
              "name": "200 The runs.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/agents/:id/runs",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "agents",
                    ":id",
                    "runs"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "20",
                      "description": "Runs per page, 1 to 50.",
                      "disabled": true
                    },
                    {
                      "key": "before",
                      "value": "",
                      "description": "Only runs created before this moment.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the agent."
                    }
                  ]
                },
                "description": "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).\n\nScope: project:read"
              },
              "status": "The runs.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"7a2e3c4d-5b6f-4071-8b82-a3c4d5e6f708\",\n      \"agent_id\": \"6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7\",\n      \"status\": \"done\",\n      \"trigger\": {\n        \"type\": \"document.created\",\n        \"id\": \"9c40e5f6-7d81-4293-ad04-c5e6f708192a\",\n        \"scheduled_for\": null\n      },\n      \"thought\": \"A new contact request from Sam about a kitchen quote: I email the owner a summary and propose a call back.\",\n      \"outcome\": \"email_owner simulated; start_call waiting for approval. The owner has the summary; the call waits for approval.\",\n      \"error\": null,\n      \"model_turns\": 2,\n      \"input_tokens\": 4210,\n      \"output_tokens\": 312,\n      \"created_at\": \"2026-10-05T09:01:00.000Z\",\n      \"started_at\": \"2026-10-05T09:01:02.000Z\",\n      \"finished_at\": \"2026-10-05T09:01:06.000Z\",\n      \"actions\": [\n        {\n          \"id\": \"a94f5e6f-7081-4293-8da4-d5e6f708192b\",\n          \"run_id\": \"7a2e3c4d-5b6f-4071-8b82-a3c4d5e6f708\",\n          \"agent_id\": \"6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7\",\n          \"type\": \"email_owner\",\n          \"input\": {\n            \"subject\": \"New contact request: Sam, kitchen quote\",\n            \"body\": \"Sam asks for a quote for a new kitchen. Phone +447700900123.\"\n          },\n          \"status\": \"simulated\",\n          \"reason\": null,\n          \"result\": {\n            \"to\": \"owner\",\n            \"subject\": \"New contact request: Sam, kitchen quote\"\n          },\n          \"decided_by\": null,\n          \"decided_at\": null,\n          \"expires_at\": null,\n          \"created_at\": \"2026-10-05T09:01:04.000Z\",\n          \"executed_at\": \"2026-10-05T09:01:04.000Z\"\n        },\n        {\n          \"id\": \"8b3f4d5e-6c70-4182-9c93-b4d5e6f70819\",\n          \"run_id\": \"7a2e3c4d-5b6f-4071-8b82-a3c4d5e6f708\",\n          \"agent_id\": \"6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7\",\n          \"type\": \"start_call\",\n          \"input\": {\n            \"to\": \"+447700900123\",\n            \"assistant_instructions\": \"Call Sam back: we received the contact request about a quote for a new kitchen and ask when we can help.\"\n          },\n          \"status\": \"pending_approval\",\n          \"reason\": null,\n          \"result\": null,\n          \"decided_by\": null,\n          \"decided_at\": null,\n          \"expires_at\": \"2026-10-08T09:01:05.000Z\",\n          \"created_at\": \"2026-10-05T09:01:05.000Z\",\n          \"executed_at\": null\n        }\n      ]\n    }\n  ],\n  \"has_more\": false\n}"
            }
          ]
        },
        {
          "name": "Approve or reject an agent action",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/agent-actions/:id/decide",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "agent-actions",
                ":id",
                "decide"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the agent action."
                }
              ]
            },
            "description": "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.\n\nScope: project:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"decision\": \"approve\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The action after the decision.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/agent-actions/:id/decide",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "agent-actions",
                    ":id",
                    "decide"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the agent action."
                    }
                  ]
                },
                "description": "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.\n\nScope: project:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"decision\": \"approve\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The action after the decision.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"8b3f4d5e-6c70-4182-9c93-b4d5e6f70819\",\n  \"run_id\": \"7a2e3c4d-5b6f-4071-8b82-a3c4d5e6f708\",\n  \"agent_id\": \"6f1d2b3c-4a5e-4f60-8a71-92b3c4d5e6f7\",\n  \"type\": \"start_call\",\n  \"input\": {\n    \"to\": \"+447700900123\",\n    \"assistant_instructions\": \"Call Sam back: we received the contact request about a quote for a new kitchen and ask when we can help.\"\n  },\n  \"status\": \"simulated\",\n  \"reason\": null,\n  \"result\": {\n    \"call_id\": \"b05f6a70-8192-43a4-9eb5-e6f708192a3c\",\n    \"status\": \"completed\",\n    \"to\": \"+447700900123\"\n  },\n  \"decided_by\": \"user:d27b8c92-a3b4-45c6-90d7-08192a3b4c5e\",\n  \"decided_at\": \"2026-10-05T09:10:00.000Z\",\n  \"expires_at\": null,\n  \"created_at\": \"2026-10-05T09:01:05.000Z\",\n  \"executed_at\": \"2026-10-05T09:10:01.000Z\"\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Apps",
      "description": "Server code on your site: /api on the same address runs your code, isolated, with allowed hosts, secrets from the vault, the project's data and schedules.",
      "item": [
        {
          "name": "List the apps",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps"
              ]
            },
            "description": "The project's apps, newest first.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The apps.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps"
                  ]
                },
                "description": "The project's apps, newest first.\n\nScope: sites:read"
              },
              "status": "The apps.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6\",\n      \"name\": \"Contact form\",\n      \"site_id\": \"5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d\",\n      \"url\": \"https://contact-demo.hypamoon.app\",\n      \"api_url\": \"https://contact-demo.hypamoon.app/api\",\n      \"mode\": \"test\",\n      \"status\": \"active\",\n      \"version\": 2,\n      \"allowed_hosts\": [\n        \"api.weatherapi.com\"\n      ],\n      \"secrets\": [\n        \"weather_key\"\n      ],\n      \"schedules\": [\n        {\n          \"cron\": \"0 7 * * *\"\n        }\n      ],\n      \"limits\": {\n        \"cpu_ms\": 10,\n        \"subrequests\": 10,\n        \"outbound_per_minute\": 60,\n        \"outbound_per_day\": 2000,\n        \"bundle_bytes\": 1048576\n      },\n      \"created_at\": \"2026-10-06T09:00:00.000Z\",\n      \"updated_at\": \"2026-10-06T09:30:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Deploy an app",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/apps",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps"
              ]
            },
            "description": "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: <name>; 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).\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"site_id\": \"5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d\",\n  \"name\": \"Contact form\",\n  \"main_module\": \"index.js\",\n  \"modules\": [\n    {\n      \"name\": \"index.js\",\n      \"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\"\n    }\n  ],\n  \"allowed_hosts\": [\n    \"api.weatherapi.com\"\n  ],\n  \"secrets\": [\n    \"weather_key\"\n  ],\n  \"schedules\": [\n    {\n      \"cron\": \"0 7 * * *\"\n    }\n  ],\n  \"vars\": {\n    \"DEFAULT_CITY\": \"Delft\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 A new version is live.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/apps",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps"
                  ]
                },
                "description": "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: <name>; 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).\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"site_id\": \"5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d\",\n  \"name\": \"Contact form\",\n  \"main_module\": \"index.js\",\n  \"modules\": [\n    {\n      \"name\": \"index.js\",\n      \"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\"\n    }\n  ],\n  \"allowed_hosts\": [\n    \"api.weatherapi.com\"\n  ],\n  \"secrets\": [\n    \"weather_key\"\n  ],\n  \"schedules\": [\n    {\n      \"cron\": \"0 7 * * *\"\n    }\n  ],\n  \"vars\": {\n    \"DEFAULT_CITY\": \"Delft\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "A new version is live.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6\",\n  \"name\": \"Contact form\",\n  \"site_id\": \"5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d\",\n  \"url\": \"https://contact-demo.hypamoon.app\",\n  \"api_url\": \"https://contact-demo.hypamoon.app/api\",\n  \"mode\": \"test\",\n  \"status\": \"active\",\n  \"version\": 2,\n  \"allowed_hosts\": [\n    \"api.weatherapi.com\"\n  ],\n  \"secrets\": [\n    \"weather_key\"\n  ],\n  \"schedules\": [\n    {\n      \"cron\": \"0 7 * * *\"\n    }\n  ],\n  \"limits\": {\n    \"cpu_ms\": 10,\n    \"subrequests\": 10,\n    \"outbound_per_minute\": 60,\n    \"outbound_per_day\": 2000,\n    \"bundle_bytes\": 1048576\n  },\n  \"created_at\": \"2026-10-06T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-06T09:30:00.000Z\"\n}"
            },
            {
              "name": "201 The app is live.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/apps",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps"
                  ]
                },
                "description": "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: <name>; 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).\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"site_id\": \"5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d\",\n  \"name\": \"Contact form\",\n  \"main_module\": \"index.js\",\n  \"modules\": [\n    {\n      \"name\": \"index.js\",\n      \"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\"\n    }\n  ],\n  \"allowed_hosts\": [\n    \"api.weatherapi.com\"\n  ],\n  \"secrets\": [\n    \"weather_key\"\n  ],\n  \"schedules\": [\n    {\n      \"cron\": \"0 7 * * *\"\n    }\n  ],\n  \"vars\": {\n    \"DEFAULT_CITY\": \"Delft\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The app is live.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6\",\n  \"name\": \"Contact form\",\n  \"site_id\": \"5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d\",\n  \"url\": \"https://contact-demo.hypamoon.app\",\n  \"api_url\": \"https://contact-demo.hypamoon.app/api\",\n  \"mode\": \"test\",\n  \"status\": \"active\",\n  \"version\": 1,\n  \"allowed_hosts\": [\n    \"api.weatherapi.com\"\n  ],\n  \"secrets\": [\n    \"weather_key\"\n  ],\n  \"schedules\": [\n    {\n      \"cron\": \"0 7 * * *\"\n    }\n  ],\n  \"limits\": {\n    \"cpu_ms\": 10,\n    \"subrequests\": 10,\n    \"outbound_per_minute\": 60,\n    \"outbound_per_day\": 2000,\n    \"bundle_bytes\": 1048576\n  },\n  \"created_at\": \"2026-10-06T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-06T09:30:00.000Z\"\n}"
            },
            {
              "name": "202 Four-eyes approval is on: the deploy waits for another owner or builder. The running code has not changed yet.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/apps",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps"
                  ]
                },
                "description": "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: <name>; 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).\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"site_id\": \"5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d\",\n  \"name\": \"Contact form\",\n  \"main_module\": \"index.js\",\n  \"modules\": [\n    {\n      \"name\": \"index.js\",\n      \"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\"\n    }\n  ],\n  \"allowed_hosts\": [\n    \"api.weatherapi.com\"\n  ],\n  \"secrets\": [\n    \"weather_key\"\n  ],\n  \"schedules\": [\n    {\n      \"cron\": \"0 7 * * *\"\n    }\n  ],\n  \"vars\": {\n    \"DEFAULT_CITY\": \"Delft\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Four-eyes approval is on: the deploy waits for another owner or builder. The running code has not changed yet.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"deploy_approval\",\n  \"id\": \"8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90\",\n  \"status\": \"pending\",\n  \"kind\": \"app_deploy\",\n  \"site_id\": \"3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c\",\n  \"name\": \"Acme Plumbing\",\n  \"slug\": \"acme-plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": 1,\n  \"bytes\": 1834,\n  \"password_change\": \"none\",\n  \"requested_by\": {\n    \"user_id\": \"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\",\n    \"via\": \"api\",\n    \"app\": \"Claude\"\n  },\n  \"requested_at\": \"2026-10-05T10:15:00Z\",\n  \"expires_at\": \"2026-10-12T10:15:00Z\",\n  \"reviewed_by\": null,\n  \"reviewed_at\": null,\n  \"reason\": null,\n  \"result\": null\n}"
            }
          ]
        },
        {
          "name": "Get an app",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The app.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The app.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6\",\n  \"name\": \"Contact form\",\n  \"site_id\": \"5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d\",\n  \"url\": \"https://contact-demo.hypamoon.app\",\n  \"api_url\": \"https://contact-demo.hypamoon.app/api\",\n  \"mode\": \"test\",\n  \"status\": \"active\",\n  \"version\": 2,\n  \"allowed_hosts\": [\n    \"api.weatherapi.com\"\n  ],\n  \"secrets\": [\n    \"weather_key\"\n  ],\n  \"schedules\": [\n    {\n      \"cron\": \"0 7 * * *\"\n    }\n  ],\n  \"limits\": {\n    \"cpu_ms\": 10,\n    \"subrequests\": 10,\n    \"outbound_per_minute\": 60,\n    \"outbound_per_day\": 2000,\n    \"bundle_bytes\": 1048576\n  },\n  \"created_at\": \"2026-10-06T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-06T09:30:00.000Z\",\n  \"main_module\": \"index.js\",\n  \"vars\": {\n    \"CITY\": \"Delft\"\n  },\n  \"fetch_pages\": false,\n  \"versions\": [\n    {\n      \"version\": 2,\n      \"bytes\": 1834,\n      \"created_at\": \"2026-10-06T09:30:00.000Z\"\n    },\n    {\n      \"version\": 1,\n      \"bytes\": 1620,\n      \"created_at\": \"2026-10-06T09:00:00.000Z\"\n    }\n  ],\n  \"recent_errors\": [\n    {\n      \"at\": \"2026-10-06T09:41:12.000Z\",\n      \"status\": 500,\n      \"path\": \"/api/contact\",\n      \"message\": \"TypeError: Cannot read properties of undefined (reading 'temp_c')\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Delete an app",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write"
          },
          "response": []
        },
        {
          "name": "Roll an app back",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/rollback",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "rollback"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "Makes a stored version live again, with the settings it had (allowed hosts, secrets, schedules, vars). Without version: the version before the current one.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"version\": 1\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The app as it runs now.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/rollback",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "rollback"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "Makes a stored version live again, with the settings it had (allowed hosts, secrets, schedules, vars). Without version: the version before the current one.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"version\": 1\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The app as it runs now.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6\",\n  \"name\": \"Contact form\",\n  \"site_id\": \"5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d\",\n  \"url\": \"https://contact-demo.hypamoon.app\",\n  \"api_url\": \"https://contact-demo.hypamoon.app/api\",\n  \"mode\": \"test\",\n  \"status\": \"active\",\n  \"version\": 1,\n  \"allowed_hosts\": [\n    \"api.weatherapi.com\"\n  ],\n  \"secrets\": [\n    \"weather_key\"\n  ],\n  \"schedules\": [\n    {\n      \"cron\": \"0 7 * * *\"\n    }\n  ],\n  \"limits\": {\n    \"cpu_ms\": 10,\n    \"subrequests\": 10,\n    \"outbound_per_minute\": 60,\n    \"outbound_per_day\": 2000,\n    \"bundle_bytes\": 1048576\n  },\n  \"created_at\": \"2026-10-06T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-06T09:30:00.000Z\"\n}"
            },
            {
              "name": "202 Four-eyes approval is on: the rollback waits for another owner or builder. Nothing has changed yet.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/rollback",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "rollback"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "Makes a stored version live again, with the settings it had (allowed hosts, secrets, schedules, vars). Without version: the version before the current one.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"version\": 1\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Four-eyes approval is on: the rollback waits for another owner or builder. Nothing has changed yet.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"deploy_approval\",\n  \"id\": \"8f6c2a51-3d1e-4b7a-9c42-0e5d6f7a8b90\",\n  \"status\": \"pending\",\n  \"kind\": \"app_rollback\",\n  \"site_id\": \"3b2f9c1e-7a4d-4e8b-b5c6-1d2e3f4a5b6c\",\n  \"name\": \"Acme Plumbing\",\n  \"slug\": \"acme-plumbing\",\n  \"url\": \"https://acme-plumbing.hypamoon.app\",\n  \"files\": 1,\n  \"bytes\": 1620,\n  \"password_change\": \"none\",\n  \"requested_by\": {\n    \"user_id\": \"5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a\",\n    \"via\": \"api\",\n    \"app\": \"Claude\"\n  },\n  \"requested_at\": \"2026-10-05T10:15:00Z\",\n  \"expires_at\": \"2026-10-12T10:15:00Z\",\n  \"reviewed_by\": null,\n  \"reviewed_at\": null,\n  \"reason\": null,\n  \"result\": null\n}"
            }
          ]
        },
        {
          "name": "Read the log of an app",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/logs",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "logs"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50",
                  "description": "Lines, 1 to 100.",
                  "disabled": true
                },
                {
                  "key": "errors_only",
                  "value": "false",
                  "description": "Only errors.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The log.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/logs",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "logs"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "50",
                      "description": "Lines, 1 to 100.",
                      "disabled": true
                    },
                    {
                      "key": "errors_only",
                      "value": "false",
                      "description": "Only errors.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The log.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"at\": \"2026-10-06T09:41:12.000Z\",\n      \"kind\": \"request\",\n      \"method\": \"POST\",\n      \"path\": \"/api/contact\",\n      \"status\": 200,\n      \"duration_ms\": 182,\n      \"message\": \"\"\n    },\n    {\n      \"at\": \"2026-10-06T09:41:12.000Z\",\n      \"kind\": \"outbound\",\n      \"method\": \"GET\",\n      \"path\": \"api.weatherapi.com\",\n      \"status\": 200,\n      \"duration_ms\": 95,\n      \"message\": \"\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "List the users of an app",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/users",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "users"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The users.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/users",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "users"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The users.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n      \"email\": \"sam@example.com\",\n      \"name\": \"Sam\",\n      \"role\": \"member\",\n      \"created_at\": \"2026-10-06T10:00:00.000Z\",\n      \"last_sign_in_at\": \"2026-10-06T10:05:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Invite someone to an app",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/users",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "users"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "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).\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"sam@example.com\",\n  \"name\": \"Sam\",\n  \"role\": \"member\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Invited.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/users",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "users"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "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).\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"email\": \"sam@example.com\",\n  \"name\": \"Sam\",\n  \"role\": \"member\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Invited.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n  \"email\": \"sam@example.com\",\n  \"name\": \"Sam\",\n  \"role\": \"member\",\n  \"created_at\": \"2026-10-06T10:00:00.000Z\",\n  \"last_sign_in_at\": null\n}"
            }
          ]
        },
        {
          "name": "List the emails an app sent",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/mails",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "mails"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "20",
                  "description": "Emails, 1 to 50.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The emails.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/mails",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "mails"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "20",
                      "description": "Emails, 1 to 50.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The emails.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"sent_at\": \"2026-10-13T12:38:30.723Z\",\n      \"kind\": \"sign_in_link\",\n      \"to\": \"a***@example.com\",\n      \"status\": \"delivered\",\n      \"reason\": null,\n      \"meaning\": \"Accepted by the recipient's mail server. If it is not in the inbox, it is in spam, junk or quarantine.\",\n      \"status_at\": \"2026-10-13T12:40:02.000Z\",\n      \"provider_ref\": \"01a13545\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Import users from a collection",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/users/import",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "users",
                "import"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"collection\": \"wp_users\",\n  \"role_map\": {\n    \"administrator\": \"admin\",\n    \"opdrachtgever\": \"opdrachtgever\",\n    \"kandidaat\": \"kandidaat\"\n  },\n  \"send_email\": false\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 What happened per person.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/users/import",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "users",
                    "import"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"collection\": \"wp_users\",\n  \"role_map\": {\n    \"administrator\": \"admin\",\n    \"opdrachtgever\": \"opdrachtgever\",\n    \"kandidaat\": \"kandidaat\"\n  },\n  \"send_email\": false\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "What happened per person.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"app_id\": \"8d0f4a1e-2b3c-4d5e-8f60-718293a4b5c6\",\n  \"collection\": \"wp_users\",\n  \"dry_run\": false,\n  \"send_email\": false,\n  \"summary\": {\n    \"found\": 2,\n    \"created\": 1,\n    \"invited\": 0,\n    \"existing\": 1,\n    \"skipped\": 0,\n    \"remaining\": 0\n  },\n  \"users\": [\n    {\n      \"wp_id\": 12,\n      \"user_id\": \"3f1e2d3c-4b5a-4c6d-9e8f-0a1b2c3d4e5f\",\n      \"email\": \"s***@example.com\",\n      \"role\": \"opdrachtgever\",\n      \"status\": \"created\"\n    },\n    {\n      \"wp_id\": 1,\n      \"user_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n      \"email\": \"a***@example.com\",\n      \"role\": \"admin\",\n      \"status\": \"existing\"\n    }\n  ],\n  \"next_step\": \"1 added silently, without email, 1 could already sign in, 0 skipped.\"\n}"
            }
          ]
        },
        {
          "name": "Change the role of a user",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/users/:userId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "users",
                ":userId"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                },
                {
                  "key": "userId",
                  "value": "",
                  "description": "The id of the user (sub in x-hypamoon-user)."
                }
              ]
            },
            "description": "Sets one of the app's roles. The app sees the new role in x-hypamoon-user within seconds.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"role\": \"admin\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The user with the new role.",
              "originalRequest": {
                "method": "PATCH",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/users/:userId",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "users",
                    ":userId"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    },
                    {
                      "key": "userId",
                      "value": "",
                      "description": "The id of the user (sub in x-hypamoon-user)."
                    }
                  ]
                },
                "description": "Sets one of the app's roles. The app sees the new role in x-hypamoon-user within seconds.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"role\": \"admin\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The user with the new role.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n  \"email\": \"sam@example.com\",\n  \"name\": \"Sam\",\n  \"role\": \"admin\",\n  \"created_at\": \"2026-10-06T10:00:00.000Z\",\n  \"last_sign_in_at\": \"2026-10-06T10:05:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Remove a user from an app",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/users/:userId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "users",
                ":userId"
              ],
              "query": [
                {
                  "key": "delete_data",
                  "value": "false",
                  "description": "Also delete their rows in per-user tables, their files and their passkeys. Without it, their data stays.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                },
                {
                  "key": "userId",
                  "value": "",
                  "description": "The id of the user (sub in x-hypamoon-user)."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write"
          },
          "response": [
            {
              "name": "200 Removed, with delete_data=true: what was deleted.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/users/:userId",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "users",
                    ":userId"
                  ],
                  "query": [
                    {
                      "key": "delete_data",
                      "value": "false",
                      "description": "Also delete their rows in per-user tables, their files and their passkeys. Without it, their data stays.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    },
                    {
                      "key": "userId",
                      "value": "",
                      "description": "The id of the user (sub in x-hypamoon-user)."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write"
              },
              "status": "Removed, with delete_data=true: what was deleted.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"app_id\": \"9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6\",\n  \"user_id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n  \"deleted\": {\n    \"rows\": {\n      \"items\": 12\n    },\n    \"files\": 12,\n    \"sessions\": 1,\n    \"passkeys\": 1,\n    \"membership\": true,\n    \"account\": true\n  },\n  \"app_tables\": \"included\",\n  \"unmarked_tables\": [],\n  \"note\": null\n}"
            }
          ]
        },
        {
          "name": "Export everything of one user (GDPR)",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/users/:userId/export",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "users",
                ":userId",
                "export"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                },
                {
                  "key": "userId",
                  "value": "",
                  "description": "The id of the user (sub in x-hypamoon-user)."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write"
          },
          "response": [
            {
              "name": "200 The export.",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/users/:userId/export",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "users",
                    ":userId",
                    "export"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    },
                    {
                      "key": "userId",
                      "value": "",
                      "description": "The id of the user (sub in x-hypamoon-user)."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write"
              },
              "status": "The export.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"app_id\": \"9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6\",\n  \"user_id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n  \"exported_at\": \"2026-10-07T12:00:00.000Z\",\n  \"account\": {\n    \"id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n    \"email\": \"sam@example.com\",\n    \"name\": \"Sam\",\n    \"created_at\": \"2026-10-06T10:00:00.000Z\"\n  },\n  \"membership\": {\n    \"role\": \"member\",\n    \"created_at\": \"2026-10-06T10:00:00.000Z\",\n    \"last_sign_in_at\": \"2026-10-06T10:05:00.000Z\",\n    \"active_sessions\": 1\n  },\n  \"passkeys\": [\n    {\n      \"name\": \"iPhone\",\n      \"rp_id\": \"wardrobe-demo.hypamoon.app\",\n      \"created_at\": \"2026-10-06T10:06:00.000Z\",\n      \"last_used_at\": \"2026-10-07T08:00:00.000Z\"\n    }\n  ],\n  \"tables\": {\n    \"items\": {\n      \"owner_column\": \"user_id\",\n      \"rows\": [\n        {\n          \"id\": \"c1\",\n          \"user_id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n          \"name\": \"Blue coat\",\n          \"photo_file_id\": \"5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9\",\n          \"created_at\": \"2026-10-06T10:10:00.000Z\"\n        }\n      ],\n      \"truncated\": false\n    }\n  },\n  \"files\": [\n    {\n      \"id\": \"5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9\",\n      \"name\": \"coat.jpg\",\n      \"content_type\": \"image/jpeg\",\n      \"bytes\": 48213,\n      \"status\": \"ready\",\n      \"created_at\": \"2026-10-06T10:10:00.000Z\",\n      \"download_url\": \"https://wardrobe-demo.hypamoon.app/_hypamoon/files/5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9?t=eyJvIjoi...\",\n      \"expires_at\": \"2026-10-07T12:15:00.000Z\"\n    }\n  ],\n  \"app_tables\": \"included\",\n  \"unmarked_tables\": [\n    \"outfits_shared\"\n  ],\n  \"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).\"\n}"
            }
          ]
        },
        {
          "name": "Delete everything of one user (GDPR)",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/users/:userId/data",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "users",
                ":userId",
                "data"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                },
                {
                  "key": "userId",
                  "value": "",
                  "description": "The id of the user (sub in x-hypamoon-user)."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write"
          },
          "response": [
            {
              "name": "200 What was deleted.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/users/:userId/data",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "users",
                    ":userId",
                    "data"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    },
                    {
                      "key": "userId",
                      "value": "",
                      "description": "The id of the user (sub in x-hypamoon-user)."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write"
              },
              "status": "What was deleted.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"app_id\": \"9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6\",\n  \"user_id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n  \"deleted\": {\n    \"rows\": {\n      \"items\": 12\n    },\n    \"files\": 12,\n    \"sessions\": 1,\n    \"passkeys\": 1,\n    \"membership\": true,\n    \"account\": true\n  },\n  \"app_tables\": \"included\",\n  \"unmarked_tables\": [],\n  \"note\": null\n}"
            }
          ]
        },
        {
          "name": "Make a table private per user",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/tables/:table/owner",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "tables",
                ":table",
                "owner"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                },
                {
                  "key": "table",
                  "value": "",
                  "description": "The name of a table in the app's database."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"column\": \"user_id\",\n  \"admin_role\": \"admin\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The app, with database.per_user_tables.",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/tables/:table/owner",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "tables",
                    ":table",
                    "owner"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    },
                    {
                      "key": "table",
                      "value": "",
                      "description": "The name of a table in the app's database."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"column\": \"user_id\",\n  \"admin_role\": \"admin\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The app, with database.per_user_tables.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6\",\n  \"name\": \"Wardrobe\",\n  \"site_id\": \"5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d\",\n  \"url\": \"https://wardrobe-demo.hypamoon.app\",\n  \"api_url\": \"https://wardrobe-demo.hypamoon.app/api\",\n  \"mode\": \"test\",\n  \"status\": \"active\",\n  \"version\": 2,\n  \"allowed_hosts\": [],\n  \"secrets\": [],\n  \"schedules\": [],\n  \"limits\": {\n    \"cpu_ms\": 10,\n    \"subrequests\": 10,\n    \"outbound_per_minute\": 60,\n    \"outbound_per_day\": 2000,\n    \"bundle_bytes\": 1048576\n  },\n  \"created_at\": \"2026-10-06T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-06T09:30:00.000Z\",\n  \"auth\": {\n    \"signup\": \"invite\",\n    \"roles\": [\n      \"admin\",\n      \"member\"\n    ],\n    \"default_role\": \"member\",\n    \"google\": false,\n    \"login_url\": \"https://wardrobe-demo.hypamoon.app/_hypamoon/auth/login\"\n  },\n  \"database\": {\n    \"bytes\": 24576,\n    \"full\": false,\n    \"per_user_tables\": [\n      {\n        \"table\": \"items\",\n        \"column\": \"user_id\",\n        \"admin_role\": \"admin\"\n      }\n    ]\n  }\n}"
            }
          ]
        },
        {
          "name": "Make a per-user table shared again",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/tables/:table/owner",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "tables",
                ":table",
                "owner"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                },
                {
                  "key": "table",
                  "value": "",
                  "description": "The name of a table in the app's database."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write"
          },
          "response": [
            {
              "name": "200 The app.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/tables/:table/owner",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "tables",
                    ":table",
                    "owner"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    },
                    {
                      "key": "table",
                      "value": "",
                      "description": "The name of a table in the app's database."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write"
              },
              "status": "The app.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"9d2e4f6a-1b3c-4d5e-8f70-a1b2c3d4e5f6\",\n  \"name\": \"Wardrobe\",\n  \"site_id\": \"5b0c7c2e-8f1d-4a3b-9c6e-2d4f5a6b7c8d\",\n  \"url\": \"https://wardrobe-demo.hypamoon.app\",\n  \"api_url\": \"https://wardrobe-demo.hypamoon.app/api\",\n  \"mode\": \"test\",\n  \"status\": \"active\",\n  \"version\": 2,\n  \"allowed_hosts\": [],\n  \"secrets\": [],\n  \"schedules\": [],\n  \"limits\": {\n    \"cpu_ms\": 10,\n    \"subrequests\": 10,\n    \"outbound_per_minute\": 60,\n    \"outbound_per_day\": 2000,\n    \"bundle_bytes\": 1048576\n  },\n  \"created_at\": \"2026-10-06T09:00:00.000Z\",\n  \"updated_at\": \"2026-10-06T09:30:00.000Z\",\n  \"auth\": {\n    \"signup\": \"invite\",\n    \"roles\": [\n      \"admin\",\n      \"member\"\n    ],\n    \"default_role\": \"member\",\n    \"google\": false,\n    \"login_url\": \"https://wardrobe-demo.hypamoon.app/_hypamoon/auth/login\"\n  },\n  \"database\": {\n    \"bytes\": 24576,\n    \"full\": false,\n    \"per_user_tables\": []\n  }\n}"
            }
          ]
        },
        {
          "name": "List the files of an app",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/files",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "files"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "100",
                  "description": "Files, 1 to 500.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "Files the app's users uploaded through the app, newest first, with the total size. The app itself uploads and downloads through /app-files.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The files.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/files",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "files"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "100",
                      "description": "Files, 1 to 500.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "Files the app's users uploaded through the app, newest first, with the total size. The app itself uploads and downloads through /app-files.\n\nScope: sites:read"
              },
              "status": "The files.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9\",\n      \"name\": \"receipt.pdf\",\n      \"content_type\": \"application/pdf\",\n      \"bytes\": 48213,\n      \"status\": \"ready\",\n      \"user_id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n      \"created_at\": \"2026-10-06T10:10:00.000Z\"\n    }\n  ],\n  \"bytes\": 48213\n}"
            }
          ]
        },
        {
          "name": "Delete a file from an app",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/files/:fileId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "files",
                ":fileId"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                },
                {
                  "key": "fileId",
                  "value": "",
                  "description": "The id of the file."
                }
              ]
            },
            "description": "Deletes the file permanently, including from storage.\n\nScope: sites:write"
          },
          "response": []
        },
        {
          "name": "List the migrations of an app",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/migrations",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "migrations"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "The migrations applied to the app's database, oldest first, and its size.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The migrations.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/migrations",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "migrations"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "The migrations applied to the app's database, oldest first, and its size.\n\nScope: sites:read"
              },
              "status": "The migrations.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"applied\": [],\n  \"skipped\": [],\n  \"data\": [\n    {\n      \"name\": \"0001_init.sql\",\n      \"sha256\": \"9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f\",\n      \"statements\": 2,\n      \"applied_at\": \"2026-10-06T10:00:00.000Z\"\n    }\n  ],\n  \"database_bytes\": 24576\n}"
            }
          ]
        },
        {
          "name": "Apply migrations to the database of an app",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/migrations",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "migrations"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"migrations\": [\n    {\n      \"name\": \"0001_init.sql\",\n      \"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);\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Applied (or already applied).",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/migrations",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "migrations"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"migrations\": [\n    {\n      \"name\": \"0001_init.sql\",\n      \"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);\"\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Applied (or already applied).",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"applied\": [\n    \"0001_init.sql\"\n  ],\n  \"skipped\": [],\n  \"data\": [\n    {\n      \"name\": \"0001_init.sql\",\n      \"sha256\": \"9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f9f\",\n      \"statements\": 2,\n      \"applied_at\": \"2026-10-06T10:00:00.000Z\"\n    }\n  ],\n  \"database_bytes\": 24576\n}"
            }
          ]
        },
        {
          "name": "Read the schema of an app's database",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/schema",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "schema"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The schema.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/schema",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "schema"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The schema.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"app_id\": \"4a7ff9fa-1c2d-4e5f-8a9b-0c1d2e3f4a5b\",\n  \"database\": true,\n  \"tables\": [\n    {\n      \"name\": \"recipes\",\n      \"columns\": [\n        {\n          \"name\": \"id\",\n          \"type\": \"TEXT\",\n          \"not_null\": false,\n          \"primary_key\": true,\n          \"default\": null\n        },\n        {\n          \"name\": \"user_id\",\n          \"type\": \"TEXT\",\n          \"not_null\": true,\n          \"primary_key\": false,\n          \"default\": null\n        },\n        {\n          \"name\": \"title\",\n          \"type\": \"TEXT\",\n          \"not_null\": true,\n          \"primary_key\": false,\n          \"default\": null\n        },\n        {\n          \"name\": \"ingredients\",\n          \"type\": \"TEXT\",\n          \"not_null\": false,\n          \"primary_key\": false,\n          \"default\": null\n        }\n      ],\n      \"indexes\": [\n        {\n          \"name\": \"recipes_user\",\n          \"unique\": false,\n          \"sql\": \"CREATE INDEX recipes_user ON recipes (user_id, created_at)\"\n        }\n      ],\n      \"rows\": 12,\n      \"per_user\": {\n        \"column\": \"user_id\",\n        \"admin_role\": \"admin\"\n      },\n      \"sql\": \"CREATE TABLE recipes (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, ingredients TEXT)\"\n    }\n  ],\n  \"views\": [],\n  \"migrations\": [\n    {\n      \"name\": \"0001_init.sql\",\n      \"applied_at\": \"2026-10-14T10:00:00.000Z\",\n      \"statements\": 4\n    }\n  ],\n  \"next_migration_name\": \"0002_next.sql\",\n  \"database_bytes\": 24576\n}"
            }
          ]
        },
        {
          "name": "Read the server code of an app",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/code",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "code"
              ],
              "query": [
                {
                  "key": "version",
                  "value": "",
                  "description": "A kept version (get_app lists them). Leave it out for the running version.",
                  "disabled": true
                },
                {
                  "key": "module",
                  "value": "",
                  "description": "Only this module, like index.js.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The code.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/code",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "code"
                  ],
                  "query": [
                    {
                      "key": "version",
                      "value": "",
                      "description": "A kept version (get_app lists them). Leave it out for the running version.",
                      "disabled": true
                    },
                    {
                      "key": "module",
                      "value": "",
                      "description": "Only this module, like index.js.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The code.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"app_code\",\n  \"app_id\": \"9f2b6c1e-3d4a-4b5c-8d7e-1f2a3b4c5d6e\",\n  \"version\": 3,\n  \"running_version\": 3,\n  \"main_module\": \"index.js\",\n  \"modules\": [\n    {\n      \"name\": \"index.js\",\n      \"type\": \"esm\",\n      \"bytes\": 74,\n      \"content\": \"export default { async fetch(request, env) { return new Response('hi'); } };\",\n      \"truncated\": false\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Run one read-only query on an app's database",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/apps/:id/query",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "apps",
                ":id",
                "query"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the app."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"sql\": \"SELECT id, title, servings FROM recipes ORDER BY created_at DESC\",\n  \"limit\": 20\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The rows.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/apps/:id/query",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "apps",
                    ":id",
                    "query"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the app."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"sql\": \"SELECT id, title, servings FROM recipes ORDER BY created_at DESC\",\n  \"limit\": 20\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The rows.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"columns\": [\n    \"id\",\n    \"title\",\n    \"servings\"\n  ],\n  \"rows\": [\n    {\n      \"id\": \"7f1c\",\n      \"title\": \"Pasta pesto\",\n      \"servings\": 4\n    }\n  ],\n  \"row_count\": 1,\n  \"limit\": 20,\n  \"truncated\": false,\n  \"per_user_tables\": [\n    \"recipes\"\n  ],\n  \"masked\": \"Email addresses and phone numbers are masked, text longer than 300 characters is cut, and binary values show only their size.\"\n}"
            }
          ]
        },
        {
          "name": "List the files (from an app)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/app-files",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-files"
              ],
              "query": [
                {
                  "key": "user_id",
                  "value": "",
                  "description": "Only the files of this user (sub from x-hypamoon-user).",
                  "disabled": true
                },
                {
                  "key": "limit",
                  "value": "100",
                  "description": "Files, 1 to 500.",
                  "disabled": true
                },
                {
                  "key": "all",
                  "value": "false",
                  "description": "With the signed headers of a user with the admin role (the first of auth.roles): the files of every user.",
                  "disabled": true
                }
              ]
            },
            "description": "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.\n\nScope: data:read"
          },
          "response": [
            {
              "name": "200 The files.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/app-files",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-files"
                  ],
                  "query": [
                    {
                      "key": "user_id",
                      "value": "",
                      "description": "Only the files of this user (sub from x-hypamoon-user).",
                      "disabled": true
                    },
                    {
                      "key": "limit",
                      "value": "100",
                      "description": "Files, 1 to 500.",
                      "disabled": true
                    },
                    {
                      "key": "all",
                      "value": "false",
                      "description": "With the signed headers of a user with the admin role (the first of auth.roles): the files of every user.",
                      "disabled": true
                    }
                  ]
                },
                "description": "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.\n\nScope: data:read"
              },
              "status": "The files.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9\",\n      \"name\": \"receipt.pdf\",\n      \"content_type\": \"application/pdf\",\n      \"bytes\": 48213,\n      \"status\": \"ready\",\n      \"user_id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n      \"created_at\": \"2026-10-06T10:10:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Get an upload link (from an app)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/app-files",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-files"
              ]
            },
            "description": "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.\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"receipt.pdf\",\n  \"content_type\": \"application/pdf\",\n  \"size\": 48213\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The upload link.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/app-files",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-files"
                  ]
                },
                "description": "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.\n\nScope: data:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"receipt.pdf\",\n  \"content_type\": \"application/pdf\",\n  \"size\": 48213\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The upload link.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9\",\n  \"upload_url\": \"/_hypamoon/files/upload?t=eyJvIjoi...\",\n  \"method\": \"PUT\",\n  \"expires_at\": \"2026-10-06T10:25:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Get a download link (from an app)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/app-files/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-files",
                ":id"
              ],
              "query": [
                {
                  "key": "public",
                  "value": "true",
                  "description": "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.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the file."
                }
              ]
            },
            "description": "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).\n\nScope: data:read"
          },
          "response": [
            {
              "name": "200 The download link.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/app-files/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-files",
                    ":id"
                  ],
                  "query": [
                    {
                      "key": "public",
                      "value": "true",
                      "description": "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.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the file."
                    }
                  ]
                },
                "description": "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).\n\nScope: data:read"
              },
              "status": "The download link.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9\",\n  \"name\": \"receipt.pdf\",\n  \"content_type\": \"application/pdf\",\n  \"bytes\": 48213,\n  \"download_url\": \"/_hypamoon/files/5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9?t=eyJvIjoi...\",\n  \"expires_at\": \"2026-10-06T10:25:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Delete a file (from an app)",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/app-files/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-files",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the file."
                }
              ]
            },
            "description": "For an app's own server code only: deletes the file permanently.\n\nScope: data:write"
          },
          "response": []
        },
        {
          "name": "List the users (from an app, as admin)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/app-users",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-users"
              ]
            },
            "description": "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.\n\nScope: data:read"
          },
          "response": [
            {
              "name": "200 The users.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/app-users",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-users"
                  ]
                },
                "description": "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.\n\nScope: data:read"
              },
              "status": "The users.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n      \"email\": \"sam@example.com\",\n      \"name\": \"Sam\",\n      \"role\": \"member\",\n      \"created_at\": \"2026-10-06T10:00:00.000Z\",\n      \"last_sign_in_at\": \"2026-10-06T10:05:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Invite someone (from an app, as admin)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/app-users",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-users"
              ]
            },
            "description": "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.\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"sam@example.com\",\n  \"name\": \"Sam\",\n  \"role\": \"member\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Invited, and the email is on its way.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/app-users",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-users"
                  ]
                },
                "description": "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.\n\nScope: data:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"email\": \"sam@example.com\",\n  \"name\": \"Sam\",\n  \"role\": \"member\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Invited, and the email is on its way.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n  \"email\": \"sam@example.com\",\n  \"name\": \"Sam\",\n  \"role\": \"member\",\n  \"created_at\": \"2026-10-06T10:00:00.000Z\",\n  \"last_sign_in_at\": null\n}"
            }
          ]
        },
        {
          "name": "Get one user (from an app, as admin)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/app-users/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-users",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the user (their sub in x-hypamoon-user)."
                }
              ]
            },
            "description": "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).\n\nScope: data:read"
          },
          "response": [
            {
              "name": "200 The user.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/app-users/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-users",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the user (their sub in x-hypamoon-user)."
                    }
                  ]
                },
                "description": "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).\n\nScope: data:read"
              },
              "status": "The user.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n  \"email\": \"sam@example.com\",\n  \"name\": \"Sam\",\n  \"role\": \"member\",\n  \"created_at\": \"2026-10-06T10:00:00.000Z\",\n  \"last_sign_in_at\": \"2026-10-06T10:05:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Change a user's role (from an app, as admin)",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/app-users/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-users",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the user (their sub in x-hypamoon-user)."
                }
              ]
            },
            "description": "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.\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"role\": \"admin\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The user with the new role.",
              "originalRequest": {
                "method": "PATCH",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/app-users/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-users",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the user (their sub in x-hypamoon-user)."
                    }
                  ]
                },
                "description": "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.\n\nScope: data:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"role\": \"admin\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The user with the new role.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7c1d2e3f-4a5b-4c6d-8e7f-90a1b2c3d4e5\",\n  \"email\": \"sam@example.com\",\n  \"name\": \"Sam\",\n  \"role\": \"admin\",\n  \"created_at\": \"2026-10-06T10:00:00.000Z\",\n  \"last_sign_in_at\": \"2026-10-06T10:05:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Remove a user (from an app, as admin)",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/app-users/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-users",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the user (their sub in x-hypamoon-user)."
                }
              ]
            },
            "description": "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).\n\nScope: data:write"
          },
          "response": []
        }
      ]
    },
    {
      "name": "Email",
      "description": "Email from the project's own domain (an extra module the owner turns on in the portal): sending domains with their DNS records, emails with cc, bcc and attachments, and batches of separate emails, only to people the project already knows.",
      "item": [
        {
          "name": "List sent emails",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/emails",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "emails"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "25",
                  "description": "Emails per page, 1 to 100.",
                  "disabled": true
                },
                {
                  "key": "cursor",
                  "value": "",
                  "description": "The next_cursor of the previous page.",
                  "disabled": true
                },
                {
                  "key": "app_id",
                  "value": "",
                  "description": "Only emails this app sent with env.HYPAMOON.sendEmail (an id from list_apps).",
                  "disabled": true
                },
                {
                  "key": "to",
                  "value": "",
                  "description": "Only emails with this address in to, cc or bcc.",
                  "disabled": true
                },
                {
                  "key": "status",
                  "value": "queued",
                  "description": "Only emails with this status.",
                  "disabled": true
                },
                {
                  "key": "since",
                  "value": "",
                  "description": "Only emails sent at or after this moment, like 2026-10-01 or 2026-10-15T09:00:00Z.",
                  "disabled": true
                }
              ]
            },
            "description": "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).\n\nScope: messages:read"
          },
          "response": [
            {
              "name": "200 The emails and the state of the module.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/emails",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "emails"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "25",
                      "description": "Emails per page, 1 to 100.",
                      "disabled": true
                    },
                    {
                      "key": "cursor",
                      "value": "",
                      "description": "The next_cursor of the previous page.",
                      "disabled": true
                    },
                    {
                      "key": "app_id",
                      "value": "",
                      "description": "Only emails this app sent with env.HYPAMOON.sendEmail (an id from list_apps).",
                      "disabled": true
                    },
                    {
                      "key": "to",
                      "value": "",
                      "description": "Only emails with this address in to, cc or bcc.",
                      "disabled": true
                    },
                    {
                      "key": "status",
                      "value": "queued",
                      "description": "Only emails with this status.",
                      "disabled": true
                    },
                    {
                      "key": "since",
                      "value": "",
                      "description": "Only emails sent at or after this moment, like 2026-10-01 or 2026-10-15T09:00:00Z.",
                      "disabled": true
                    }
                  ]
                },
                "description": "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).\n\nScope: messages:read"
              },
              "status": "The emails and the state of the module.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f\",\n      \"app_id\": null,\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"to\": \"sam@example.org\",\n      \"recipients\": {\n        \"to\": [\n          \"sam@example.org\"\n        ],\n        \"cc\": [],\n        \"bcc\": []\n      },\n      \"recipient_count\": 1,\n      \"attachments\": [\n        {\n          \"filename\": \"quote.pdf\",\n          \"content_type\": \"application/pdf\",\n          \"size\": 48213\n        }\n      ],\n      \"reply_to\": \"office@acmeplumbing.com\",\n      \"subject\": \"We received your request\",\n      \"category\": \"transactional\",\n      \"status\": \"sent\",\n      \"recipient_source\": \"form\",\n      \"test\": false,\n      \"error\": null,\n      \"reason\": null,\n      \"recipient_status\": [\n        {\n          \"email\": \"sam@example.org\",\n          \"field\": \"to\",\n          \"status\": \"sent\",\n          \"reason\": null,\n          \"updated_at\": \"2026-10-06T10:15:01.000Z\"\n        }\n      ],\n      \"timeline\": [\n        {\n          \"status\": \"queued\",\n          \"at\": \"2026-10-06T10:15:00.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        },\n        {\n          \"status\": \"sent\",\n          \"at\": \"2026-10-06T10:15:01.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        }\n      ],\n      \"created_at\": \"2026-10-06T10:15:00.000Z\",\n      \"sent_at\": \"2026-10-06T10:15:01.000Z\",\n      \"delivered_at\": null,\n      \"bounced_at\": null,\n      \"complained_at\": null\n    }\n  ],\n  \"next_cursor\": null,\n  \"mail\": {\n    \"enabled\": true,\n    \"paused\": false,\n    \"pause_reason\": null,\n    \"daily_limit\": 100,\n    \"sent_last_24h\": 12\n  }\n}"
            }
          ]
        },
        {
          "name": "Send one email",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "Optional. The same key returns the same email instead of sending it again."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/emails",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "emails"
              ]
            },
            "description": "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.\n\nScope: messages:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"to\": \"sam@example.org\",\n  \"subject\": \"We received your request\",\n  \"text\": \"Hi Sam, thanks for your request. We will call you back today.\",\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"category\": \"transactional\",\n  \"attachments\": [\n    {\n      \"filename\": \"quote.pdf\",\n      \"content\": \"JVBERi0xLjcKJcfsj6IK...\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The same Idempotency-Key as before: the original email, and nothing is sent again.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "Optional. The same key returns the same email instead of sending it again."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/emails",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "emails"
                  ]
                },
                "description": "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.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"to\": \"sam@example.org\",\n  \"subject\": \"We received your request\",\n  \"text\": \"Hi Sam, thanks for your request. We will call you back today.\",\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"category\": \"transactional\",\n  \"attachments\": [\n    {\n      \"filename\": \"quote.pdf\",\n      \"content\": \"JVBERi0xLjcKJcfsj6IK...\"\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The same Idempotency-Key as before: the original email, and nothing is sent again.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f\",\n  \"app_id\": null,\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"to\": \"sam@example.org\",\n  \"recipients\": {\n    \"to\": [\n      \"sam@example.org\"\n    ],\n    \"cc\": [],\n    \"bcc\": []\n  },\n  \"recipient_count\": 1,\n  \"attachments\": [\n    {\n      \"filename\": \"quote.pdf\",\n      \"content_type\": \"application/pdf\",\n      \"size\": 48213\n    }\n  ],\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"subject\": \"We received your request\",\n  \"category\": \"transactional\",\n  \"status\": \"sent\",\n  \"recipient_source\": \"form\",\n  \"test\": false,\n  \"error\": null,\n  \"reason\": null,\n  \"recipient_status\": [\n    {\n      \"email\": \"sam@example.org\",\n      \"field\": \"to\",\n      \"status\": \"sent\",\n      \"reason\": null,\n      \"updated_at\": \"2026-10-06T10:15:01.000Z\"\n    }\n  ],\n  \"timeline\": [\n    {\n      \"status\": \"queued\",\n      \"at\": \"2026-10-06T10:15:00.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    },\n    {\n      \"status\": \"sent\",\n      \"at\": \"2026-10-06T10:15:01.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    }\n  ],\n  \"created_at\": \"2026-10-06T10:15:00.000Z\",\n  \"sent_at\": \"2026-10-06T10:15:01.000Z\",\n  \"delivered_at\": null,\n  \"bounced_at\": null,\n  \"complained_at\": null\n}"
            },
            {
              "name": "201 Accepted for delivery (or simulated with a test key).",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "Optional. The same key returns the same email instead of sending it again."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/emails",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "emails"
                  ]
                },
                "description": "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.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"to\": \"sam@example.org\",\n  \"subject\": \"We received your request\",\n  \"text\": \"Hi Sam, thanks for your request. We will call you back today.\",\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"category\": \"transactional\",\n  \"attachments\": [\n    {\n      \"filename\": \"quote.pdf\",\n      \"content\": \"JVBERi0xLjcKJcfsj6IK...\"\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Accepted for delivery (or simulated with a test key).",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f\",\n  \"app_id\": null,\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"to\": \"sam@example.org\",\n  \"recipients\": {\n    \"to\": [\n      \"sam@example.org\"\n    ],\n    \"cc\": [],\n    \"bcc\": []\n  },\n  \"recipient_count\": 1,\n  \"attachments\": [\n    {\n      \"filename\": \"quote.pdf\",\n      \"content_type\": \"application/pdf\",\n      \"size\": 48213\n    }\n  ],\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"subject\": \"We received your request\",\n  \"category\": \"transactional\",\n  \"status\": \"sent\",\n  \"recipient_source\": \"form\",\n  \"test\": false,\n  \"error\": null,\n  \"reason\": null,\n  \"recipient_status\": [\n    {\n      \"email\": \"sam@example.org\",\n      \"field\": \"to\",\n      \"status\": \"sent\",\n      \"reason\": null,\n      \"updated_at\": \"2026-10-06T10:15:01.000Z\"\n    }\n  ],\n  \"timeline\": [\n    {\n      \"status\": \"queued\",\n      \"at\": \"2026-10-06T10:15:00.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    },\n    {\n      \"status\": \"sent\",\n      \"at\": \"2026-10-06T10:15:01.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    }\n  ],\n  \"created_at\": \"2026-10-06T10:15:00.000Z\",\n  \"sent_at\": \"2026-10-06T10:15:01.000Z\",\n  \"delivered_at\": null,\n  \"bounced_at\": null,\n  \"complained_at\": null\n}"
            },
            {
              "name": "202 With send_at: the scheduled email. get_scheduled_email shows it; once sent, email_id is the email.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "Optional. The same key returns the same email instead of sending it again."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/emails",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "emails"
                  ]
                },
                "description": "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.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"to\": \"sam@example.org\",\n  \"subject\": \"We received your request\",\n  \"text\": \"Hi Sam, thanks for your request. We will call you back today.\",\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"category\": \"transactional\",\n  \"attachments\": [\n    {\n      \"filename\": \"quote.pdf\",\n      \"content\": \"JVBERi0xLjcKJcfsj6IK...\"\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "With send_at: the scheduled email. get_scheduled_email shows it; once sent, email_id is the email.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6c7d8e9f-0a1b-4c2d-9e3f-4a5b6c7d8e9f\",\n  \"source\": \"api\",\n  \"status\": \"scheduled\",\n  \"send_at\": \"2026-10-16T08:00:00.000Z\",\n  \"to\": \"sam@example.org\",\n  \"subject\": \"Your appointment tomorrow\",\n  \"template_id\": null,\n  \"email_id\": null,\n  \"reason\": null,\n  \"test\": false,\n  \"created_at\": \"2026-10-15T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "List the emails of an app (from the app)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/app-emails",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-emails"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "25",
                  "description": "Emails per page, 1 to 100.",
                  "disabled": true
                },
                {
                  "key": "cursor",
                  "value": "",
                  "description": "The next_cursor of the previous page.",
                  "disabled": true
                },
                {
                  "key": "to",
                  "value": "",
                  "description": "Only emails with this address in to, cc or bcc.",
                  "disabled": true
                },
                {
                  "key": "status",
                  "value": "queued",
                  "description": "Only emails with this status.",
                  "disabled": true
                },
                {
                  "key": "since",
                  "value": "",
                  "description": "Only emails sent at or after this moment, like 2026-10-01 or 2026-10-15T09:00:00Z.",
                  "disabled": true
                }
              ]
            },
            "description": "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}.\n\nScope: data:read"
          },
          "response": [
            {
              "name": "200 The app's emails.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/app-emails",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-emails"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "25",
                      "description": "Emails per page, 1 to 100.",
                      "disabled": true
                    },
                    {
                      "key": "cursor",
                      "value": "",
                      "description": "The next_cursor of the previous page.",
                      "disabled": true
                    },
                    {
                      "key": "to",
                      "value": "",
                      "description": "Only emails with this address in to, cc or bcc.",
                      "disabled": true
                    },
                    {
                      "key": "status",
                      "value": "queued",
                      "description": "Only emails with this status.",
                      "disabled": true
                    },
                    {
                      "key": "since",
                      "value": "",
                      "description": "Only emails sent at or after this moment, like 2026-10-01 or 2026-10-15T09:00:00Z.",
                      "disabled": true
                    }
                  ]
                },
                "description": "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}.\n\nScope: data:read"
              },
              "status": "The app's emails.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f\",\n      \"app_id\": \"5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d\",\n      \"from\": \"noreply@acmeplumbing.com\",\n      \"to\": \"sam@example.org\",\n      \"recipients\": {\n        \"to\": [\n          \"sam@example.org\"\n        ],\n        \"cc\": [],\n        \"bcc\": []\n      },\n      \"recipient_count\": 1,\n      \"attachments\": [],\n      \"reply_to\": null,\n      \"subject\": \"Your hours for week 41 are approved\",\n      \"category\": \"transactional\",\n      \"status\": \"delivered\",\n      \"recipient_source\": \"form\",\n      \"test\": false,\n      \"error\": null,\n      \"reason\": null,\n      \"recipient_status\": [\n        {\n          \"email\": \"sam@example.org\",\n          \"field\": \"to\",\n          \"status\": \"delivered\",\n          \"reason\": null,\n          \"updated_at\": \"2026-10-06T10:15:04.000Z\"\n        }\n      ],\n      \"timeline\": [\n        {\n          \"status\": \"queued\",\n          \"at\": \"2026-10-06T10:15:00.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        },\n        {\n          \"status\": \"sent\",\n          \"at\": \"2026-10-06T10:15:01.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        },\n        {\n          \"status\": \"delivered\",\n          \"at\": \"2026-10-06T10:15:04.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        }\n      ],\n      \"created_at\": \"2026-10-06T10:15:00.000Z\",\n      \"sent_at\": \"2026-10-06T10:15:01.000Z\",\n      \"delivered_at\": \"2026-10-06T10:15:04.000Z\",\n      \"bounced_at\": null,\n      \"complained_at\": null\n    }\n  ],\n  \"next_cursor\": null\n}"
            }
          ]
        },
        {
          "name": "Send one email (from an app)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "Optional. The same key returns the same email instead of sending it again."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/app-emails",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-emails"
              ]
            },
            "description": "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).\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"to\": \"sam@example.org\",\n  \"subject\": \"Your hours for week 41 are approved\",\n  \"text\": \"Hi Sam, your hours for week 41 were approved.\",\n  \"category\": \"transactional\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The same Idempotency-Key as before: the original email, and nothing is sent again.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "Optional. The same key returns the same email instead of sending it again."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/app-emails",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-emails"
                  ]
                },
                "description": "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).\n\nScope: data:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"to\": \"sam@example.org\",\n  \"subject\": \"Your hours for week 41 are approved\",\n  \"text\": \"Hi Sam, your hours for week 41 were approved.\",\n  \"category\": \"transactional\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The same Idempotency-Key as before: the original email, and nothing is sent again.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f\",\n  \"app_id\": null,\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"to\": \"sam@example.org\",\n  \"recipients\": {\n    \"to\": [\n      \"sam@example.org\"\n    ],\n    \"cc\": [],\n    \"bcc\": []\n  },\n  \"recipient_count\": 1,\n  \"attachments\": [\n    {\n      \"filename\": \"quote.pdf\",\n      \"content_type\": \"application/pdf\",\n      \"size\": 48213\n    }\n  ],\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"subject\": \"We received your request\",\n  \"category\": \"transactional\",\n  \"status\": \"sent\",\n  \"recipient_source\": \"form\",\n  \"test\": false,\n  \"error\": null,\n  \"reason\": null,\n  \"recipient_status\": [\n    {\n      \"email\": \"sam@example.org\",\n      \"field\": \"to\",\n      \"status\": \"sent\",\n      \"reason\": null,\n      \"updated_at\": \"2026-10-06T10:15:01.000Z\"\n    }\n  ],\n  \"timeline\": [\n    {\n      \"status\": \"queued\",\n      \"at\": \"2026-10-06T10:15:00.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    },\n    {\n      \"status\": \"sent\",\n      \"at\": \"2026-10-06T10:15:01.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    }\n  ],\n  \"created_at\": \"2026-10-06T10:15:00.000Z\",\n  \"sent_at\": \"2026-10-06T10:15:01.000Z\",\n  \"delivered_at\": null,\n  \"bounced_at\": null,\n  \"complained_at\": null\n}"
            },
            {
              "name": "201 Accepted for delivery (or simulated with a test key).",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "Optional. The same key returns the same email instead of sending it again."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/app-emails",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-emails"
                  ]
                },
                "description": "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).\n\nScope: data:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"to\": \"sam@example.org\",\n  \"subject\": \"Your hours for week 41 are approved\",\n  \"text\": \"Hi Sam, your hours for week 41 were approved.\",\n  \"category\": \"transactional\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Accepted for delivery (or simulated with a test key).",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f\",\n  \"app_id\": null,\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"to\": \"sam@example.org\",\n  \"recipients\": {\n    \"to\": [\n      \"sam@example.org\"\n    ],\n    \"cc\": [],\n    \"bcc\": []\n  },\n  \"recipient_count\": 1,\n  \"attachments\": [\n    {\n      \"filename\": \"quote.pdf\",\n      \"content_type\": \"application/pdf\",\n      \"size\": 48213\n    }\n  ],\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"subject\": \"We received your request\",\n  \"category\": \"transactional\",\n  \"status\": \"sent\",\n  \"recipient_source\": \"form\",\n  \"test\": false,\n  \"error\": null,\n  \"reason\": null,\n  \"recipient_status\": [\n    {\n      \"email\": \"sam@example.org\",\n      \"field\": \"to\",\n      \"status\": \"sent\",\n      \"reason\": null,\n      \"updated_at\": \"2026-10-06T10:15:01.000Z\"\n    }\n  ],\n  \"timeline\": [\n    {\n      \"status\": \"queued\",\n      \"at\": \"2026-10-06T10:15:00.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    },\n    {\n      \"status\": \"sent\",\n      \"at\": \"2026-10-06T10:15:01.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    }\n  ],\n  \"created_at\": \"2026-10-06T10:15:00.000Z\",\n  \"sent_at\": \"2026-10-06T10:15:01.000Z\",\n  \"delivered_at\": null,\n  \"bounced_at\": null,\n  \"complained_at\": null\n}"
            }
          ]
        },
        {
          "name": "Send a batch of emails",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "Optional. The same key returns the same emails instead of sending them again."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/emails/batch",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "emails",
                "batch"
              ]
            },
            "description": "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).\n\nScope: messages:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"emails\": [\n    {\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"to\": \"sam@example.org\",\n      \"subject\": \"Your invitation\",\n      \"text\": \"Hi Sam, you are invited to our open day on 1 November.\",\n      \"category\": \"transactional\"\n    },\n    {\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"to\": \"kim@example.org\",\n      \"subject\": \"Your invitation\",\n      \"text\": \"Hi Kim, you are invited to our open day on 1 November.\",\n      \"category\": \"transactional\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The same Idempotency-Key as before: the original emails, and nothing is sent again.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "Optional. The same key returns the same emails instead of sending them again."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/emails/batch",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "emails",
                    "batch"
                  ]
                },
                "description": "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).\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"emails\": [\n    {\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"to\": \"sam@example.org\",\n      \"subject\": \"Your invitation\",\n      \"text\": \"Hi Sam, you are invited to our open day on 1 November.\",\n      \"category\": \"transactional\"\n    },\n    {\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"to\": \"kim@example.org\",\n      \"subject\": \"Your invitation\",\n      \"text\": \"Hi Kim, you are invited to our open day on 1 November.\",\n      \"category\": \"transactional\"\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The same Idempotency-Key as before: the original emails, and nothing is sent again.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f\",\n      \"app_id\": null,\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"to\": \"sam@example.org\",\n      \"recipients\": {\n        \"to\": [\n          \"sam@example.org\"\n        ],\n        \"cc\": [],\n        \"bcc\": []\n      },\n      \"recipient_count\": 1,\n      \"attachments\": [\n        {\n          \"filename\": \"quote.pdf\",\n          \"content_type\": \"application/pdf\",\n          \"size\": 48213\n        }\n      ],\n      \"reply_to\": \"office@acmeplumbing.com\",\n      \"subject\": \"We received your request\",\n      \"category\": \"transactional\",\n      \"status\": \"sent\",\n      \"recipient_source\": \"form\",\n      \"test\": false,\n      \"error\": null,\n      \"reason\": null,\n      \"recipient_status\": [\n        {\n          \"email\": \"sam@example.org\",\n          \"field\": \"to\",\n          \"status\": \"sent\",\n          \"reason\": null,\n          \"updated_at\": \"2026-10-06T10:15:01.000Z\"\n        }\n      ],\n      \"timeline\": [\n        {\n          \"status\": \"queued\",\n          \"at\": \"2026-10-06T10:15:00.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        },\n        {\n          \"status\": \"sent\",\n          \"at\": \"2026-10-06T10:15:01.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        }\n      ],\n      \"created_at\": \"2026-10-06T10:15:00.000Z\",\n      \"sent_at\": \"2026-10-06T10:15:01.000Z\",\n      \"delivered_at\": null,\n      \"bounced_at\": null,\n      \"complained_at\": null\n    }\n  ]\n}"
            },
            {
              "name": "201 Accepted for delivery (or simulated with a test key), in the order of the request.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "Optional. The same key returns the same emails instead of sending them again."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/emails/batch",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "emails",
                    "batch"
                  ]
                },
                "description": "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).\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"emails\": [\n    {\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"to\": \"sam@example.org\",\n      \"subject\": \"Your invitation\",\n      \"text\": \"Hi Sam, you are invited to our open day on 1 November.\",\n      \"category\": \"transactional\"\n    },\n    {\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"to\": \"kim@example.org\",\n      \"subject\": \"Your invitation\",\n      \"text\": \"Hi Kim, you are invited to our open day on 1 November.\",\n      \"category\": \"transactional\"\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Accepted for delivery (or simulated with a test key), in the order of the request.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f\",\n      \"app_id\": null,\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"to\": \"sam@example.org\",\n      \"recipients\": {\n        \"to\": [\n          \"sam@example.org\"\n        ],\n        \"cc\": [],\n        \"bcc\": []\n      },\n      \"recipient_count\": 1,\n      \"attachments\": [\n        {\n          \"filename\": \"quote.pdf\",\n          \"content_type\": \"application/pdf\",\n          \"size\": 48213\n        }\n      ],\n      \"reply_to\": \"office@acmeplumbing.com\",\n      \"subject\": \"We received your request\",\n      \"category\": \"transactional\",\n      \"status\": \"sent\",\n      \"recipient_source\": \"form\",\n      \"test\": false,\n      \"error\": null,\n      \"reason\": null,\n      \"recipient_status\": [\n        {\n          \"email\": \"sam@example.org\",\n          \"field\": \"to\",\n          \"status\": \"sent\",\n          \"reason\": null,\n          \"updated_at\": \"2026-10-06T10:15:01.000Z\"\n        }\n      ],\n      \"timeline\": [\n        {\n          \"status\": \"queued\",\n          \"at\": \"2026-10-06T10:15:00.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        },\n        {\n          \"status\": \"sent\",\n          \"at\": \"2026-10-06T10:15:01.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        }\n      ],\n      \"created_at\": \"2026-10-06T10:15:00.000Z\",\n      \"sent_at\": \"2026-10-06T10:15:01.000Z\",\n      \"delivered_at\": null,\n      \"bounced_at\": null,\n      \"complained_at\": null\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Send a batch of emails (from an app)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              },
              {
                "key": "Idempotency-Key",
                "value": "{{$guid}}",
                "description": "Optional. The same key returns the same emails instead of sending them again."
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/app-emails/batch",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-emails",
                "batch"
              ]
            },
            "description": "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.\n\nScope: data:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"emails\": [\n    {\n      \"to\": \"sam@example.org\",\n      \"subject\": \"Your invitation\",\n      \"text\": \"Hi Sam, see you on 1 November.\",\n      \"category\": \"transactional\"\n    }\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The same Idempotency-Key as before: the original emails.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "Optional. The same key returns the same emails instead of sending them again."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/app-emails/batch",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-emails",
                    "batch"
                  ]
                },
                "description": "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.\n\nScope: data:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"emails\": [\n    {\n      \"to\": \"sam@example.org\",\n      \"subject\": \"Your invitation\",\n      \"text\": \"Hi Sam, see you on 1 November.\",\n      \"category\": \"transactional\"\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The same Idempotency-Key as before: the original emails.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f\",\n      \"app_id\": null,\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"to\": \"sam@example.org\",\n      \"recipients\": {\n        \"to\": [\n          \"sam@example.org\"\n        ],\n        \"cc\": [],\n        \"bcc\": []\n      },\n      \"recipient_count\": 1,\n      \"attachments\": [\n        {\n          \"filename\": \"quote.pdf\",\n          \"content_type\": \"application/pdf\",\n          \"size\": 48213\n        }\n      ],\n      \"reply_to\": \"office@acmeplumbing.com\",\n      \"subject\": \"We received your request\",\n      \"category\": \"transactional\",\n      \"status\": \"sent\",\n      \"recipient_source\": \"form\",\n      \"test\": false,\n      \"error\": null,\n      \"reason\": null,\n      \"recipient_status\": [\n        {\n          \"email\": \"sam@example.org\",\n          \"field\": \"to\",\n          \"status\": \"sent\",\n          \"reason\": null,\n          \"updated_at\": \"2026-10-06T10:15:01.000Z\"\n        }\n      ],\n      \"timeline\": [\n        {\n          \"status\": \"queued\",\n          \"at\": \"2026-10-06T10:15:00.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        },\n        {\n          \"status\": \"sent\",\n          \"at\": \"2026-10-06T10:15:01.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        }\n      ],\n      \"created_at\": \"2026-10-06T10:15:00.000Z\",\n      \"sent_at\": \"2026-10-06T10:15:01.000Z\",\n      \"delivered_at\": null,\n      \"bounced_at\": null,\n      \"complained_at\": null\n    }\n  ]\n}"
            },
            {
              "name": "201 Accepted for delivery (or simulated with a test key).",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  },
                  {
                    "key": "Idempotency-Key",
                    "value": "{{$guid}}",
                    "description": "Optional. The same key returns the same emails instead of sending them again."
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/app-emails/batch",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-emails",
                    "batch"
                  ]
                },
                "description": "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.\n\nScope: data:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"emails\": [\n    {\n      \"to\": \"sam@example.org\",\n      \"subject\": \"Your invitation\",\n      \"text\": \"Hi Sam, see you on 1 November.\",\n      \"category\": \"transactional\"\n    }\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Accepted for delivery (or simulated with a test key).",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f\",\n      \"app_id\": null,\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"to\": \"sam@example.org\",\n      \"recipients\": {\n        \"to\": [\n          \"sam@example.org\"\n        ],\n        \"cc\": [],\n        \"bcc\": []\n      },\n      \"recipient_count\": 1,\n      \"attachments\": [\n        {\n          \"filename\": \"quote.pdf\",\n          \"content_type\": \"application/pdf\",\n          \"size\": 48213\n        }\n      ],\n      \"reply_to\": \"office@acmeplumbing.com\",\n      \"subject\": \"We received your request\",\n      \"category\": \"transactional\",\n      \"status\": \"sent\",\n      \"recipient_source\": \"form\",\n      \"test\": false,\n      \"error\": null,\n      \"reason\": null,\n      \"recipient_status\": [\n        {\n          \"email\": \"sam@example.org\",\n          \"field\": \"to\",\n          \"status\": \"sent\",\n          \"reason\": null,\n          \"updated_at\": \"2026-10-06T10:15:01.000Z\"\n        }\n      ],\n      \"timeline\": [\n        {\n          \"status\": \"queued\",\n          \"at\": \"2026-10-06T10:15:00.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        },\n        {\n          \"status\": \"sent\",\n          \"at\": \"2026-10-06T10:15:01.000Z\",\n          \"recipient\": null,\n          \"reason\": null\n        }\n      ],\n      \"created_at\": \"2026-10-06T10:15:00.000Z\",\n      \"sent_at\": \"2026-10-06T10:15:01.000Z\",\n      \"delivered_at\": null,\n      \"bounced_at\": null,\n      \"complained_at\": null\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Get one email",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/emails/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "emails",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the email."
                }
              ]
            },
            "description": "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.\n\nScope: messages:read"
          },
          "response": [
            {
              "name": "200 The email.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/emails/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "emails",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the email."
                    }
                  ]
                },
                "description": "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.\n\nScope: messages:read"
              },
              "status": "The email.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f\",\n  \"app_id\": null,\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"to\": \"sam@example.org\",\n  \"recipients\": {\n    \"to\": [\n      \"sam@example.org\"\n    ],\n    \"cc\": [],\n    \"bcc\": []\n  },\n  \"recipient_count\": 1,\n  \"attachments\": [\n    {\n      \"filename\": \"quote.pdf\",\n      \"content_type\": \"application/pdf\",\n      \"size\": 48213\n    }\n  ],\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"subject\": \"We received your request\",\n  \"category\": \"transactional\",\n  \"status\": \"delivered\",\n  \"recipient_source\": \"form\",\n  \"test\": false,\n  \"error\": null,\n  \"reason\": null,\n  \"recipient_status\": [\n    {\n      \"email\": \"sam@example.org\",\n      \"field\": \"to\",\n      \"status\": \"delivered\",\n      \"reason\": null,\n      \"updated_at\": \"2026-10-06T10:15:04.000Z\"\n    }\n  ],\n  \"timeline\": [\n    {\n      \"status\": \"queued\",\n      \"at\": \"2026-10-06T10:15:00.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    },\n    {\n      \"status\": \"sent\",\n      \"at\": \"2026-10-06T10:15:01.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    },\n    {\n      \"status\": \"delivered\",\n      \"at\": \"2026-10-06T10:15:04.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    }\n  ],\n  \"created_at\": \"2026-10-06T10:15:00.000Z\",\n  \"sent_at\": \"2026-10-06T10:15:01.000Z\",\n  \"delivered_at\": \"2026-10-06T10:15:04.000Z\",\n  \"bounced_at\": null,\n  \"complained_at\": null\n}"
            }
          ]
        },
        {
          "name": "Get one email of an app (from the app)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/app-emails/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "app-emails",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the email."
                }
              ]
            },
            "description": "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.\n\nScope: data:read"
          },
          "response": [
            {
              "name": "200 The email.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/app-emails/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "app-emails",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the email."
                    }
                  ]
                },
                "description": "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.\n\nScope: data:read"
              },
              "status": "The email.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f\",\n  \"app_id\": \"5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d\",\n  \"from\": \"noreply@acmeplumbing.com\",\n  \"to\": \"sam@example.org\",\n  \"recipients\": {\n    \"to\": [\n      \"sam@example.org\"\n    ],\n    \"cc\": [],\n    \"bcc\": []\n  },\n  \"recipient_count\": 1,\n  \"attachments\": [],\n  \"reply_to\": null,\n  \"subject\": \"Your hours for week 41 are approved\",\n  \"category\": \"transactional\",\n  \"status\": \"delivered\",\n  \"recipient_source\": \"form\",\n  \"test\": false,\n  \"error\": null,\n  \"reason\": null,\n  \"recipient_status\": [\n    {\n      \"email\": \"sam@example.org\",\n      \"field\": \"to\",\n      \"status\": \"delivered\",\n      \"reason\": null,\n      \"updated_at\": \"2026-10-06T10:15:04.000Z\"\n    }\n  ],\n  \"timeline\": [\n    {\n      \"status\": \"queued\",\n      \"at\": \"2026-10-06T10:15:00.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    },\n    {\n      \"status\": \"sent\",\n      \"at\": \"2026-10-06T10:15:01.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    },\n    {\n      \"status\": \"delivered\",\n      \"at\": \"2026-10-06T10:15:04.000Z\",\n      \"recipient\": null,\n      \"reason\": null\n    }\n  ],\n  \"created_at\": \"2026-10-06T10:15:00.000Z\",\n  \"sent_at\": \"2026-10-06T10:15:01.000Z\",\n  \"delivered_at\": \"2026-10-06T10:15:04.000Z\",\n  \"bounced_at\": null,\n  \"complained_at\": null\n}"
            }
          ]
        },
        {
          "name": "List sending domains",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-domains",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-domains"
              ]
            },
            "description": "The project's sending domains with their status and DNS records.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The sending domains.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-domains",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-domains"
                  ]
                },
                "description": "The project's sending domains with their status and DNS records.\n\nScope: sites:read"
              },
              "status": "The sending domains.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a\",\n      \"domain\": \"acmeplumbing.com\",\n      \"status\": \"pending\",\n      \"test\": false,\n      \"dns\": \"managed\",\n      \"records\": [\n        {\n          \"type\": \"MX\",\n          \"name\": \"send.acmeplumbing.com\",\n          \"value\": \"feedback-smtp.example.net\",\n          \"priority\": 10,\n          \"state\": \"set\"\n        },\n        {\n          \"type\": \"TXT\",\n          \"name\": \"send.acmeplumbing.com\",\n          \"value\": \"v=spf1 include:spf.example.net ~all\",\n          \"state\": \"set\"\n        },\n        {\n          \"type\": \"TXT\",\n          \"name\": \"mail._domainkey.acmeplumbing.com\",\n          \"value\": \"p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...\",\n          \"state\": \"set\"\n        }\n      ],\n      \"dmarc_suggestion\": {\n        \"type\": \"TXT\",\n        \"name\": \"_dmarc.acmeplumbing.com\",\n        \"value\": \"v=DMARC1; p=none;\"\n      },\n      \"next_step\": \"The records are set. Call verify_email_domain in a few minutes; verification usually takes minutes, sometimes up to a few hours.\",\n      \"checked_at\": null,\n      \"verified_at\": null,\n      \"created_at\": \"2026-10-06T10:00:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Add a sending domain",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-domains",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-domains"
              ]
            },
            "description": "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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"domain\": \"acmeplumbing.com\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Already a sending domain of this project.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-domains",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-domains"
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"domain\": \"acmeplumbing.com\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Already a sending domain of this project.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a\",\n  \"domain\": \"acmeplumbing.com\",\n  \"status\": \"pending\",\n  \"test\": false,\n  \"dns\": \"managed\",\n  \"records\": [\n    {\n      \"type\": \"MX\",\n      \"name\": \"send.acmeplumbing.com\",\n      \"value\": \"feedback-smtp.example.net\",\n      \"priority\": 10,\n      \"state\": \"set\"\n    },\n    {\n      \"type\": \"TXT\",\n      \"name\": \"send.acmeplumbing.com\",\n      \"value\": \"v=spf1 include:spf.example.net ~all\",\n      \"state\": \"set\"\n    },\n    {\n      \"type\": \"TXT\",\n      \"name\": \"mail._domainkey.acmeplumbing.com\",\n      \"value\": \"p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...\",\n      \"state\": \"set\"\n    }\n  ],\n  \"dmarc_suggestion\": {\n    \"type\": \"TXT\",\n    \"name\": \"_dmarc.acmeplumbing.com\",\n    \"value\": \"v=DMARC1; p=none;\"\n  },\n  \"next_step\": \"The records are set. Call verify_email_domain in a few minutes; verification usually takes minutes, sometimes up to a few hours.\",\n  \"checked_at\": null,\n  \"verified_at\": null,\n  \"created_at\": \"2026-10-06T10:00:00.000Z\"\n}"
            },
            {
              "name": "201 The sending domain, with its records.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-domains",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-domains"
                  ]
                },
                "description": "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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"domain\": \"acmeplumbing.com\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The sending domain, with its records.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a\",\n  \"domain\": \"acmeplumbing.com\",\n  \"status\": \"pending\",\n  \"test\": false,\n  \"dns\": \"managed\",\n  \"records\": [\n    {\n      \"type\": \"MX\",\n      \"name\": \"send.acmeplumbing.com\",\n      \"value\": \"feedback-smtp.example.net\",\n      \"priority\": 10,\n      \"state\": \"set\"\n    },\n    {\n      \"type\": \"TXT\",\n      \"name\": \"send.acmeplumbing.com\",\n      \"value\": \"v=spf1 include:spf.example.net ~all\",\n      \"state\": \"set\"\n    },\n    {\n      \"type\": \"TXT\",\n      \"name\": \"mail._domainkey.acmeplumbing.com\",\n      \"value\": \"p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...\",\n      \"state\": \"set\"\n    }\n  ],\n  \"dmarc_suggestion\": {\n    \"type\": \"TXT\",\n    \"name\": \"_dmarc.acmeplumbing.com\",\n    \"value\": \"v=DMARC1; p=none;\"\n  },\n  \"next_step\": \"The records are set. Call verify_email_domain in a few minutes; verification usually takes minutes, sometimes up to a few hours.\",\n  \"checked_at\": null,\n  \"verified_at\": null,\n  \"created_at\": \"2026-10-06T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Get a sending domain",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-domains/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-domains",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the sending domain."
                }
              ]
            },
            "description": "One sending domain with its records and the status of the last check. Read-only; verify_email_domain runs a new check.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The sending domain.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-domains/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-domains",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the sending domain."
                    }
                  ]
                },
                "description": "One sending domain with its records and the status of the last check. Read-only; verify_email_domain runs a new check.\n\nScope: sites:read"
              },
              "status": "The sending domain.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a\",\n  \"domain\": \"acmeplumbing.com\",\n  \"status\": \"pending\",\n  \"test\": false,\n  \"dns\": \"managed\",\n  \"records\": [\n    {\n      \"type\": \"MX\",\n      \"name\": \"send.acmeplumbing.com\",\n      \"value\": \"feedback-smtp.example.net\",\n      \"priority\": 10,\n      \"state\": \"set\"\n    },\n    {\n      \"type\": \"TXT\",\n      \"name\": \"send.acmeplumbing.com\",\n      \"value\": \"v=spf1 include:spf.example.net ~all\",\n      \"state\": \"set\"\n    },\n    {\n      \"type\": \"TXT\",\n      \"name\": \"mail._domainkey.acmeplumbing.com\",\n      \"value\": \"p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...\",\n      \"state\": \"set\"\n    }\n  ],\n  \"dmarc_suggestion\": {\n    \"type\": \"TXT\",\n    \"name\": \"_dmarc.acmeplumbing.com\",\n    \"value\": \"v=DMARC1; p=none;\"\n  },\n  \"next_step\": \"The records are set. Call verify_email_domain in a few minutes; verification usually takes minutes, sometimes up to a few hours.\",\n  \"checked_at\": null,\n  \"verified_at\": null,\n  \"created_at\": \"2026-10-06T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Remove a sending domain",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-domains/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-domains",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the sending domain."
                }
              ]
            },
            "description": "Stops sending from this domain. The DNS records remain (they do no harm); the domain itself stays connected.\n\nScope: sites:write"
          },
          "response": [
            {
              "name": "200 Removed.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-domains/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-domains",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the sending domain."
                    }
                  ]
                },
                "description": "Stops sending from this domain. The DNS records remain (they do no harm); the domain itself stays connected.\n\nScope: sites:write"
              },
              "status": "Removed.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a\",\n  \"deleted\": true\n}"
            }
          ]
        },
        {
          "name": "Check a sending domain now",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-domains/:id/verify",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-domains",
                ":id",
                "verify"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the sending domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:write"
          },
          "response": [
            {
              "name": "200 The sending domain after the check.",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-domains/:id/verify",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-domains",
                    ":id",
                    "verify"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the sending domain."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:write"
              },
              "status": "The sending domain after the check.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a\",\n  \"domain\": \"acmeplumbing.com\",\n  \"status\": \"verified\",\n  \"test\": false,\n  \"dns\": \"managed\",\n  \"records\": [\n    {\n      \"type\": \"MX\",\n      \"name\": \"send.acmeplumbing.com\",\n      \"value\": \"feedback-smtp.example.net\",\n      \"priority\": 10,\n      \"state\": \"set\"\n    },\n    {\n      \"type\": \"TXT\",\n      \"name\": \"send.acmeplumbing.com\",\n      \"value\": \"v=spf1 include:spf.example.net ~all\",\n      \"state\": \"set\"\n    },\n    {\n      \"type\": \"TXT\",\n      \"name\": \"mail._domainkey.acmeplumbing.com\",\n      \"value\": \"p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...\",\n      \"state\": \"set\"\n    }\n  ],\n  \"dmarc_suggestion\": {\n    \"type\": \"TXT\",\n    \"name\": \"_dmarc.acmeplumbing.com\",\n    \"value\": \"v=DMARC1; p=none;\"\n  },\n  \"next_step\": \"The records are set. Call verify_email_domain in a few minutes; verification usually takes minutes, sometimes up to a few hours.\",\n  \"checked_at\": \"2026-10-06T10:05:00.000Z\",\n  \"verified_at\": \"2026-10-06T10:05:00.000Z\",\n  \"created_at\": \"2026-10-06T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Confirm a recipient (owner only)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-recipients",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-recipients"
              ]
            },
            "description": "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.\n\nScope: messages:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"sam@example.org\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 Confirmed.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-recipients",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-recipients"
                  ]
                },
                "description": "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.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"email\": \"sam@example.org\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Confirmed.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"email\": \"sam@example.org\",\n  \"allowed\": true\n}"
            }
          ]
        },
        {
          "name": "Check email addresses",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-recipients/check",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-recipients",
                "check"
              ]
            },
            "description": "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.\n\nScope: messages:read",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"emails\": [\n    \"sam@example.org\",\n    \"kim@gmial.com\"\n  ]\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 One result per address, in the order sent.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-recipients/check",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-recipients",
                    "check"
                  ]
                },
                "description": "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.\n\nScope: messages:read",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"emails\": [\n    \"sam@example.org\",\n    \"kim@gmial.com\"\n  ]\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "One result per address, in the order sent.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"email\": \"sam@example.org\",\n      \"result\": \"valid\",\n      \"code\": \"ok\",\n      \"reason\": \"The address looks deliverable: its domain accepts email.\",\n      \"suggestion\": null,\n      \"would_send\": {\n        \"transactional\": true,\n        \"other\": true\n      }\n    },\n    {\n      \"email\": \"kim@gmial.com\",\n      \"result\": \"invalid\",\n      \"code\": \"likely_typo\",\n      \"reason\": \"This looks like a typo in the domain. Did you mean kim@gmail.com?\",\n      \"suggestion\": \"kim@gmail.com\",\n      \"would_send\": {\n        \"transactional\": false,\n        \"other\": false\n      }\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Get delivery per sending domain",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-deliverability",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-deliverability"
              ]
            },
            "description": "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.\n\nScope: messages:read"
          },
          "response": [
            {
              "name": "200 Delivery per sending domain.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-deliverability",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-deliverability"
                  ]
                },
                "description": "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.\n\nScope: messages:read"
              },
              "status": "Delivery per sending domain.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"test\": false,\n  \"thresholds\": {\n    \"bounce_rate\": 0.02,\n    \"complaint_rate\": 0.001\n  },\n  \"opens_measured\": false,\n  \"domains\": [\n    {\n      \"email_domain_id\": \"8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a\",\n      \"domain\": \"acmeplumbing.com\",\n      \"status\": \"verified\",\n      \"authentication\": {\n        \"spf\": \"pass\",\n        \"dkim\": \"pass\",\n        \"dmarc\": {\n          \"record\": \"v=DMARC1; p=none;\",\n          \"policy\": \"none\",\n          \"status\": \"monitoring\"\n        }\n      },\n      \"last_7_days\": {\n        \"sent\": 120,\n        \"delivered\": 119,\n        \"bounced\": 1,\n        \"complained\": 0,\n        \"opened\": null,\n        \"bounce_rate\": 0.0083,\n        \"complaint_rate\": 0\n      },\n      \"last_30_days\": {\n        \"sent\": 480,\n        \"delivered\": 476,\n        \"bounced\": 4,\n        \"complained\": 0,\n        \"opened\": null,\n        \"bounce_rate\": 0.0083,\n        \"complaint_rate\": 0\n      },\n      \"health\": \"good\",\n      \"advice\": [\n        \"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.\"\n      ]\n    }\n  ],\n  \"dedicated_ip\": {\n    \"status\": \"none\",\n    \"requested_at\": null,\n    \"emails_last_30_days\": 480,\n    \"price\": {\n      \"month_eur_per_ip\": 49,\n      \"free\": false,\n      \"text\": \"€49 a month per IP, excluding VAT, once we have set it up.\"\n    },\n    \"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.\",\n    \"available\": true,\n    \"reason\": null\n  }\n}"
            }
          ]
        },
        {
          "name": "Get the inbox logo (BIMI) of a sending domain",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-domains/:id/bimi",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-domains",
                ":id",
                "bimi"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the sending domain."
                }
              ]
            },
            "description": "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.\n\nScope: sites:read"
          },
          "response": [
            {
              "name": "200 The logo settings of the domain.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-domains/:id/bimi",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-domains",
                    ":id",
                    "bimi"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the sending domain."
                    }
                  ]
                },
                "description": "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.\n\nScope: sites:read"
              },
              "status": "The logo settings of the domain.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"email_domain_id\": \"8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a\",\n  \"domain\": \"acmeplumbing.com\",\n  \"dmarc\": {\n    \"record\": \"v=DMARC1; p=quarantine; rua=mailto:dmarc@acmeplumbing.com\",\n    \"policy\": \"quarantine\",\n    \"percentage\": 100,\n    \"ok\": true\n  },\n  \"bimi\": {\n    \"name\": \"default._bimi.acmeplumbing.com\",\n    \"record\": \"v=BIMI1; l=https://acmeplumbing.com/logo.svg;\",\n    \"logo_url\": \"https://acmeplumbing.com/logo.svg\",\n    \"certificate_url\": null\n  },\n  \"ready\": true,\n  \"dns_managed\": true,\n  \"steps\": [\n    \"For Gmail and Apple Mail, get a mark certificate (VMC or CMC) for the logo and add its address as certificate_url.\"\n  ],\n  \"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.\"\n}"
            }
          ]
        },
        {
          "name": "Set the inbox logo (BIMI) of a sending domain",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-domains/:id/bimi",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-domains",
                ":id",
                "bimi"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the sending domain."
                }
              ]
            },
            "description": "Sets the BIMI record default._bimi.<domain> 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 <title>, 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.\n\nScope: sites:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"logo_url\": \"https://acmeplumbing.com/logo.svg\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The record is set (or record_to_add says what to add).",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-domains/:id/bimi",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-domains",
                    ":id",
                    "bimi"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the sending domain."
                    }
                  ]
                },
                "description": "Sets the BIMI record default._bimi.<domain> 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 <title>, 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.\n\nScope: sites:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"logo_url\": \"https://acmeplumbing.com/logo.svg\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The record is set (or record_to_add says what to add).",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"email_domain_id\": \"8d2f3e4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a\",\n  \"domain\": \"acmeplumbing.com\",\n  \"dmarc\": {\n    \"record\": \"v=DMARC1; p=quarantine; rua=mailto:dmarc@acmeplumbing.com\",\n    \"policy\": \"quarantine\",\n    \"percentage\": 100,\n    \"ok\": true\n  },\n  \"bimi\": {\n    \"name\": \"default._bimi.acmeplumbing.com\",\n    \"record\": \"v=BIMI1; l=https://acmeplumbing.com/logo.svg;\",\n    \"logo_url\": \"https://acmeplumbing.com/logo.svg\",\n    \"certificate_url\": null\n  },\n  \"ready\": true,\n  \"dns_managed\": true,\n  \"steps\": [\n    \"For Gmail and Apple Mail, get a mark certificate (VMC or CMC) for the logo and add its address as certificate_url.\"\n  ],\n  \"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.\",\n  \"record_to_add\": null\n}"
            }
          ]
        },
        {
          "name": "Request a dedicated sending IP",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-dedicated-ip",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-dedicated-ip"
              ]
            },
            "description": "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.\n\nScope: messages:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"note\": \"We send about 150,000 order emails a month.\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Already requested: the open request.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-dedicated-ip",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-dedicated-ip"
                  ]
                },
                "description": "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.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"note\": \"We send about 150,000 order emails a month.\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Already requested: the open request.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"status\": \"requested\",\n  \"requested_at\": \"2026-10-15T12:00:00.000Z\",\n  \"emails_last_30_days\": 142300,\n  \"price\": {\n    \"month_eur_per_ip\": 49,\n    \"free\": false,\n    \"text\": \"€49 a month per IP, excluding VAT, once we have set it up.\"\n  },\n  \"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.\",\n  \"available\": true,\n  \"reason\": null\n}"
            },
            {
              "name": "201 Requested.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-dedicated-ip",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-dedicated-ip"
                  ]
                },
                "description": "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.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"note\": \"We send about 150,000 order emails a month.\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Requested.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"status\": \"requested\",\n  \"requested_at\": \"2026-10-15T12:00:00.000Z\",\n  \"emails_last_30_days\": 142300,\n  \"price\": {\n    \"month_eur_per_ip\": 49,\n    \"free\": false,\n    \"text\": \"€49 a month per IP, excluding VAT, once we have set it up.\"\n  },\n  \"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.\",\n  \"available\": true,\n  \"reason\": null\n}"
            }
          ]
        },
        {
          "name": "List email templates",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-templates",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-templates"
              ]
            },
            "description": "The project's email templates, newest first, with their variables.\n\nScope: messages:read"
          },
          "response": [
            {
              "name": "200 The templates.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-templates",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-templates"
                  ]
                },
                "description": "The project's email templates, newest first, with their variables.\n\nScope: messages:read"
              },
              "status": "The templates.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n      \"name\": \"Welcome\",\n      \"subject\": \"Welcome, {{name|there}}\",\n      \"html\": \"<p>Hi {{name|there}},</p><p>Thanks for signing up. Reply to this email if you have a question.</p>\",\n      \"text\": null,\n      \"style\": \"site\",\n      \"site_id\": null,\n      \"variables\": [\n        \"name\"\n      ],\n      \"created_at\": \"2026-10-15T10:00:00.000Z\",\n      \"updated_at\": \"2026-10-15T10:00:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Create an email template",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-templates",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-templates"
              ]
            },
            "description": "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.\n\nScope: messages:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Welcome\",\n  \"subject\": \"Welcome, {{name|there}}\",\n  \"html\": \"<p>Hi {{name|there}},</p><p>Thanks for signing up.</p>\",\n  \"style\": \"site\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The template.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-templates",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-templates"
                  ]
                },
                "description": "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.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Welcome\",\n  \"subject\": \"Welcome, {{name|there}}\",\n  \"html\": \"<p>Hi {{name|there}},</p><p>Thanks for signing up.</p>\",\n  \"style\": \"site\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The template.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n  \"name\": \"Welcome\",\n  \"subject\": \"Welcome, {{name|there}}\",\n  \"html\": \"<p>Hi {{name|there}},</p><p>Thanks for signing up. Reply to this email if you have a question.</p>\",\n  \"text\": null,\n  \"style\": \"site\",\n  \"site_id\": null,\n  \"variables\": [\n    \"name\"\n  ],\n  \"created_at\": \"2026-10-15T10:00:00.000Z\",\n  \"updated_at\": \"2026-10-15T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Get an email template",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-templates/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-templates",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the template."
                }
              ]
            },
            "description": "Scope: messages:read"
          },
          "response": [
            {
              "name": "200 The template.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-templates/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-templates",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the template."
                    }
                  ]
                },
                "description": "Scope: messages:read"
              },
              "status": "The template.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n  \"name\": \"Welcome\",\n  \"subject\": \"Welcome, {{name|there}}\",\n  \"html\": \"<p>Hi {{name|there}},</p><p>Thanks for signing up. Reply to this email if you have a question.</p>\",\n  \"text\": null,\n  \"style\": \"site\",\n  \"site_id\": null,\n  \"variables\": [\n    \"name\"\n  ],\n  \"created_at\": \"2026-10-15T10:00:00.000Z\",\n  \"updated_at\": \"2026-10-15T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Update an email template",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-templates/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-templates",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the template."
                }
              ]
            },
            "description": "Changes only the fields you send. Emails that are scheduled with this template use the new version when they go out.\n\nScope: messages:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"subject\": \"Welcome to Acme, {{name|there}}\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The updated template.",
              "originalRequest": {
                "method": "PATCH",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-templates/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-templates",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the template."
                    }
                  ]
                },
                "description": "Changes only the fields you send. Emails that are scheduled with this template use the new version when they go out.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"subject\": \"Welcome to Acme, {{name|there}}\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The updated template.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n  \"name\": \"Welcome\",\n  \"subject\": \"Welcome, {{name|there}}\",\n  \"html\": \"<p>Hi {{name|there}},</p><p>Thanks for signing up. Reply to this email if you have a question.</p>\",\n  \"text\": null,\n  \"style\": \"site\",\n  \"site_id\": null,\n  \"variables\": [\n    \"name\"\n  ],\n  \"created_at\": \"2026-10-15T10:00:00.000Z\",\n  \"updated_at\": \"2026-10-15T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Delete an email template",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-templates/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-templates",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the template."
                }
              ]
            },
            "description": "Not possible while an automation, a sequence or a list uses it (conflict); change those first.\n\nScope: messages:write"
          },
          "response": [
            {
              "name": "200 Deleted.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-templates/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-templates",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the template."
                    }
                  ]
                },
                "description": "Not possible while an automation, a sequence or a list uses it (conflict); change those first.\n\nScope: messages:write"
              },
              "status": "Deleted.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n  \"deleted\": true\n}"
            }
          ]
        },
        {
          "name": "Preview an email template",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-templates/:id/preview",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-templates",
                ":id",
                "preview"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the template."
                }
              ]
            },
            "description": "The template filled with data, in the style of the site, without sending anything. missing_variables lists what has no value.\n\nScope: messages:read",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"data\": {\n    \"name\": \"Sam\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The subject, html and text as they would be sent.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-templates/:id/preview",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-templates",
                    ":id",
                    "preview"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the template."
                    }
                  ]
                },
                "description": "The template filled with data, in the style of the site, without sending anything. missing_variables lists what has no value.\n\nScope: messages:read",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"data\": {\n    \"name\": \"Sam\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The subject, html and text as they would be sent.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"subject\": \"Welcome, Sam\",\n  \"html\": \"<!doctype html>...\",\n  \"text\": \"Hi Sam,\\n\\nThanks for signing up.\",\n  \"missing_variables\": []\n}"
            }
          ]
        },
        {
          "name": "List email automations",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-automations",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-automations"
              ]
            },
            "description": "Scope: messages:read"
          },
          "response": [
            {
              "name": "200 The automations.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-automations",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-automations"
                  ]
                },
                "description": "Scope: messages:read"
              },
              "status": "The automations.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e\",\n      \"event\": \"form.submitted\",\n      \"template_id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n      \"sequence_id\": null,\n      \"to\": \"contact\",\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"reply_to\": \"office@acmeplumbing.com\",\n      \"category\": \"transactional\",\n      \"delay_minutes\": 0,\n      \"filter\": {\n        \"collection\": \"contact\"\n      },\n      \"enabled\": true,\n      \"created_at\": \"2026-10-15T10:00:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Create an email automation",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-automations",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-automations"
              ]
            },
            "description": "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.\n\nScope: messages:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"event\": \"form.submitted\",\n  \"template_id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"filter\": {\n    \"collection\": \"contact\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The automation.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-automations",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-automations"
                  ]
                },
                "description": "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.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"event\": \"form.submitted\",\n  \"template_id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"filter\": {\n    \"collection\": \"contact\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The automation.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e\",\n  \"event\": \"form.submitted\",\n  \"template_id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n  \"sequence_id\": null,\n  \"to\": \"contact\",\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"category\": \"transactional\",\n  \"delay_minutes\": 0,\n  \"filter\": {\n    \"collection\": \"contact\"\n  },\n  \"enabled\": true,\n  \"created_at\": \"2026-10-15T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Update an email automation",
          "request": {
            "method": "PATCH",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-automations/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-automations",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the automation."
                }
              ]
            },
            "description": "Changes only the fields you send, like enabled: false to pause it.\n\nScope: messages:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"enabled\": false\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The updated automation.",
              "originalRequest": {
                "method": "PATCH",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-automations/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-automations",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the automation."
                    }
                  ]
                },
                "description": "Changes only the fields you send, like enabled: false to pause it.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"enabled\": false\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The updated automation.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e\",\n  \"event\": \"form.submitted\",\n  \"template_id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n  \"sequence_id\": null,\n  \"to\": \"contact\",\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"reply_to\": \"office@acmeplumbing.com\",\n  \"category\": \"transactional\",\n  \"delay_minutes\": 0,\n  \"filter\": {\n    \"collection\": \"contact\"\n  },\n  \"enabled\": false,\n  \"created_at\": \"2026-10-15T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Delete an email automation",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-automations/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-automations",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the automation."
                }
              ]
            },
            "description": "Emails it already scheduled still go; cancel those with POST /scheduled-emails/{id}/cancel.\n\nScope: messages:write"
          },
          "response": [
            {
              "name": "200 Deleted.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-automations/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-automations",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the automation."
                    }
                  ]
                },
                "description": "Emails it already scheduled still go; cancel those with POST /scheduled-emails/{id}/cancel.\n\nScope: messages:write"
              },
              "status": "Deleted.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e\",\n  \"deleted\": true\n}"
            }
          ]
        },
        {
          "name": "List scheduled emails",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/scheduled-emails",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "scheduled-emails"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "25",
                  "description": "Per page, 1 to 100.",
                  "disabled": true
                },
                {
                  "key": "cursor",
                  "value": "",
                  "description": "The next_cursor of the previous page.",
                  "disabled": true
                },
                {
                  "key": "status",
                  "value": "scheduled",
                  "description": "Only this status.",
                  "disabled": true
                },
                {
                  "key": "source",
                  "value": "api",
                  "description": "Only this source.",
                  "disabled": true
                }
              ]
            },
            "description": "Emails that go later or went: send_at, automations, sequence steps, list sends and confirmation emails, newest first, with their status.\n\nScope: messages:read"
          },
          "response": [
            {
              "name": "200 The scheduled emails.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/scheduled-emails",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "scheduled-emails"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "25",
                      "description": "Per page, 1 to 100.",
                      "disabled": true
                    },
                    {
                      "key": "cursor",
                      "value": "",
                      "description": "The next_cursor of the previous page.",
                      "disabled": true
                    },
                    {
                      "key": "status",
                      "value": "scheduled",
                      "description": "Only this status.",
                      "disabled": true
                    },
                    {
                      "key": "source",
                      "value": "api",
                      "description": "Only this source.",
                      "disabled": true
                    }
                  ]
                },
                "description": "Emails that go later or went: send_at, automations, sequence steps, list sends and confirmation emails, newest first, with their status.\n\nScope: messages:read"
              },
              "status": "The scheduled emails.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"6c7d8e9f-0a1b-4c2d-9e3f-4a5b6c7d8e9f\",\n      \"source\": \"api\",\n      \"status\": \"scheduled\",\n      \"send_at\": \"2026-10-16T08:00:00.000Z\",\n      \"to\": \"sam@example.org\",\n      \"subject\": \"Your appointment tomorrow\",\n      \"template_id\": null,\n      \"email_id\": null,\n      \"reason\": null,\n      \"test\": false,\n      \"created_at\": \"2026-10-15T10:00:00.000Z\"\n    }\n  ],\n  \"next_cursor\": null\n}"
            }
          ]
        },
        {
          "name": "Get a scheduled email",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/scheduled-emails/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "scheduled-emails",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the scheduled email."
                }
              ]
            },
            "description": "Scope: messages:read"
          },
          "response": [
            {
              "name": "200 The scheduled email.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/scheduled-emails/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "scheduled-emails",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the scheduled email."
                    }
                  ]
                },
                "description": "Scope: messages:read"
              },
              "status": "The scheduled email.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6c7d8e9f-0a1b-4c2d-9e3f-4a5b6c7d8e9f\",\n  \"source\": \"api\",\n  \"status\": \"scheduled\",\n  \"send_at\": \"2026-10-16T08:00:00.000Z\",\n  \"to\": \"sam@example.org\",\n  \"subject\": \"Your appointment tomorrow\",\n  \"template_id\": null,\n  \"email_id\": null,\n  \"reason\": null,\n  \"test\": false,\n  \"created_at\": \"2026-10-15T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Cancel a scheduled email",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/scheduled-emails/:id/cancel",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "scheduled-emails",
                ":id",
                "cancel"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the scheduled email."
                }
              ]
            },
            "description": "An email that is still scheduled will not be sent. Once it is sent (or sending) it cannot be canceled (conflict).\n\nScope: messages:write"
          },
          "response": [
            {
              "name": "200 Canceled.",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/scheduled-emails/:id/cancel",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "scheduled-emails",
                    ":id",
                    "cancel"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the scheduled email."
                    }
                  ]
                },
                "description": "An email that is still scheduled will not be sent. Once it is sent (or sending) it cannot be canceled (conflict).\n\nScope: messages:write"
              },
              "status": "Canceled.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"6c7d8e9f-0a1b-4c2d-9e3f-4a5b6c7d8e9f\",\n  \"source\": \"api\",\n  \"status\": \"canceled\",\n  \"send_at\": \"2026-10-16T08:00:00.000Z\",\n  \"to\": \"sam@example.org\",\n  \"subject\": \"Your appointment tomorrow\",\n  \"template_id\": null,\n  \"email_id\": null,\n  \"reason\": \"canceled\",\n  \"test\": false,\n  \"created_at\": \"2026-10-15T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "List email sequences",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-sequences",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-sequences"
              ]
            },
            "description": "Scope: messages:read"
          },
          "response": [
            {
              "name": "200 The sequences, with how many people are in each now.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-sequences",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-sequences"
                  ]
                },
                "description": "Scope: messages:read"
              },
              "status": "The sequences, with how many people are in each now.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"data\": [\n    {\n      \"id\": \"7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a\",\n      \"name\": \"Onboarding\",\n      \"steps\": [\n        {\n          \"template_id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n          \"wait_minutes\": 0\n        },\n        {\n          \"template_id\": \"8e9f0a1b-2c3d-4e4f-9a5b-6c7d8e9f0a1b\",\n          \"wait_minutes\": 2880\n        }\n      ],\n      \"stop_on\": [\n        \"order.paid\"\n      ],\n      \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n      \"reply_to\": null,\n      \"category\": \"other\",\n      \"active\": 12,\n      \"created_at\": \"2026-10-15T10:00:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Create an email sequence",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-sequences",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-sequences"
              ]
            },
            "description": "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.\n\nScope: messages:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Onboarding\",\n  \"steps\": [\n    {\n      \"template_id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n      \"wait_minutes\": 0\n    },\n    {\n      \"template_id\": \"8e9f0a1b-2c3d-4e4f-9a5b-6c7d8e9f0a1b\",\n      \"wait_minutes\": 2880\n    }\n  ],\n  \"stop_on\": [\n    \"order.paid\"\n  ],\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The sequence.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-sequences",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-sequences"
                  ]
                },
                "description": "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.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Onboarding\",\n  \"steps\": [\n    {\n      \"template_id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n      \"wait_minutes\": 0\n    },\n    {\n      \"template_id\": \"8e9f0a1b-2c3d-4e4f-9a5b-6c7d8e9f0a1b\",\n      \"wait_minutes\": 2880\n    }\n  ],\n  \"stop_on\": [\n    \"order.paid\"\n  ],\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The sequence.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a\",\n  \"name\": \"Onboarding\",\n  \"steps\": [\n    {\n      \"template_id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n      \"wait_minutes\": 0\n    },\n    {\n      \"template_id\": \"8e9f0a1b-2c3d-4e4f-9a5b-6c7d8e9f0a1b\",\n      \"wait_minutes\": 2880\n    }\n  ],\n  \"stop_on\": [\n    \"order.paid\"\n  ],\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"reply_to\": null,\n  \"category\": \"other\",\n  \"active\": 0,\n  \"created_at\": \"2026-10-15T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Get an email sequence",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-sequences/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-sequences",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the sequence."
                }
              ]
            },
            "description": "The sequence with its last 100 enrollments.\n\nScope: messages:read"
          },
          "response": [
            {
              "name": "200 The sequence.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-sequences/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-sequences",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the sequence."
                    }
                  ]
                },
                "description": "The sequence with its last 100 enrollments.\n\nScope: messages:read"
              },
              "status": "The sequence.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a\",\n  \"name\": \"Onboarding\",\n  \"steps\": [\n    {\n      \"template_id\": \"3f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b\",\n      \"wait_minutes\": 0\n    },\n    {\n      \"template_id\": \"8e9f0a1b-2c3d-4e4f-9a5b-6c7d8e9f0a1b\",\n      \"wait_minutes\": 2880\n    }\n  ],\n  \"stop_on\": [\n    \"order.paid\"\n  ],\n  \"from\": \"Acme Plumbing <hello@acmeplumbing.com>\",\n  \"reply_to\": null,\n  \"category\": \"other\",\n  \"active\": 12,\n  \"created_at\": \"2026-10-15T10:00:00.000Z\",\n  \"enrollments\": [\n    {\n      \"id\": \"9f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n      \"sequence_id\": \"7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a\",\n      \"email\": \"sam@example.org\",\n      \"status\": \"active\",\n      \"step\": 0,\n      \"next_send_at\": \"2026-10-15T10:00:00.000Z\",\n      \"stop_reason\": null,\n      \"created_at\": \"2026-10-15T10:00:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Delete an email sequence",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/email-sequences/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-sequences",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the sequence."
                }
              ]
            },
            "description": "Everyone in it stops; steps that were scheduled are not sent.\n\nScope: messages:write"
          },
          "response": [
            {
              "name": "200 Deleted.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/email-sequences/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-sequences",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the sequence."
                    }
                  ]
                },
                "description": "Everyone in it stops; steps that were scheduled are not sent.\n\nScope: messages:write"
              },
              "status": "Deleted.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a\",\n  \"deleted\": true\n}"
            }
          ]
        },
        {
          "name": "Add someone to an email sequence",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-sequences/:id/enrollments",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-sequences",
                ":id",
                "enrollments"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the sequence."
                }
              ]
            },
            "description": "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).\n\nScope: messages:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"sam@example.org\",\n  \"data\": {\n    \"name\": \"Sam\"\n  }\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Already in the sequence.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-sequences/:id/enrollments",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-sequences",
                    ":id",
                    "enrollments"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the sequence."
                    }
                  ]
                },
                "description": "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).\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"email\": \"sam@example.org\",\n  \"data\": {\n    \"name\": \"Sam\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Already in the sequence.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"9f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n  \"sequence_id\": \"7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a\",\n  \"email\": \"sam@example.org\",\n  \"status\": \"active\",\n  \"step\": 0,\n  \"next_send_at\": \"2026-10-15T10:00:00.000Z\",\n  \"stop_reason\": null,\n  \"created_at\": \"2026-10-15T10:00:00.000Z\"\n}"
            },
            {
              "name": "201 Added; the first step goes after its wait.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-sequences/:id/enrollments",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-sequences",
                    ":id",
                    "enrollments"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the sequence."
                    }
                  ]
                },
                "description": "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).\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"email\": \"sam@example.org\",\n  \"data\": {\n    \"name\": \"Sam\"\n  }\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Added; the first step goes after its wait.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"9f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n  \"sequence_id\": \"7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a\",\n  \"email\": \"sam@example.org\",\n  \"status\": \"active\",\n  \"step\": 0,\n  \"next_send_at\": \"2026-10-15T10:00:00.000Z\",\n  \"stop_reason\": null,\n  \"created_at\": \"2026-10-15T10:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Stop an email sequence for someone",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/email-sequences/:id/stop",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "email-sequences",
                ":id",
                "stop"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the sequence."
                }
              ]
            },
            "description": "For example when the person replied. Steps that were scheduled for them are not sent.\n\nScope: messages:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"sam@example.org\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 Stopped.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/email-sequences/:id/stop",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "email-sequences",
                    ":id",
                    "stop"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the sequence."
                    }
                  ]
                },
                "description": "For example when the person replied. Steps that were scheduled for them are not sent.\n\nScope: messages:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"email\": \"sam@example.org\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "Stopped.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"id\": \"9f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n  \"sequence_id\": \"7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a\",\n  \"email\": \"sam@example.org\",\n  \"status\": \"stopped\",\n  \"step\": 0,\n  \"next_send_at\": null,\n  \"stop_reason\": \"stopped\",\n  \"created_at\": \"2026-10-15T10:00:00.000Z\"\n}"
            }
          ]
        }
      ]
    },
    {
      "name": "Agencies",
      "description": "For agencies that build for clients: client projects, access requests, owner invitations, who pays, budgets, usage, invoice lines and activity per client, and payment links and subscriptions on your own payment account for a client. Work inside a client project with the Hypamoon-Project header on any other route. For a client: which agencies have access, and revoking it.",
      "item": [
        {
          "name": "List your client projects",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/clients",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients"
              ],
              "query": [
                {
                  "key": "all",
                  "value": "false",
                  "description": "Also include declined and ended relations.",
                  "disabled": true
                }
              ]
            },
            "description": "The client projects of your agency: pending access requests and active relations, newest first. With all=true also declined and ended ones.\n\nScope: clients:read"
          },
          "response": [
            {
              "name": "200 The client relations.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/clients",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients"
                  ],
                  "query": [
                    {
                      "key": "all",
                      "value": "false",
                      "description": "Also include declined and ended relations.",
                      "disabled": true
                    }
                  ]
                },
                "description": "The client projects of your agency: pending access requests and active relations, newest first. With all=true also declined and ended ones.\n\nScope: clients:read"
              },
              "status": "The client relations.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"list\",\n  \"data\": [\n    {\n      \"object\": \"client_relation\",\n      \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n      \"agency\": {\n        \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n        \"name\": \"Studio North\"\n      },\n      \"client\": {\n        \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n        \"name\": \"Bakery Jansen\"\n      },\n      \"status\": \"active\",\n      \"stage\": \"building\",\n      \"role\": \"builder\",\n      \"max_mode\": \"test\",\n      \"four_eyes\": false,\n      \"payer\": \"client\",\n      \"budget_eur\": null,\n      \"payer_change\": null,\n      \"owner_invite\": null,\n      \"confirmed_at\": null,\n      \"ended_at\": null,\n      \"created_at\": \"2026-10-12T09:00:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Set up a project for a client",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/clients",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients"
              ]
            },
            "description": "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.\n\nScope: clients:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Bakery Jansen\",\n  \"payer\": \"agency\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "201 The new client project and its relation.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/clients",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients"
                  ]
                },
                "description": "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.\n\nScope: clients:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Bakery Jansen\",\n  \"payer\": \"agency\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The new client project and its relation.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_relation\",\n  \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n  \"agency\": {\n    \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n    \"name\": \"Studio North\"\n  },\n  \"client\": {\n    \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n    \"name\": \"Bakery Jansen\"\n  },\n  \"status\": \"active\",\n  \"stage\": \"building\",\n  \"role\": \"builder\",\n  \"max_mode\": \"test\",\n  \"four_eyes\": false,\n  \"payer\": \"client\",\n  \"budget_eur\": null,\n  \"payer_change\": null,\n  \"owner_invite\": null,\n  \"confirmed_at\": null,\n  \"ended_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\",\n  \"project\": {\n    \"id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n    \"name\": \"Bakery Jansen\"\n  }\n}"
            }
          ]
        },
        {
          "name": "Ask a client for access to their project",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/clients/access-requests",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                "access-requests"
              ]
            },
            "description": "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).\n\nScope: clients:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n  \"message\": \"As discussed on the phone.\",\n  \"payer\": \"agency\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The open request or active relation that already existed.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/clients/access-requests",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    "access-requests"
                  ]
                },
                "description": "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).\n\nScope: clients:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n  \"message\": \"As discussed on the phone.\",\n  \"payer\": \"agency\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The open request or active relation that already existed.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_relation\",\n  \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n  \"agency\": {\n    \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n    \"name\": \"Studio North\"\n  },\n  \"client\": {\n    \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n    \"name\": \"Bakery Jansen\"\n  },\n  \"status\": \"active\",\n  \"stage\": \"client_owned\",\n  \"role\": \"builder\",\n  \"max_mode\": \"test\",\n  \"four_eyes\": false,\n  \"payer\": \"client\",\n  \"budget_eur\": null,\n  \"payer_change\": null,\n  \"owner_invite\": null,\n  \"confirmed_at\": null,\n  \"ended_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\",\n  \"confirm_url\": \"https://app.hypamoon.com/agencies?request=6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\"\n}"
            },
            {
              "name": "201 The new request.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/clients/access-requests",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    "access-requests"
                  ]
                },
                "description": "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).\n\nScope: clients:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n  \"message\": \"As discussed on the phone.\",\n  \"payer\": \"agency\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The new request.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_relation\",\n  \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n  \"agency\": {\n    \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n    \"name\": \"Studio North\"\n  },\n  \"client\": {\n    \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n    \"name\": \"Bakery Jansen\"\n  },\n  \"status\": \"pending\",\n  \"stage\": \"client_owned\",\n  \"role\": \"builder\",\n  \"max_mode\": \"test\",\n  \"four_eyes\": false,\n  \"payer\": \"client\",\n  \"budget_eur\": null,\n  \"payer_change\": null,\n  \"owner_invite\": null,\n  \"confirmed_at\": null,\n  \"ended_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\",\n  \"confirm_url\": \"https://app.hypamoon.com/agencies?request=6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\"\n}"
            }
          ]
        },
        {
          "name": "Invite the client to own the project",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/owner-invite",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "owner-invite"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "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.\n\nScope: clients:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"owner@example.org\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The relation with the open invitation.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/owner-invite",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "owner-invite"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"email\": \"owner@example.org\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The relation with the open invitation.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_relation\",\n  \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n  \"agency\": {\n    \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n    \"name\": \"Studio North\"\n  },\n  \"client\": {\n    \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n    \"name\": \"Bakery Jansen\"\n  },\n  \"status\": \"active\",\n  \"stage\": \"building\",\n  \"role\": \"builder\",\n  \"max_mode\": \"test\",\n  \"four_eyes\": false,\n  \"payer\": \"client\",\n  \"budget_eur\": null,\n  \"payer_change\": null,\n  \"owner_invite\": {\n    \"email\": \"owner@example.org\",\n    \"expires_at\": \"2026-10-26T09:00:00.000Z\",\n    \"agency_keeps_access\": true\n  },\n  \"confirmed_at\": null,\n  \"ended_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Stop working on a client project",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/clients/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "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.\n\nScope: clients:write"
          },
          "response": [
            {
              "name": "200 The ended relation.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:write"
              },
              "status": "The ended relation.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_relation\",\n  \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n  \"agency\": {\n    \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n    \"name\": \"Studio North\"\n  },\n  \"client\": {\n    \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n    \"name\": \"Bakery Jansen\"\n  },\n  \"status\": \"ended\",\n  \"stage\": \"building\",\n  \"role\": \"builder\",\n  \"max_mode\": \"test\",\n  \"four_eyes\": false,\n  \"payer\": \"client\",\n  \"budget_eur\": null,\n  \"payer_change\": null,\n  \"owner_invite\": null,\n  \"confirmed_at\": null,\n  \"ended_at\": \"2026-10-20T09:00:00.000Z\",\n  \"created_at\": \"2026-10-12T09:00:00.000Z\",\n  \"payer_stopped\": false,\n  \"open_payments\": []\n}"
            }
          ]
        },
        {
          "name": "Hand over a client project",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/transfer",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "transfer"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "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.\n\nScope: clients:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"to_agency_project_id\": \"5c4b3a29-1807-4f6e-8d5c-4b3a29180706\",\n  \"message\": \"As agreed, you take over the build.\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "202 The handover waits for the receiving side.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/transfer",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "transfer"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"to_agency_project_id\": \"5c4b3a29-1807-4f6e-8d5c-4b3a29180706\",\n  \"message\": \"As agreed, you take over the build.\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The handover waits for the receiving side.",
              "code": 202,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_relation\",\n  \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n  \"agency\": {\n    \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n    \"name\": \"Studio North\"\n  },\n  \"client\": {\n    \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n    \"name\": \"Bakery Jansen\"\n  },\n  \"status\": \"active\",\n  \"stage\": \"building\",\n  \"role\": \"builder\",\n  \"max_mode\": \"test\",\n  \"four_eyes\": false,\n  \"payer\": \"client\",\n  \"budget_eur\": null,\n  \"payer_change\": null,\n  \"owner_invite\": null,\n  \"confirmed_at\": null,\n  \"ended_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\",\n  \"transfer\": {\n    \"direction\": \"outgoing\",\n    \"agency\": {\n      \"project_id\": \"5c4b3a29-1807-4f6e-8d5c-4b3a29180706\",\n      \"name\": \"Studio South\"\n    },\n    \"relation_id\": \"7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918\",\n    \"expires_at\": \"2026-10-26T09:00:00.000Z\"\n  },\n  \"open_payments\": []\n}"
            }
          ]
        },
        {
          "name": "Cancel a handover",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/transfer",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "transfer"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "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.\n\nScope: clients:write"
          },
          "response": [
            {
              "name": "200 The relation, without the open handover.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/transfer",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "transfer"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:write"
              },
              "status": "The relation, without the open handover.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_relation\",\n  \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n  \"agency\": {\n    \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n    \"name\": \"Studio North\"\n  },\n  \"client\": {\n    \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n    \"name\": \"Bakery Jansen\"\n  },\n  \"status\": \"active\",\n  \"stage\": \"building\",\n  \"role\": \"builder\",\n  \"max_mode\": \"test\",\n  \"four_eyes\": false,\n  \"payer\": \"client\",\n  \"budget_eur\": null,\n  \"payer_change\": null,\n  \"owner_invite\": null,\n  \"confirmed_at\": null,\n  \"ended_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\",\n  \"canceled\": 1\n}"
            }
          ]
        },
        {
          "name": "Restore an ended client project",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/restore",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "restore"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "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.\n\nScope: clients:write"
          },
          "response": [
            {
              "name": "200 The new relation.",
              "originalRequest": {
                "method": "POST",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/restore",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "restore"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:write"
              },
              "status": "The new relation.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_relation\",\n  \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n  \"agency\": {\n    \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n    \"name\": \"Studio North\"\n  },\n  \"client\": {\n    \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n    \"name\": \"Bakery Jansen\"\n  },\n  \"status\": \"active\",\n  \"stage\": \"building\",\n  \"role\": \"builder\",\n  \"max_mode\": \"test\",\n  \"four_eyes\": false,\n  \"payer\": \"client\",\n  \"budget_eur\": null,\n  \"payer_change\": null,\n  \"owner_invite\": null,\n  \"confirmed_at\": null,\n  \"ended_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Get the usage of a client project",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/usage",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "usage"
              ],
              "query": [
                {
                  "key": "months",
                  "value": "3",
                  "description": "How many months back, the current month included (at most 12).",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "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.\n\nScope: clients:read"
          },
          "response": [
            {
              "name": "200 Usage per month.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/usage",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "usage"
                  ],
                  "query": [
                    {
                      "key": "months",
                      "value": "3",
                      "description": "How many months back, the current month included (at most 12).",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:read"
              },
              "status": "Usage per month.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_usage\",\n  \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n  \"currency\": \"EUR\",\n  \"payer\": \"agency\",\n  \"budget_eur\": 50,\n  \"months\": [\n    {\n      \"month\": \"2026-10\",\n      \"cost_eur\": 4.2,\n      \"cost_by_source\": {\n        \"calls\": 3.9,\n        \"sms\": 0.3\n      },\n      \"cost_by_payer\": {\n        \"client\": 0,\n        \"agency\": 4.2\n      },\n      \"cost_by_payer_source\": {\n        \"client\": {},\n        \"agency\": {\n          \"calls\": 3.9,\n          \"sms\": 0.3\n        }\n      },\n      \"top_ups_eur\": 0,\n      \"app_requests\": 1830,\n      \"app_errors\": 2,\n      \"app_cpu_ms\": 51200\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Change who pays for a client project",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/payer",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "payer"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "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.\n\nScope: clients:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"payer\": \"agency\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The relation, with payer_change while it waits for the client.",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/payer",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "payer"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"payer\": \"agency\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The relation, with payer_change while it waits for the client.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_relation\",\n  \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n  \"agency\": {\n    \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n    \"name\": \"Studio North\"\n  },\n  \"client\": {\n    \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n    \"name\": \"Bakery Jansen\"\n  },\n  \"status\": \"active\",\n  \"stage\": \"client_owned\",\n  \"role\": \"builder\",\n  \"max_mode\": \"test\",\n  \"four_eyes\": false,\n  \"payer\": \"client\",\n  \"budget_eur\": null,\n  \"payer_change\": {\n    \"to\": \"agency\",\n    \"proposed_by\": \"agency\",\n    \"proposed_at\": \"2026-10-12T09:00:00.000Z\"\n  },\n  \"owner_invite\": null,\n  \"confirmed_at\": null,\n  \"ended_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\",\n  \"applied\": false\n}"
            }
          ]
        },
        {
          "name": "Set the monthly budget for a client project",
          "request": {
            "method": "PUT",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/budget",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "budget"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "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.\n\nScope: clients:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"monthly_budget_eur\": 50\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The relation with the budget.",
              "originalRequest": {
                "method": "PUT",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/budget",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "budget"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"monthly_budget_eur\": 50\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The relation with the budget.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_relation\",\n  \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n  \"agency\": {\n    \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n    \"name\": \"Studio North\"\n  },\n  \"client\": {\n    \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n    \"name\": \"Bakery Jansen\"\n  },\n  \"status\": \"active\",\n  \"stage\": \"building\",\n  \"role\": \"builder\",\n  \"max_mode\": \"test\",\n  \"four_eyes\": false,\n  \"payer\": \"agency\",\n  \"budget_eur\": 50,\n  \"payer_change\": null,\n  \"owner_invite\": null,\n  \"confirmed_at\": null,\n  \"ended_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Export invoice lines for a client project",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/invoice-lines",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "invoice-lines"
              ],
              "query": [
                {
                  "key": "months",
                  "value": "1",
                  "description": "How many months back, the current month included.",
                  "disabled": true
                },
                {
                  "key": "payer",
                  "value": "all",
                  "description": "Only the lines your agency paid, only those the client paid, or all.",
                  "disabled": true
                },
                {
                  "key": "format",
                  "value": "json",
                  "description": "json, or csv for a spreadsheet or import.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "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.\n\nScope: clients:read"
          },
          "response": [
            {
              "name": "200 The invoice lines.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/invoice-lines",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "invoice-lines"
                  ],
                  "query": [
                    {
                      "key": "months",
                      "value": "1",
                      "description": "How many months back, the current month included.",
                      "disabled": true
                    },
                    {
                      "key": "payer",
                      "value": "all",
                      "description": "Only the lines your agency paid, only those the client paid, or all.",
                      "disabled": true
                    },
                    {
                      "key": "format",
                      "value": "json",
                      "description": "json, or csv for a spreadsheet or import.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:read"
              },
              "status": "The invoice lines.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"list\",\n  \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n  \"currency\": \"EUR\",\n  \"months\": [\n    \"2026-10\"\n  ],\n  \"total_eur\": 4.2,\n  \"data\": [\n    {\n      \"month\": \"2026-10\",\n      \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n      \"project_name\": \"Bakery Jansen\",\n      \"payer\": \"agency\",\n      \"source\": \"calls\",\n      \"description\": \"Calls, Bakery Jansen, 2026-10\",\n      \"amount_eur\": 3.9,\n      \"currency\": \"EUR\"\n    },\n    {\n      \"month\": \"2026-10\",\n      \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n      \"project_name\": \"Bakery Jansen\",\n      \"payer\": \"agency\",\n      \"source\": \"sms\",\n      \"description\": \"Text messages, Bakery Jansen, 2026-10\",\n      \"amount_eur\": 0.3,\n      \"currency\": \"EUR\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Create a payment link for a client",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/payment-links",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "payment-links"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "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.\n\nScope: clients:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount_cents\": 12100,\n  \"currency\": \"EUR\",\n  \"description\": \"Website maintenance October\",\n  \"redirect_url\": \"https://portal.studionorth.example/paid\",\n  \"idempotency_key\": \"inv-2026-10-bakery\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The payment link made earlier with this idempotency_key.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/payment-links",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "payment-links"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"amount_cents\": 12100,\n  \"currency\": \"EUR\",\n  \"description\": \"Website maintenance October\",\n  \"redirect_url\": \"https://portal.studionorth.example/paid\",\n  \"idempotency_key\": \"inv-2026-10-bakery\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The payment link made earlier with this idempotency_key.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_payment_link\",\n  \"id\": \"0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6\",\n  \"client_project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n  \"status\": \"open\",\n  \"amount_cents\": 12100,\n  \"fee_cents\": 131,\n  \"currency\": \"EUR\",\n  \"description\": \"Website maintenance October\",\n  \"test\": true,\n  \"checkout_url\": \"https://checkout.example/pay/7UhSN1zuXS\",\n  \"paid_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\"\n}"
            },
            {
              "name": "201 The payment link.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/payment-links",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "payment-links"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"amount_cents\": 12100,\n  \"currency\": \"EUR\",\n  \"description\": \"Website maintenance October\",\n  \"redirect_url\": \"https://portal.studionorth.example/paid\",\n  \"idempotency_key\": \"inv-2026-10-bakery\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The payment link.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_payment_link\",\n  \"id\": \"0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6\",\n  \"client_project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n  \"status\": \"open\",\n  \"amount_cents\": 12100,\n  \"fee_cents\": 131,\n  \"currency\": \"EUR\",\n  \"description\": \"Website maintenance October\",\n  \"test\": true,\n  \"checkout_url\": \"https://checkout.example/pay/7UhSN1zuXS\",\n  \"paid_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Create a subscription for a client",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/subscriptions",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "subscriptions"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "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.\n\nScope: clients:write",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"amount_cents\": 4900,\n  \"currency\": \"EUR\",\n  \"interval\": \"month\",\n  \"description\": \"Hosting and support\",\n  \"email\": \"owner@example.org\",\n  \"name\": \"Bakery Jansen\",\n  \"redirect_url\": \"https://portal.studionorth.example/subscribed\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            }
          },
          "response": [
            {
              "name": "200 The subscription made earlier with this idempotency_key.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/subscriptions",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "subscriptions"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"amount_cents\": 4900,\n  \"currency\": \"EUR\",\n  \"interval\": \"month\",\n  \"description\": \"Hosting and support\",\n  \"email\": \"owner@example.org\",\n  \"name\": \"Bakery Jansen\",\n  \"redirect_url\": \"https://portal.studionorth.example/subscribed\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The subscription made earlier with this idempotency_key.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_subscription\",\n  \"id\": \"0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6\",\n  \"client_project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n  \"status\": \"open\",\n  \"amount_cents\": 12100,\n  \"fee_cents\": 131,\n  \"currency\": \"EUR\",\n  \"description\": \"Website maintenance October\",\n  \"test\": true,\n  \"checkout_url\": \"https://checkout.example/pay/7UhSN1zuXS\",\n  \"paid_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\",\n  \"interval\": \"month\",\n  \"paid_until\": null,\n  \"canceled_at\": null\n}"
            },
            {
              "name": "201 The subscription, open until the first payment.",
              "originalRequest": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/subscriptions",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "subscriptions"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:write",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"amount_cents\": 4900,\n  \"currency\": \"EUR\",\n  \"interval\": \"month\",\n  \"description\": \"Hosting and support\",\n  \"email\": \"owner@example.org\",\n  \"name\": \"Bakery Jansen\",\n  \"redirect_url\": \"https://portal.studionorth.example/subscribed\"\n}",
                  "options": {
                    "raw": {
                      "language": "json"
                    }
                  }
                }
              },
              "status": "The subscription, open until the first payment.",
              "code": 201,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_subscription\",\n  \"id\": \"0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6\",\n  \"client_project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n  \"status\": \"open\",\n  \"amount_cents\": 4900,\n  \"fee_cents\": 59,\n  \"currency\": \"EUR\",\n  \"description\": \"Hosting and support\",\n  \"test\": true,\n  \"checkout_url\": \"https://checkout.example/pay/7UhSN1zuXS\",\n  \"paid_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\",\n  \"interval\": \"month\",\n  \"paid_until\": null,\n  \"canceled_at\": null\n}"
            }
          ]
        },
        {
          "name": "List payment links and subscriptions for a client",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/payments",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "payments"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50",
                  "description": "How many.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "The payment links and subscriptions your agency made for this client project, newest first. Without the payer's email or name.\n\nScope: clients:read"
          },
          "response": [
            {
              "name": "200 The payments.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/payments",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "payments"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "50",
                      "description": "How many.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "The payment links and subscriptions your agency made for this client project, newest first. Without the payer's email or name.\n\nScope: clients:read"
              },
              "status": "The payments.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"list\",\n  \"data\": [\n    {\n      \"object\": \"client_payment_link\",\n      \"id\": \"0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6\",\n      \"client_project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n      \"status\": \"open\",\n      \"amount_cents\": 12100,\n      \"fee_cents\": 131,\n      \"currency\": \"EUR\",\n      \"description\": \"Website maintenance October\",\n      \"test\": true,\n      \"checkout_url\": \"https://checkout.example/pay/7UhSN1zuXS\",\n      \"paid_at\": null,\n      \"created_at\": \"2026-10-12T09:00:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Cancel a client subscription",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/subscriptions/:subscriptionId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "subscriptions",
                ":subscriptionId"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                },
                {
                  "key": "subscriptionId",
                  "value": "",
                  "description": "The id of the subscription."
                }
              ]
            },
            "description": "Stops a subscription at your payment provider right away: no more charges. What was paid stays paid; nothing is refunded.\n\nScope: clients:write"
          },
          "response": [
            {
              "name": "200 The canceled subscription.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/subscriptions/:subscriptionId",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "subscriptions",
                    ":subscriptionId"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    },
                    {
                      "key": "subscriptionId",
                      "value": "",
                      "description": "The id of the subscription."
                    }
                  ]
                },
                "description": "Stops a subscription at your payment provider right away: no more charges. What was paid stays paid; nothing is refunded.\n\nScope: clients:write"
              },
              "status": "The canceled subscription.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_subscription\",\n  \"id\": \"0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6\",\n  \"client_project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n  \"status\": \"canceled\",\n  \"amount_cents\": 12100,\n  \"fee_cents\": 131,\n  \"currency\": \"EUR\",\n  \"description\": \"Website maintenance October\",\n  \"test\": true,\n  \"checkout_url\": null,\n  \"paid_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\",\n  \"interval\": \"month\",\n  \"paid_until\": \"2026-11-12T09:00:00.000Z\",\n  \"canceled_at\": \"2026-10-20T09:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Withdraw an open payment link",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/payment-links/:linkId",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "payment-links",
                ":linkId"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                },
                {
                  "key": "linkId",
                  "value": "",
                  "description": "The id of the payment link."
                }
              ]
            },
            "description": "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.\n\nScope: clients:write"
          },
          "response": [
            {
              "name": "200 The withdrawn payment link.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/payment-links/:linkId",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "payment-links",
                    ":linkId"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    },
                    {
                      "key": "linkId",
                      "value": "",
                      "description": "The id of the payment link."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:write"
              },
              "status": "The withdrawn payment link.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_payment_link\",\n  \"id\": \"0d1e2f3a-4b5c-4d6e-8f70-81a2b3c4d5e6\",\n  \"client_project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n  \"status\": \"expired\",\n  \"amount_cents\": 12100,\n  \"fee_cents\": 131,\n  \"currency\": \"EUR\",\n  \"description\": \"Website maintenance October\",\n  \"test\": true,\n  \"checkout_url\": null,\n  \"paid_at\": null,\n  \"created_at\": \"2026-10-12T09:00:00.000Z\"\n}"
            }
          ]
        },
        {
          "name": "Read the activity log of a client project",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/clients/:id/activity",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "clients",
                ":id",
                "activity"
              ],
              "query": [
                {
                  "key": "limit",
                  "value": "50",
                  "description": "How many entries.",
                  "disabled": true
                },
                {
                  "key": "before",
                  "value": "",
                  "description": "next_before from the previous page.",
                  "disabled": true
                }
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the client project."
                }
              ]
            },
            "description": "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.\n\nScope: clients:read"
          },
          "response": [
            {
              "name": "200 The log entries.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/clients/:id/activity",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "clients",
                    ":id",
                    "activity"
                  ],
                  "query": [
                    {
                      "key": "limit",
                      "value": "50",
                      "description": "How many entries.",
                      "disabled": true
                    },
                    {
                      "key": "before",
                      "value": "",
                      "description": "next_before from the previous page.",
                      "disabled": true
                    }
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the client project."
                    }
                  ]
                },
                "description": "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.\n\nScope: clients:read"
              },
              "status": "The log entries.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"list\",\n  \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n  \"data\": [],\n  \"next_before\": null\n}"
            }
          ]
        },
        {
          "name": "List the agencies on this project",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/agencies",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "agencies"
              ]
            },
            "description": "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.\n\nScope: project:read"
          },
          "response": [
            {
              "name": "200 The agency relations of this project.",
              "originalRequest": {
                "method": "GET",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/agencies",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "agencies"
                  ]
                },
                "description": "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.\n\nScope: project:read"
              },
              "status": "The agency relations of this project.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"list\",\n  \"data\": [\n    {\n      \"object\": \"client_relation\",\n      \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n      \"agency\": {\n        \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n        \"name\": \"Studio North\"\n      },\n      \"client\": {\n        \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n        \"name\": \"Bakery Jansen\"\n      },\n      \"status\": \"active\",\n      \"stage\": \"client_owned\",\n      \"role\": \"builder\",\n      \"max_mode\": \"test\",\n      \"four_eyes\": false,\n      \"payer\": \"client\",\n      \"budget_eur\": null,\n      \"payer_change\": null,\n      \"owner_invite\": null,\n      \"confirmed_at\": null,\n      \"ended_at\": null,\n      \"created_at\": \"2026-10-12T09:00:00.000Z\"\n    }\n  ]\n}"
            }
          ]
        },
        {
          "name": "Revoke an agency's access",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{baseUrl}}/agencies/:id",
              "host": [
                "{{baseUrl}}"
              ],
              "path": [
                "agencies",
                ":id"
              ],
              "variable": [
                {
                  "key": "id",
                  "value": "",
                  "description": "The id of the agency relation."
                }
              ]
            },
            "description": "For the owner of a client project: ends an agency's access right away, or declines its open request. What the agency built stays.\n\nScope: project:write"
          },
          "response": [
            {
              "name": "200 The ended relation.",
              "originalRequest": {
                "method": "DELETE",
                "header": [],
                "url": {
                  "raw": "{{baseUrl}}/agencies/:id",
                  "host": [
                    "{{baseUrl}}"
                  ],
                  "path": [
                    "agencies",
                    ":id"
                  ],
                  "variable": [
                    {
                      "key": "id",
                      "value": "",
                      "description": "The id of the agency relation."
                    }
                  ]
                },
                "description": "For the owner of a client project: ends an agency's access right away, or declines its open request. What the agency built stays.\n\nScope: project:write"
              },
              "status": "The ended relation.",
              "code": 200,
              "_postman_previewlanguage": "json",
              "header": [
                {
                  "key": "Content-Type",
                  "value": "application/json"
                }
              ],
              "body": "{\n  \"object\": \"client_relation\",\n  \"id\": \"6e5d4c3b-2a19-4f8e-9d7c-6b5a49382716\",\n  \"agency\": {\n    \"project_id\": \"3f2a1c4e-5b6d-4e7f-8a9b-0c1d2e3f4a5b\",\n    \"name\": \"Studio North\"\n  },\n  \"client\": {\n    \"project_id\": \"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\",\n    \"name\": \"Bakery Jansen\"\n  },\n  \"status\": \"ended\",\n  \"stage\": \"client_owned\",\n  \"role\": \"builder\",\n  \"max_mode\": \"test\",\n  \"four_eyes\": false,\n  \"payer\": \"client\",\n  \"budget_eur\": null,\n  \"payer_change\": null,\n  \"owner_invite\": null,\n  \"confirmed_at\": null,\n  \"ended_at\": \"2026-10-20T09:00:00.000Z\",\n  \"created_at\": \"2026-10-12T09:00:00.000Z\"\n}"
            }
          ]
        }
      ]
    }
  ]
}
