Skip to content
Hypamoon
API docs

Start for free. Sign up without a card and you'll get a test project and a test key right away. Once your company (or, for a personal account, you) is verified, you can create a live key in the portal.

Phone, SMS and chat

A phone number for your business that rings your team first and lets a voice AI pick up when nobody answers. This guide takes you from a test key to that number, step by step. By the end, you'll receive a webhook for every call and fetch its transcript and summary. The recipes that follow send SMS, have the assistant call someone who is expecting the call, and put the same assistant in a chat.

Where it works: numbers and calls are available in the Netherlands now. Other countries follow. Need another country? Join the waitlist for your country and we'll email you once when it opens.

Just starting? Begin with the Quickstart: it gets a site with a form live, from your chat or with code. Everything here works on the same project.

You start in test mode: with a test key everything works the same, but your number is a test number, calls are simulated, nothing is charged and no payment method is needed. When everything works, you switch to your live key (see Going live).

Every step shows curl and TypeScript (fetch, Node 18 or later). At the bottom is a single prompt you can give your coding agent to do all of it for you.

Conventions used below:

  • $HYPAMOON_API_KEY is your key. While you build, that is your test key (hm_test_...); in production it is your live key (hm_live_...). Nothing else in your code changes.
  • The base URL is https://api.hypamoon.com/api/v1.
  • Phone numbers in the examples are fictional (+447700900123, from the range Ofcom reserves for drama). Test numbers come from that same range, so they can never ring a real phone. A live Hypamoon number is shown masked, like +•••••••••42; use the real value the API returns.
  • Every error is application/problem+json with a type URL that explains the fix, for example https://hypamoon.com/docs/errors/company_details_missing. Every response carries an X-Request-Id; include it when you ask us for help.

Prefer clicking to typing? Import the Postman collection: every endpoint, one folder per topic, with the examples from this guide. Bruno imports the same file. Set the variable apiKey to your test key; baseUrl is already https://api.hypamoon.com/api/v1.

TypeScript
// Shared by all TypeScript snippets below.
const BASE = "https://api.hypamoon.com/api/v1";
const KEY = process.env.HYPAMOON_API_KEY!;

async function hm(path: string, init: RequestInit = {}) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${KEY}`,
      "Content-Type": "application/json",
      ...(init.headers ?? {}),
    },
  });
  const body = res.status === 204 ? null : await res.json();
  if (!res.ok) throw new Error(`${res.status} ${body?.type}: ${body?.detail}`);
  return body;
}

1. Get a test key

Sign up at https://app.hypamoon.com with your email address: free, no card. Right after you sign up, the portal shows your first test key once, for your test project. The Quickstart explains test and live projects.

A key has scopes. This guide needs seven: project:read, project:write, numbers:read, numbers:write, assistant:write, webhooks:write and calls:read. Your test key has every scope. A live key has the scopes you select when you create it; with a read-only key, steps 2 to 6 return 403 missing_scope. The recipes below need more: messages:write and messages:read for SMS, calls:write for an outbound call, and chat:write and chat:read for a chat.

Check your key:

curl https://api.hypamoon.com/api/v1/project \
  -H "Authorization: Bearer $HYPAMOON_API_KEY"
const project = await hm("/project");
// { id, name, edition: "api", mode: "test", live_project_id: "...", spending_limit_eur: null, numbers_count: 0, ... }

In test mode, verification in this response shows the verification of your live project, because that is what you will need to go live. The company details you set with your test key in step 2 are for the test project only, so verification can still list them as missing while the MCP tool get_project says company_details: done for the test project.

2. Set your company details (or your own)

A phone number is registered to a business or a person. You do not need a company to use Hypamoon: in test mode no registration or VAT number is ever needed, and a personal account never needs one.

For a business, the regulator needs your company name, your registration number (kvk_number for a company in the Netherlands, registration_number elsewhere), your address and a contact person with a phone number where they can be reached today:

curl -X PUT https://api.hypamoon.com/api/v1/project/company \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "company_name": "Acme Plumbing Ltd",
    "registration_number": "01234567",
    "address": {
      "street": "Main Street",
      "house_number": "40",
      "postal_code": "SW1A 1AA",
      "city": "London",
      "country": "GB"
    },
    "contact": { "name": "Jane Smith", "phone": "+447700900123" }
  }'
await hm("/project/company", {
  method: "PUT",
  body: JSON.stringify({
    company_name: "Acme Plumbing Ltd",
    registration_number: "01234567",
    address: { street: "Main Street", house_number: "40", postal_code: "SW1A 1AA", city: "London", country: "GB" },
    contact: { name: "Jane Smith", phone: "+447700900123" },
  }),
});

Without a company, send "account_type": "personal" with your own name and phone number as the contact, plus your address, and without company_name, kvk_number, registration_number or vat_number:

curl
curl -X PUT https://api.hypamoon.com/api/v1/project/company \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "account_type": "personal",
    "address": {
      "street": "Main Street",
      "house_number": "12",
      "postal_code": "12345",
      "city": "Springfield"
    },
    "contact": { "name": "Jane Smith", "phone": "+447700900123" }
  }'

Leave account_type out to keep the type the project already has (you chose it when you signed up). A personal account is available for an address in the EU and pays the VAT of its country. Phone numbers, calls and SMS need an address in the Netherlands: a personal account there can order a number with a local area code (step 3), elsewhere these return 422 country_not_supported with a user_message and a waitlist_url for the user's country. A national number needs a business. Outside the EU a personal account is not available: 422 validation_failed says so on address.country. A country we do not serve at all returns 422 country_not_supported.

The contact needs a first and a last name. A mistake comes back as 422 validation_failed with an errors list of { path, message }. In test mode the details are checked and saved the same way, but nothing is sent to the carrier.

3. Pick a number and claim it

Search first. What the search returns is what can be ordered right now: for now numbers in the Netherlands. Show users elsewhere the waitlist link (https://app.hypamoon.com/waitlist?want=phone_numbers&country=GB) instead of promising a number. type=national returns national numbers (non-geographic numbers that work nationwide), which are sometimes ready within seconds (from_stock: true). With a test key you'll get test numbers instead (+447700900..., test: true): they are active immediately, and you can simulate calls on them (step 7). Search is limited to 20 requests per minute per project, and the results stay the same for 60 seconds, so search once and reuse the list.

curl "https://api.hypamoon.com/api/v1/numbers/available?type=national" \
  -H "Authorization: Bearer $HYPAMOON_API_KEY"
const { data: available } = await hm("/numbers/available?type=national");
// test mode: [{ e164: "+447700900042", type: "national", from_stock: false, test: true }, ...]
// live:      [{ e164: "+•••••••••42", type: "national", from_stock: true, test: false }, ...]

Claim one. The Idempotency-Key header is required. Generate the key once for this order and keep it: if the request times out or your script crashes, send the same request again with the same key and you'll get the same number, never a second one. A new key on every run would order a new number on every run.

The quickest way: send a phone_number from the search results, like "+447700900042" with a test key. You can also send an area, in the form GET /numbers/available returns it, and we'll pick the first available number there. This works the same with a test key and a live key.

curl
curl -X POST https://api.hypamoon.com/api/v1/numbers \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: acme-first-number" \
  -d '{ "phone_number": "+447700900042" }'

Or claim the exact number you picked from the search:

TypeScript
// One key per order, stored with the order. Reuse it on every retry of this order.
const ORDER_KEY = "acme-first-number";

const number = await hm("/numbers", {
  method: "POST",
  headers: { "Idempotency-Key": ORDER_KEY },
  body: JSON.stringify({ phone_number: available[0].e164 }),
});
// test mode: { id: "5f0c...", e164: "+447700900042", type: "national", status: "active", test: true, from_stock: false, ... }

Use the e164 exactly as the search returned it. With a test key only test numbers (+447700900000 to +447700900999) are accepted; with a live key only numbers from your own search.

If the response is 502 order_status_unknown, the carrier did not confirm in time and the number may have been ordered. Send the same request with the same key: we'll check and give you the number, or order it at that point.

status is active (it works now) or pending (the registration is with the carrier; it usually turns active within one business day). A test number is always active; a test project can have three (422 test_number_limit_reached). To make room, release one with DELETE /api/v1/numbers/{id} (test numbers only; its call plan is deleted with it, while calls and messages are kept). A live number cannot be released through the API: that returns 409 number_release_not_supported.

We never hand out the scenario numbers (+447700900900 to +447700900999, including 991 to 995 and 999 below) or the example numbers in this guide (+447700900123, 321 and 456) as a project number, so they always work as targets and destinations.

Before a number is ordered, we run these checks in this order, each with its own error. In test mode only the first one applies; the others are live only.

error means
422 company_details_missing step 2 is not done; missing lists the fields
422 business_required a personal account asked for a national number; pick a number with a local area code or add business details
422 spend_cap_missing the project has no spending cap; set one in the portal under Billing
402 payment_mandate_missing no saved card and no balance; top up or save a card in the portal under Balance
402 billing_blocked this month's spending cap is reached
402 insufficient_balance your prepaid balance is too low for the first month of the number; top up under Balance
403 number_limit_reached the project already has three numbers, the maximum during the beta

Numbers with a local area code (type=geographic, with an area as the search returns it) need documents that cannot be uploaded through the API yet: proof of address, an ID and, for a business, an extract from the business register. Without them you get 422 regulatory_documents_missing. A business can use a national number instead; a personal account uploads its proof of address and ID in the portal under Verification.

4. Set up the assistant

One assistant per project. It answers in the language and voice you choose, and follows your instructions.

curl -X PUT https://api.hypamoon.com/api/v1/assistant \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "language": "en",
    "voice": "ava",
    "instructions": "You answer for Acme Plumbing. Ask for the address and the problem, and tell the caller we call back within the hour. Never quote prices.",
    "website": "https://acme-plumbing.example"
  }'
const assistant = await hm("/assistant", {
  method: "PUT",
  body: JSON.stringify({
    language: "en",
    voice: "ava",
    instructions:
      "You answer for Acme Plumbing. Ask for the address and the problem, " +
      "and tell the caller we call back within the hour. Never quote prices.",
    website: "https://acme-plumbing.example",
  }),
});
// { configured: true, language: "en", voice: "ava", languages: ["en"], speech_recognition: "en",
//   greeting: "You are speaking with the digital assistant of Acme Plumbing. ...", sync: "synced", synced_at: "..." }
  • language: the language the assistant answers and speaks in: en (English), nl (Dutch), de (German), fr (French) or es (Spanish). The greeting and the voice follow it. Default: the language of the country of your first number (Dutch while the project has no number yet).

  • voice: a voice that speaks that language. Female first, then male:

    language voices
    nl saar, fenna, merel, julia, roos, tim, bram, daan, ruben
    en ava, nora, liam, owen
    de lena, mara, jonas, felix
    fr chloe, manon, hugo, theo
    es lucia, elena, pablo, diego

    Default: the first voice of the language. A voice that does not speak the language gives a 422 that lists the ones that do.

  • languages: other languages your callers speak, from the same five; language always comes first. One language is the most reliable: speech recognition then never has to guess (speech_recognition: "en"). Two or more switch it to multi, which follows the caller sentence by sentence, and the assistant switches with it. auto is not accepted: it guesses once at the start and gets single words wrong.

  • instructions: free text, up to 4000 characters.

  • website: optional; we read the page and use it for opening hours, address and services.

  • The greeting is fixed and comes from your project name, in the language of the assistant: it always says the caller is talking to a digital assistant. You cannot change it (sending greeting gives a 422 that says so). In test mode the greeting, the chat and the simulated calls use your company name without " (test)", just like live; mode and test: true tell you it is a test.

PUT replaces all fields; leave one out and it goes back to its default. GET /api/v1/assistant shows the current settings, the greeting and synced_at. sync is synced when the assistant was updated right away, or failed when that did not work; your settings are saved either way and are applied with the next PUT. In test mode it is skipped_test_mode: the settings are saved and checked, and the assistant is only built when you go live.

5. Set the call plan: team first, then voice AI

A call plan is a small graph. entry is the first node; every node points to the next. This one rings your team for 20 seconds and hands over to the assistant when nobody answers.

curl -X PUT https://api.hypamoon.com/api/v1/numbers/$NUMBER_ID/call-plan \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "entry": "team",
    "nodes": {
      "team": {
        "id": "team",
        "type": "forward",
        "strategy": "sequential",
        "targets": [{ "kind": "external", "e164": "+447700900123" }],
        "ringSeconds": 20,
        "onNoAnswer": "ai"
      },
      "ai": { "id": "ai", "type": "ai_agent" }
    }
  }'
const plan = await hm(`/numbers/${number.id}/call-plan`, {
  method: "PUT",
  body: JSON.stringify({
    entry: "team",
    nodes: {
      team: {
        id: "team",
        type: "forward",
        strategy: "sequential",
        targets: [{ kind: "external", e164: "+447700900123" }],
        ringSeconds: 20,
        onNoAnswer: "ai",
      },
      ai: { id: "ai", type: "ai_agent" },
    },
  }),
});
// { number_id, version: 1, plan: { ... }, assistant_sync: "synced" }  (test mode: "skipped_test_mode")

Forward targets with a test key are test numbers (+447700900000 to +447700900999), like the one above, but not one of your own test numbers (that would loop, just like live). With a live key they are a mobile, geographic, national or toll-free number in a country your project supports; premium-rate, service and short numbers never. Those, test numbers and Hypamoon numbers (that would loop) give 422 forward_target_not_allowed, with every refused target and its reason in errors.

Node types: forward (ring up to three phone numbers, sequential or parallel, ringSeconds 5 to 120), ai_agent (the assistant from step 4), hours (opening hours with an open and a closed branch), menu ("press 1 for..."), announcement (read a text, then next), voicemail and hangup. Forward targets are phone numbers (kind: "external"); team members of the Hypamoon apps (kind: "user") are not available in the API (422 team_targets_not_allowed). A plan with ai_agent needs step 4 first (422 assistant_not_configured). The field names inside the plan (onNoAnswer, ringSeconds) are camelCase: the plan is stored exactly as you send it.

Want to check opening hours first? Put an hours node at the start:

JSON
{
  "id": "hours",
  "type": "hours",
  "schedule": {
    "timezone": "Europe/London",
    "days": {
      "mon": [{ "open": "09:00", "close": "17:00" }],
      "tue": [{ "open": "09:00", "close": "17:00" }],
      "wed": [{ "open": "09:00", "close": "17:00" }],
      "thu": [{ "open": "09:00", "close": "17:00" }],
      "fri": [{ "open": "09:00", "close": "17:00" }]
    }
  },
  "open": "team",
  "closed": "ai"
}

A plan that is inconsistent comes back as 422 validation_failed with every problem in errors, for example { "path": "nodes.team.onNoAnswer", "message": "Points to node \"ai\", which does not exist." }.

Two people editing at once? GET returns the plan with an ETag; send it back as If-Match and an outdated write gets 412 version_conflict instead of overwriting the newer plan.

6. Get a webhook for every call

curl -X POST https://api.hypamoon.com/api/v1/webhooks \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://your-app.example/hooks/hypamoon", "events": ["call.completed", "call.summary.ready"] }'
const endpoint = await hm("/webhooks", {
  method: "POST",
  body: JSON.stringify({
    url: "https://your-app.example/hooks/hypamoon",
    events: ["call.completed", "call.summary.ready"],
  }),
});
// endpoint.secret is "whsec_..." and is shown only now. Store it as HYPAMOON_WEBHOOK_SECRET.

Every delivery is signed: Hypamoon-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the full secret (including whsec_) as the key. Check it on the raw body, before you parse the JSON:

TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyHypamoon(rawBody: string, header: string | null, secret: string): boolean {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const given = String(parts.v1 ?? "");
  return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}

// Next.js route handler: app/hooks/hypamoon/route.ts
export async function POST(req: Request) {
  const raw = await req.text();
  if (!verifyHypamoon(raw, req.headers.get("hypamoon-signature"), process.env.HYPAMOON_WEBHOOK_SECRET!)) {
    return new Response("invalid signature", { status: 400 });
  }
  const event = JSON.parse(raw); // { id: "evt_...", type, created_at, data: { call, summary? } }
  // event.id stays the same on retries: skip it if you have seen it before.
  return new Response(null, { status: 204 });
}

Respond with any 2xx within ten seconds. Anything else is retried with backoff: eight attempts in total, after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours, so about 45 hours from the first attempt to the last. Webhooks of a test project are retried three times at most (after 1 and 5 minutes), so a wrong address does not keep failing for days. Send yourself a test event with POST /api/v1/webhooks/{id}/test. Deleting an endpoint also deletes its delivery log.

Every call in the payload has test: true when it was simulated, so your handler can tell test traffic apart.

7. Simulate a call

In test mode nobody can call a test number, so you make the call happen. Pick a scenario: reservation, callback_request, question or missed (nobody picked up). The call is stored with a transcript and a summary, and the real webhooks go out: call.completed, then call.summary.ready (not for missed, which has no summary). They arrive within about a minute.

curl -X POST https://api.hypamoon.com/api/v1/test/calls \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "number_id": "'$NUMBER_ID'", "scenario": "callback_request" }'
const simulated = await hm("/test/calls", {
  method: "POST",
  body: JSON.stringify({ number_id: number.id, scenario: "callback_request" }),
});
// { id, direction: "inbound", from: "+447700900456", to: "+447700900042",
//   status: "ai_handled", duration_sec: 64, has_summary: true, test: true,
//   transcript: "Assistant: ...\nCaller: ...", summary: { text, topics, ... } }
  • from is optional; it must be a fictional number from +447700900000 to +447700900999.
  • caller_goal is optional: what the caller wants, in a sentence or two (at most 300 characters), for example "Ask whether you are open on Sunday.". Without it the caller asks something that fits the scenario.
  • 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: a simulation never books anything. The transcript is that conversation, and the summary is made the same way as for a real call.
  • This route only works with a test key (403 test_key_required with a live key), and at most 30 times per hour per test project. The first 10 simulated calls per day are free; after that each one is deducted from the balance of your live project, and without balance or a saved card you get 402 test_free_replies_used. missed needs no AI and always works.

8. Fetch the call and the summary

A webhook carries the call and, for call.summary.ready, the summary; never the transcript. Fetch that with the call id:

curl https://api.hypamoon.com/api/v1/calls/$CALL_ID \
  -H "Authorization: Bearer $HYPAMOON_API_KEY"
const call = await hm(`/calls/${event.data.call.id}`);
// {
//   id, direction: "inbound", from: "+447700900456", to: "+•••••••••42",
//   status: "ai_handled", duration_sec: 94, has_summary: true,
//   transcript: "...",
//   summary: { text, topics, sentiment: "neutral", action_items: [...], next_step }
// }

Or list them, newest first: GET /api/v1/calls?limit=25, and follow next_cursor while has_more is true.

That's it. In test mode, simulate another call and watch the webhook arrive; in live mode, call your number.

Recipe: order updates by SMS

Your online store sends the customer a text when the order is confirmed and another one when it ships, with the tracking link. One POST /api/v1/messages per order event, from your server (your store's order webhook, a background job), never from the browser. The key needs the messages:write scope; reading the messages back needs messages:read.

Transactional, not marketing. Send only what the customer expects because of something they did: the order confirmation, "your order has shipped", a pickup or appointment reminder, a delivery problem. Discounts, newsletters, "we miss you" and other promotions are marketing: they need the recipient's prior opt-in under marketing law, and this endpoint is not built for them. Keep the text about the order, say who it is from, and leave out the upsell.

What works today:

  • To: a mobile number in E.164 (+447700900321). The API decides which destinations are allowed right now: one that is not returns 422 destination_not_allowed with a reason. Normalize what your checkout collects to E.164 (with the country code, without the leading trunk zero) before you send.
  • From: your project name, up to 11 letters, digits and spaces, or your own from in that same form. Your Hypamoon phone numbers cannot send SMS, and the customer cannot reply to a name: put a link or your support address in the text. Names of banks, carriers and government bodies are refused (sender_not_allowed).
  • Length: at most six SMS segments. A plain text segment holds 160 characters (153 when a message is split); a single emoji turns the whole text into UCS-2 with 70 (67) per segment, so leave emoji out. Typographic quotes and dashes are made plain for you. segments in the response says what it cost.
  • Limits: 30 messages per minute and 200 per day per project during the beta.
  • Live: your live key needs the company verification level: company details, a spending cap and a payment method (a saved card or a balance), like a number. A business also needs a business register check (kvk_check; pick the company under Settings, Company in the portal, or our team checks it by hand). A personal account needs our approval of its identity documents (proof of address and an ID, uploaded in the portal under Verification). Until then the request gets 403 verification_required with a next_step.
  • Cost: every live segment is deducted from your prepaid balance, with no bundle; the rate is under Billing in the portal, and test mode is free. When the balance is too low for this message, the request gets insufficient_balance; when it would take the project past the spending cap, billing_blocked. In both cases nothing is sent. The Overview shows what SMS cost this month.

Send the confirmation

Use one Idempotency-Key per order event, like order-10432-confirmed. When your request times out and you send it again with the same key, you get the same message back and the customer gets one SMS, not two. A webhook from your shop that arrives twice is covered the same way. The header is optional for SMS, but without it a retry sends a second SMS.

curl -X POST https://api.hypamoon.com/api/v1/messages \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-10432-confirmed" \
  -d '{ "to": "+447700900321", "body": "Hi Sam, thanks for your order 10432 at Acme Shop. We will text you when it ships.", "reference": "order-10432" }'
type OrderEvent = "confirmed" | "shipped";

async function sendOrderSms(
  order: { id: string; phone: string; name: string; trackingUrl?: string },
  event: OrderEvent,
) {
  const body =
    event === "confirmed"
      ? `Hi ${order.name}, thanks for your order ${order.id} at Acme Shop. We will text you when it ships.`
      : `Hi ${order.name}, your Acme Shop order ${order.id} has shipped. Track it: ${order.trackingUrl}`;
  return hm("/messages", {
    method: "POST",
    headers: { "Idempotency-Key": `order-${order.id}-${event}` },
    body: JSON.stringify({ to: order.phone, body, reference: order.id }),
  });
}

const message = await sendOrderSms({ id: "10432", phone: "+447700900321", name: "Sam" }, "confirmed");
// { id, to: "+447700900321", from: "Acme Shop", body: "Hi Sam, ...", segments: 1,
//   encoding: "gsm7", status: "queued", error: null, reference: "10432", test: false, ... }

Send the shipping update

The same function with "shipped" and the tracking link, when your shop marks the order as shipped. The key order-10432-shipped differs from the confirmation's, so each goes out once.

Know whether it arrived

The response says queued. The status then moves to sent and to delivered or failed, and two webhooks tell you how it ended: message.delivered and message.failed. Subscribe an endpoint to them (step 6, with "events": ["message.delivered", "message.failed"]); the payload is { id, type, created_at, data: { message } }, with the same message as the API returns, including your reference. Or look it up with GET /api/v1/messages/{id}, or list them with GET /api/v1/messages?limit=25.

When a message failed, error says why (delivery_failed, sending_failed, expired). Check the number with the customer before you try again; do not retry in a loop.

Test it first

With your test key nothing is sent and nothing is charged. Send to a test number from +447700900000 to +447700900999: the message is delivered immediately and message.delivered goes to your webhook. Send to +447700900999 to see failed and message.failed. A real number with a test key gives destination_not_allowed.

Recipe: confirm appointments with an outbound call

Your assistant can also make a call: the day before an appointment it calls the customer, confirms the time and, if they cannot make it, asks which day suits better. One POST /api/v1/calls per call, from your server (a job that runs the day before, an action in your own admin panel behind your own auth), never from the browser. The key needs the calls:write scope; reading the calls back needs calls:read. The assistant from step 4 makes the call, from one of your numbers, so the customer sees who is calling and can call back.

Only people who expect the call. An existing customer about their appointment, a callback someone asked for, a delivery they are waiting on. Not cold calling, sales or promotions: telemarketing to consumers needs their prior consent under telemarketing law, and national do-not-call registers must be respected. Do not loop over a list of leads.

What to know:

  • The assistant always says what it is. It opens with "Hello, this is the digital assistant of Acme Plumbing. Is this a good moment?" (in the assistant's language). That cannot be switched off; assistant_instructions adds what this call is about, never new rules.
  • 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 it you get outside_calling_hours with next_window_start: schedule the call for then. Public holidays are not recognized yet, so skip those yourself.
  • Limits: 5 calls per minute, 100 per day and 2 at the same time per project (concurrent_call_limit, with Retry-After). A call lasts at most max_duration_sec (default 600, at most 1200).
  • To: in live mode, a phone number in E.164 that your project may call. Toll-free, premium-rate, service and short numbers, and destinations that are not supported, give 422 destination_not_allowed with a reason.
  • Voicemail: on_voicemail is hangup (default) or leave_message; the message starts with the same digital assistant sentence, then your voicemail_message.
  • Live: your live key needs the full verification level (request it in the portal under Verification; we review it by hand) plus the company level (company details, a spending cap and a payment method).
  • Cost: a live call is deducted from your prepaid balance when it ends: the voice AI minutes plus the minutes of the line, each per started minute (the rates are under Billing in the portal). No answer, busy and failed cost nothing; test mode is free. Before we call, the balance must cover the call at its full max_duration_sec, otherwise you get insufficient_balance (or billing_blocked at your spending cap) and nobody is called.

Start the call

Use one Idempotency-Key per call you mean to make, like appointment-8812-confirm. The header is required: when your request times out and you send it again with the same key, you get the same call back and the customer's phone rings once, not twice.

curl -X POST https://api.hypamoon.com/api/v1/calls \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: appointment-8812-confirm" \
  -d '{ "to": "+447700900321", "assistant_instructions": "Confirm the appointment of Sam Taylor tomorrow at 10:00 at 40 Main Street. If Sam cannot make it, ask which day suits better and say we will confirm by SMS.", "on_voicemail": "leave_message", "reference": "appointment-8812", "metadata": { "customer_id": "cus_4821" } }'
async function confirmAppointmentByCall(appointment: {
  id: string;
  phone: string;
  name: string;
  startsAt: string;
  address: string;
}) {
  return hm("/calls", {
    method: "POST",
    headers: { "Idempotency-Key": `appointment-${appointment.id}-confirm` },
    body: JSON.stringify({
      to: appointment.phone,
      assistant_instructions:
        `Confirm the appointment of ${appointment.name} ${appointment.startsAt} at ${appointment.address}. ` +
        "If they cannot make it, ask which day suits better and say we will confirm by SMS.",
      on_voicemail: "leave_message",
      reference: appointment.id,
    }),
  });
}

const call = await confirmAppointmentByCall({
  id: "8812",
  phone: "+447700900321",
  name: "Sam Taylor",
  startsAt: "tomorrow at 10:00",
  address: "40 Main Street",
});
// 201: the call as GET /calls/{id} returns it, with direction "outbound" and
// outbound: { status: "ringing", answered_by: null, voicemail_left: false,
//   on_voicemail: "leave_message", max_duration_sec: 600, reference: "8812", metadata: {}, error: null }

Find out how it went

The response says queued or ringing. When the call ends, call.completed arrives at your webhook (step 6) with direction: "outbound" and the same outbound object, now with the outcome in outbound.status. Read that one: the status at the top of the call says the call record is finished (completed), even when nobody picked up.

  • completed: the assistant spoke with the customer. call.summary.ready follows; fetch GET /api/v1/calls/{id} for the summary and the transcript, and check summary.text to see whether the appointment stands.
  • no_answer: nobody picked up. With answered_by: "machine" an answering machine did, and voicemail_left says whether the assistant left a message.
  • busy or failed (error says why, like unallocated_number).
TypeScript
// In your webhook receiver, after the signature check.
if (event.type === "call.completed" && event.data.call.outbound) {
  const { status, answered_by, voicemail_left, reference } = event.data.call.outbound;
  // reference is your appointment id. Try again later on no_answer or busy,
  // with a new Idempotency-Key, or send an SMS instead.
}
if (event.type === "call.summary.ready" && event.data.call.outbound) {
  const call = await hm(`/calls/${event.data.call.id}`);
  // call.summary.text: what was agreed. call.outbound.reference: your appointment id.
}

List only your outbound calls with GET /api/v1/calls?direction=outbound.

Test it first

With your test key nothing is dialed and nothing is charged. Call a test number from +447700900000 to +447700900999: the response is the final result immediately, and the real webhooks follow. The number picks the outcome, so you can test every branch of your code:

to outcome
+447700900991 no_answer
+447700900992 busy
+447700900993 an answering machine (no_answer, answered_by: "machine"; with leave_message also voicemail_left: true)
+447700900994 failed
+447700900995 422 outside_calling_hours, and nobody is called
any other completed, with a transcript and a summary

Calling hours do not apply in test mode, except for +447700900995. A real number with a test key gives destination_not_allowed, and so does one of your own test numbers (reason: "platform_number": you would be calling yourself).

Recipe: add a chat to your app with the same assistant

Your customers call you, but many of them would rather type. Give them a chat window in your app or on your website, answered by the assistant you set up in step 4. It is the same assistant as on the phone: the same instructions, languages, opening hours and website, so a customer gets the same answers in the chat as when they call. You don't have to write or maintain a second prompt.

The key needs the chat:write scope to start conversations and send messages, and chat:read to read them back. The assistant must be set up first (PUT /api/v1/assistant, step 4); without it you get 422 assistant_not_configured.

The key stays on your server

This is the one rule that matters. Your API key is a server-side secret, even for a chat. A chat window on a website therefore never talks to Hypamoon directly: the browser calls a small route on your backend, and that route adds the key and calls Hypamoon. Never put hm_live_... or hm_test_... in browser code, a mobile app bundle or a public environment variable (NEXT_PUBLIC_..., VITE_...). Anyone who opens the page could read it and use your project at your expense.

No backend of your own? Then use the chat widget instead: it talks to Hypamoon with a public chat token that can only chat, and only from your own website. See the recipe "Add a chat widget to your website" below.

How a chat works

  1. Your backend starts a conversation: POST /api/v1/conversations. The response contains the fixed greeting as the first message, which says the customer is chatting with a digital assistant.
  2. For every message the customer types, your backend calls POST /api/v1/conversations/{id}/messages and gets the reply in the same response.
  3. When the customer is done, your backend closes it: POST /api/v1/conversations/{id}/close. If you forget, it closes by itself after 30 minutes without a message.
  4. The summary follows as the webhook conversation.summary.ready and in GET /api/v1/conversations/{id}.

What the assistant does in a chat. It answers questions from your instructions and your website, and when it cannot help, it takes a message: who the customer is, how to reach them and what they need. It does not book appointments, transfer anyone or send anything in a chat. The message ends up in the summary, so your team picks it up from the conversation.summary.ready webhook, the same way you handle call.summary.ready.

Start, send, close with curl

curl
curl -X POST https://api.hypamoon.com/api/v1/conversations \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: session-8841-start" \
  -d '{ "reference": "session-8841", "metadata": { "page": "/pricing" }, "customer": { "name": "Sam Jones", "email": "sam@example.com" } }'

Every field is optional; {} starts a conversation too. reference and metadata are yours: they come back in the conversation and the webhook, and the assistant never sees them. customer is what your app already knows, for example from the login. The assistant may use it to address the customer, but treats it as data, never as instructions.

JSON
{
  "id": "3d4e5f6a-...",
  "channel": "chat",
  "status": "open",
  "language": "en",
  "turns": 0,
  "max_turns": 30,
  "messages": [
    { "id": "4e5f...", "role": "assistant", "text": "You are chatting with the digital assistant of Acme Plumbing. How can I help you?", "created_at": "..." }
  ],
  "summary": null,
  "test": true
}

Send what the customer typed:

curl
curl -X POST https://api.hypamoon.com/api/v1/conversations/$CONVERSATION_ID/messages \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: session-8841-msg-1" \
  -d '{ "text": "My kitchen tap is leaking. Can someone come today?" }'
JSON
{
  "message": { "id": "5f6a...", "role": "customer", "text": "My kitchen tap is leaking. Can someone come today?", "created_at": "..." },
  "reply": { "id": "6a7b...", "role": "assistant", "text": "I'm sorry about the leak. I can't plan a visit myself, but I'll pass it on: on which number can our team call you back within the hour?", "created_at": "..." },
  "conversation": { "id": "3d4e5f6a-...", "status": "open", "turns": 1, "max_turns": 30, "...": "..." }
}

Show reply.text to the customer. Close when the chat window closes:

curl
curl -X POST https://api.hypamoon.com/api/v1/conversations/$CONVERSATION_ID/close \
  -H "Authorization: Bearer $HYPAMOON_API_KEY"

Send the text as the customer typed it. You do not need to filter it for tricks like "ignore your instructions": the customer's text always goes to the assistant as a customer message, never as an instruction, and every reply is checked before it leaves us, so the assistant does not reveal its instructions or anything that looks like a key.

One Idempotency-Key per customer message. Create it when the customer presses send (a UUID from the browser is fine) and reuse it when you retry that message. A retry with the same key within 24 hours returns the same reply, without a second turn and without paying twice. A new key per retry would make the assistant answer the same question twice.

Your backend route

A Next.js route handler that the chat window in the browser calls. It holds the key, keeps the conversation id in an httpOnly cookie so a visitor can only write into their own conversation, and passes the reply back. The same shape works in Express, a Supabase Edge Function or any other server.

TypeScript
// app/api/chat/route.ts (Next.js). HYPAMOON_API_KEY is a server-side secret.
import { cookies } from "next/headers";

const BASE = "https://api.hypamoon.com/api/v1";
const KEY = process.env.HYPAMOON_API_KEY!;

async function hypamoon(path: string, init: RequestInit = {}) {
  return fetch(`${BASE}${path}`, {
    ...init,
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...(init.headers ?? {}) },
  });
}

// The browser sends { text, messageId }, where messageId is a UUID it made when
// the customer pressed send, and the same one on a retry.
export async function POST(req: Request) {
  const { text, messageId } = await req.json();
  if (typeof text !== "string" || !text.trim() || text.length > 2000) {
    return Response.json({ error: "Type a message of at most 2000 characters." }, { status: 400 });
  }
  if (typeof messageId !== "string" || !/^[A-Za-z0-9-]{8,64}$/.test(messageId)) {
    return Response.json({ error: "Missing message id." }, { status: 400 });
  }

  const jar = await cookies();
  let conversationId = jar.get("hm_chat")?.value;

  if (!conversationId) {
    const res = await hypamoon("/conversations", {
      method: "POST",
      headers: { "Idempotency-Key": `start-${messageId}` },
      body: JSON.stringify({ metadata: { page: req.headers.get("referer") ?? "" } }),
    });
    if (!res.ok) return Response.json({ error: "The chat is not available right now." }, { status: 502 });
    conversationId = (await res.json()).id as string;
    jar.set("hm_chat", conversationId, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 60 * 30 });
  }

  const res = await hypamoon(`/conversations/${conversationId}/messages`, {
    method: "POST",
    headers: { "Idempotency-Key": `msg-${messageId}` },
    body: JSON.stringify({ text }),
  });
  const body = await res.json();

  if (!res.ok) {
    // Closed, or the turn limit was reached: forget it; the next message starts a new one.
    if (body.type?.endsWith("/conversation_closed") || body.type?.endsWith("/conversation_turn_limit_reached")) {
      jar.delete("hm_chat");
      return Response.json({ error: "This chat has ended. Send your message again to start a new one." }, { status: 409 });
    }
    if (body.type?.endsWith("/conversation_busy")) {
      return Response.json({ error: "One moment, still answering." }, { status: 409 });
    }
    return Response.json({ error: "Could not answer right now. Try again." }, { status: 502 });
  }

  if (body.conversation.status === "closed") jar.delete("hm_chat");
  return Response.json({ reply: body.reply.text });
}

// When the chat window closes, close the conversation so the summary arrives right away.
export async function DELETE() {
  const jar = await cookies();
  const conversationId = jar.get("hm_chat")?.value;
  if (conversationId) {
    await hypamoon(`/conversations/${conversationId}/close`, { method: "POST" });
    jar.delete("hm_chat");
  }
  return new Response(null, { status: 204 });
}

The greeting is in the response of POST /conversations; the route above leaves it out for brevity. To show it before the customer types, start the conversation when the chat window opens and return messages[0].text.

Your route is public, so treat it like any public form: a rate limit per visitor and, on a busy site, a bot check before the first message. Our limits below stop a runaway loop, not a bot that fills your daily quota.

Stream the reply

Send Accept: text/event-stream and the reply arrives as Server-Sent Events while the assistant writes it:

event: delta
data: {"text":"I'm sorry about the leak. "}

event: delta
data: {"text":"I can't plan a visit myself, "}

event: done
data: {"message":{...},"reply":{...},"conversation":{...}}

done has the same body as the JSON response. Show the delta pieces as they arrive, then replace them with done.reply.text: that is the reply as stored, and if the reply check replaced it partway through, only done has the final text. An event: error carries a problem+json; nothing was charged and you can send the same message again with the same Idempotency-Key. Errors before the assistant starts (a closed conversation, a limit) come as a normal problem+json response with their status, not as a stream.

Your backend passes the stream on as is:

TypeScript
const res = await hypamoon(`/conversations/${conversationId}/messages`, {
  method: "POST",
  headers: { "Idempotency-Key": `msg-${messageId}`, Accept: "text/event-stream" },
  body: JSON.stringify({ text }),
});
if (!res.ok || !res.headers.get("content-type")?.startsWith("text/event-stream")) {
  return Response.json(await res.json(), { status: res.status });
}
return new Response(res.body, {
  headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
});

The summary

Subscribe an endpoint to conversation.summary.ready (step 6, with "events": ["conversation.summary.ready"]). It arrives within about a minute of the conversation closing. The payload is { id, type, created_at, data: { conversation, summary } }, and the summary has the same shape as a call's summary (text, topics, sentiment, action_items, next_step), so the code that handles call.summary.ready can handle this one too. The messages are not in the webhook; fetch them with GET /api/v1/conversations/{data.conversation.id}. A conversation in which the customer never typed anything has no summary and sends no webhook.

GET /api/v1/conversations?status=closed&limit=25 lists them, newest first, with next_cursor while has_more is true.

Limits

  • Message: at most 2000 characters.
  • Turns: 30 per conversation (a turn is one customer message with its reply). After the 30th reply the conversation closes and the summary follows; another message gives 409 conversation_turn_limit_reached.
  • One at a time: a second message while the first is still being answered gives 409 conversation_busy. Disable the send button until the reply is in.
  • Per minute: 20 new conversations per project and 12 messages per conversation (429 rate_limited, with Retry-After).
  • Per day: 1000 new conversations per live project during the beta (429 conversation_limit_reached), counted until midnight, Europe/Amsterdam.
  • Idle: a conversation without a message for 30 minutes closes by itself (close_reason: "inactive"), and its summary follows.
  • Live: your live key needs the company verification level: company details, a spending cap and a balance or saved card, like a number and SMS.
  • Cost: every assistant reply in a live conversation is deducted from your prepaid balance (the rate is under Balance in the portal); the greeting is free. In test mode the first 20 replies per day are free, after that they come from the balance of your live project. A reply the assistant could not give is refunded. When the balance cannot cover a reply you get 402 insufficient_balance and the message is not answered; past the spending cap you get 402 billing_blocked.

Test it first

With your test key the assistant answers for real, with your own instructions. That makes it the place to tune your instructions before customers see them. A test project can start 25 conversations per day (429 conversation_limit_reached). The first 20 replies per day are free; after that each reply is deducted from the balance of your live project at the live price, and without balance or a saved card you get 402 test_free_replies_used. The counts reset at midnight, Europe/Amsterdam. Test conversations have test: true, and their summaries go to your webhook like live ones.

Recipe: add a chat widget to your website

Do you have a website but no backend of your own, or do you simply want a chat window on every page without writing one? Then use the chat widget: one script tag, and your visitors chat with the same assistant that answers your phone (step 4). The widget draws its own button and window, streams the reply as it is written, and remembers the conversation while the visitor moves through your site.

What is secret and what is not

What Looks like Where it lives Secret?
API key hm_live_..., hm_test_... your server, your connector settings Yes. Never in a web page.
Public chat token hm_pub_..., hm_pubtest_... the script tag in your page source No. It is meant to be public.
Conversation token v1.... the visitor's browser, for 6 hours Only for that one visitor.

The public chat token is not a key. Anyone can read it in your page source, and that's fine: it can only start a chat and send messages, only from the origins you allowed, behind a bot check and with its own limits. It cannot read your calls, your numbers, your other conversations or anything else in your project. If it ends up somewhere you do not want, revoke it and make a new one.

When a conversation starts, the visitor's browser gets a conversation token. Only with that token can the browser continue or read its own conversation; another visitor, or someone who guesses an id, gets 401 invalid_conversation_token. A conversation you start with your API key is never reachable through the widget.

1. Create a public chat token

In the portal under Chat widget, or with your API key (chat:write):

curl
curl -X POST https://api.hypamoon.com/api/v1/chat-tokens \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Website", "allowed_origins": ["https://www.example.com", "https://example.com"] }'
JSON
{
  "id": "7b8c9d0e-...",
  "name": "Website",
  "token": "hm_pubtest_A1b2C3d4E5f6G7h8I9j0K1l2",
  "mode": "test",
  "allowed_origins": ["https://www.example.com", "https://example.com"],
  "turnstile": true,
  "max_conversations_per_day": 200,
  "revoked": false,
  "embed_code": "<script src=\"https://api.hypamoon.com/widget.js\" data-token=\"hm_pubtest_A1b2C3d4E5f6G7h8I9j0K1l2\" async></script>",
  "...": "..."
}

With a test key you get a hm_pubtest_ token on your test project; with a live key a hm_pub_ token. In an AI tool with the Hypamoon connector, ask for create_chat_widget.

Origins. List every origin exactly: scheme and host, no path, no wildcard. https://example.com does not cover https://www.example.com, so list both if your site answers on both. A live token needs at least one https origin. A test token also works from localhost (any port), so you can try it on your own machine first. A page on any other origin gets 403 origin_not_allowed, and the browser does not even let that page read the response.

2. Paste the snippet

Put embed_code on every page, just before </body>. In a site builder, use its custom code field for the footer; in Next.js, put it in the root layout with <Script src="..." data-token="..." strategy="afterInteractive" />.

html
<script src="https://api.hypamoon.com/widget.js"
        data-token="hm_pub_A1b2C3d4E5f6G7h8I9j0K1l2"
        data-color="#2563eb"
        data-position="right"
        data-title="Acme Plumbing"
        data-lang="en"
        async></script>
Attribute What it does Default
data-token Your public chat token. Required.
data-color The color of the button and the header, as hex (#2563eb). The text on it turns black or white automatically. dark gray
data-position right or left. right
data-title The title of the chat window. your company name
data-welcome The first message the visitor sees. the greeting of the assistant
data-lang en, nl, de, fr or es for the texts of the widget itself. the browser language
data-open true opens the window when the page loads. closed

Your own button opens it too:

html
<button onclick="window.Hypamoon.chat.open()">Chat with us</button>

window.Hypamoon.chat also has close(), toggle() and isOpen().

The widget lives in a Shadow DOM: your CSS does not change it and its CSS does not leak into your page. It works with a keyboard and a screen reader, and on a phone it fills the screen. The script is under 25 kB gzipped and has no dependencies. widget.js always serves the latest version; pin one with https://api.hypamoon.com/widget/1.1.0.js. Version 1.0.0 still works and also shows the AI notice below.

A Content-Security-Policy on your site? Allow https://api.hypamoon.com in script-src, connect-src and frame-src (the bot check runs in a small frame on our host), and allow inline styles (style-src 'unsafe-inline') for the style sheet inside the widget.

What a visitor sees

Under the title, the window always says that the visitor is talking to an AI: "You're chatting with an AI assistant." in English, "Je chat met een AI-assistent." in Dutch, and the same in German, French and Spanish. It follows the language of the widget, uses the text color of the header so it reads on any brand color, and screen readers announce it with the window. There is no setting to hide it: the EU AI Act and several US states (Maine, Utah) require that people know they are talking to a machine.

  1. The visitor clicks the button. With the bot check on (the default), a short check appears to confirm they are not a robot; it usually passes without a click.
  2. The first message starts the conversation, and the reply streams in word by word.
  3. The conversation survives a page load in the same tab. After 30 minutes without a message it closes, and the visitor can start a new one.
  4. The summary arrives at your webhook as conversation.summary.ready, with data.conversation.channel set to "widget", and the conversation is in the portal and in GET /api/v1/conversations like any other.

When the chat cannot answer, for example because your balance is empty, the visitor only sees that the chat is not available right now (503 chat_unavailable). The reason is for you, not for your visitor: the portal shows it, and so does a conversation you start with your API key.

Limits

On top of the limits of the chat recipe above (2000 characters, 30 turns, one message at a time), the widget has its own, because the token is public:

  • Per visitor address: 3 new conversations per minute and 20 per hour, 20 messages per minute, 60 requests per minute in total.
  • Per token: 30 new conversations per minute and 300 per hour, and a daily limit you set per token: 200 by default, up to 1000 (429 public_chat_limit_reached).
  • Per conversation: 6 messages per minute.
  • Spending per day: what the widget may spend on replies per day per project, 5 euro by default. Set it in the portal under Chat widget, from 0 to 1000 euro; 0 turns the live widget off. Past it, the widget answers 429 public_chat_limit_reached until midnight, Europe/Amsterdam.

Every reply is deducted from your prepaid balance like a reply through your own backend; the greeting is free.

Turn it off

Revoke a token in the portal or with DELETE /api/v1/chat-tokens/{id}. That takes effect immediately: the widget stops starting conversations, and open conversations with that token accept no more messages. A revoked token cannot be switched back on; create a new one and replace the snippet. Change the origins, the bot check or the daily number of conversations with PATCH /api/v1/chat-tokens/{id}.

Test it first

Create the token with your test key and try it on http://localhost. The replies are real; the first 20 replies per day are free, after that they come from the balance of your live project (25 test conversations per day). When it works, create a live token with your live key, with your real origins, and swap the snippet.

Recipe: give the assistant data

Your assistant knows what is in its instructions and on your website. For facts that change (the menu of the week, prices, opening exceptions, FAQ), keep them in a collection and let the assistant read along.

curl
curl -X POST https://api.hypamoon.com/api/v1/collections \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "faq", "access": { "assistant_read": true } }'

curl -X POST https://api.hypamoon.com/api/v1/collections/faq/documents \
  -H "Authorization: Bearer $HYPAMOON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "data": { "question": "Do you deliver?", "answer": "Yes, within 10 km of the shop, free above 25 euro." } }'

Every new chat conversation gets the newest documents of every collection with assistant_read, as compact JSON under the heading "Project data (collection faq)", 8 kB at most for all collections together. A conversation keeps what it started with, so a change takes effect from the next conversation on. Only documents added with your API key reach the assistant, never entries from a form: a visitor cannot put text in front of your assistant, and one visitor's details never reach another. Phone calls follow later; currently this works in the chat and the chat widget.

Want to show the same data on your site? Turn on public_read too, and fetch GET /api/v1/public/collections/faq/documents?token=hm_pub_... from the page: only data and created_at, the newest 100 at most.

Billing and balance

A live project pays in advance from a prepaid balance. There is no subscription and no invoice afterwards: every SMS segment, call minute, number and chat reply is deducted from the balance the moment it is used. The prices per unit are under Billing in the portal. AI usage (chat replies, agents, the portal assistant) is billed per use; a chat reply is usually about one cent. All amounts are in euro, excluding VAT.

  • Top up in the portal under Balance (https://app.hypamoon.com/tegoed): from 10 EUR, by card or bank payment. You'll receive an invoice for every top-up.
  • Automatic top-up: save a card and the balance tops itself up. By default, when it drops below 5 EUR we add 20 EUR, at most 10 times per calendar month. You can change all three under Balance.
  • Spending cap: still applies, per calendar month, on top of the balance. Everything deducted this month counts toward it (402 billing_blocked).
  • Too low: when the balance cannot pay for an SMS, a new number or a chat reply, the request gets 402 insufficient_balance with balance_eur, required_eur and auto_reload_started, and nothing is sent, ordered or answered. A call that is already running is always allowed to finish.
  • Test mode is free and never touches the balance.
  • Read it: GET /api/v1/balance (scope project:read) returns the balance, this month's spending against the cap, the automatic top-up settings and the latest 20 transactions. With a test key it shows the live project's balance with test: true. For agents, the MCP tool get_balance does the same.
  • Webhooks: subscribe to balance.low (the balance dropped below the threshold), balance.topped_up (a top-up was added) and balance.reload_failed (an automatic top-up failed) to keep an eye on it from your app.
curl
curl https://api.hypamoon.com/api/v1/balance \
  -H "Authorization: Bearer $HYPAMOON_API_KEY"

Going live

There is no separate go-live step. When your test run works end to end (the webhook arrives, your signature check passes and you store the summary), use a live key (hm_live_..., created in the portal under API keys with the scopes of this guide) or a normal chat connection, and ask for the real thing. The first time you order a number, send an SMS or call someone, it is real as soon as the project is verified for it:

  • every function: the company level (or the personal level), a prepaid balance or saved card (Balance), a spending cap (Billing) and a verified phone;
  • a national number and SMS from a business: the business register check; a number with a local area code and SMS from a personal account: approved identity documents;
  • outbound calls (POST /api/v1/calls, scope calls:write): the full verification level, which we review by hand.

If something is missing, that request answers 403 verification_required with missing, next_step and verify_url (a link to exactly that step in the portal). Nothing was ordered, sent or charged; after the user finishes it, send the same request again with the same Idempotency-Key. Voice AI is always included.

Your code stays the same; only the data changes: the forward target in step 5 becomes a real number (your team's phone), and the order in step 3 gets a new Idempotency-Key, because it is a new order. Your test numbers and simulated calls stay visible as test data. Call your number from your own phone: POST /api/v1/test/calls is for test only. During the beta a project can have three live numbers. Test outbound calls against the test numbers +447700900991 to +447700900995 first; live outbound calling is switched on step by step, and until it is on for your project a live request gets 503 telephony_unavailable and nobody is called.

Keep the test key: it stays useful for your own tests and for your CI.

The prompt for your agent

Paste this into Claude Code, Cursor, Lovable or Bolt, with your test key in your environment as HYPAMOON_API_KEY:

Add an AI phone receptionist to this app with the Hypamoon API
(base URL https://api.hypamoon.com/api/v1, auth "Authorization: Bearer $HYPAMOON_API_KEY",
the key is in the environment; never put it in client code). The key is a test key
(hm_test_...): numbers are test numbers and calls are simulated, so nothing costs
money. Later I swap in the live key; do not hard-code anything that only works in
one mode.

1. PUT /project/company with our company_name, our registration number
   (kvk_number for a company in the Netherlands, registration_number elsewhere),
   address { street, house_number, postal_code, city, country } and contact
   { name, phone }. Without a company: account_type "personal", my address and
   me as the contact, and no company fields. Ask me for these values; do not
   invent them.
2. GET /numbers/available, then POST /numbers with { "phone_number": <an e164
   from that list> } and an Idempotency-Key header. Make that
   key once, store it (in our database or config) and send the same key on every
   retry of this order, including after a timeout, a crash or a 502
   order_status_unknown; never a new random key per run. Save the returned id.
3. PUT /assistant with { language: <ask me which language our callers speak: en, nl,
   de, fr or es>, instructions: <ask me what the assistant should do>, website: <our
   site> }. Leave out voice to get the default voice of that language.
4. PUT /numbers/{id}/call-plan with a plan that forwards for 20 seconds (forward
   node, targets [{ kind: "external", e164 }], ringSeconds 20) and then goes to
   { type: "ai_agent" }. With the test key the target must be a test number such
   as +447700900123; with the live key it is my own phone number (ask me).
5. Add a route that receives webhooks: POST /webhooks with our public URL and events
   ["call.completed", "call.summary.ready"], store the returned secret as
   HYPAMOON_WEBHOOK_SECRET, and verify Hypamoon-Signature (t=..,v1=.. ; v1 is
   HMAC-SHA256 of "<t>.<raw body>" with the full secret) before parsing. On
   call.summary.ready, GET /calls/{data.call.id} and store the summary and transcript.
6. Test it: POST /test/calls with { "number_id": <the id>, "scenario": "callback_request" }
   and check that the webhook arrives and the summary is stored. Try "missed" too:
   that call has no summary and only sends call.completed.

Every error is application/problem+json. Its "type" is a URL that explains the fix;
read it and follow it before trying something else.
Hypamoon is the infrastructure your chat or agent builds on for a business. Hypamoon API, version 1.hypamoon.com