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_KEYis 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+jsonwith atypeURL that explains the fix, for examplehttps://hypamoon.com/docs/errors/company_details_missing. Every response carries anX-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.
// 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 -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 -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:
// 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) ores(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 nlsaar,fenna,merel,julia,roos,tim,bram,daan,rubenenava,nora,liam,owendelena,mara,jonas,felixfrchloe,manon,hugo,theoeslucia,elena,pablo,diegoDefault: the first voice of the language. A voice that does not speak the language gives a
422that lists the ones that do.languages: other languages your callers speak, from the same five;languagealways comes first. One language is the most reliable: speech recognition then never has to guess (speech_recognition: "en"). Two or more switch it tomulti, which follows the caller sentence by sentence, and the assistant switches with it.autois 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
greetinggives a422that says so). In test mode the greeting, the chat and the simulated calls use your company name without " (test)", just like live;modeandtest: truetell 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:
{
"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:
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, ... } }fromis optional; it must be a fictional number from+447700900000to+447700900999.caller_goalis 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_requiredwith 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 get402 test_free_replies_used.missedneeds 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 returns422 destination_not_allowedwith areason. 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
fromin 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.
segmentsin 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 gets403 verification_requiredwith anext_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_instructionsadds 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_windowcan narrow or move that, but never before 08:00 or after 21:00. Outside it you getoutside_calling_hourswithnext_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, withRetry-After). A call lasts at mostmax_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_allowedwith areason. - Voicemail:
on_voicemailishangup(default) orleave_message; the message starts with the same digital assistant sentence, then yourvoicemail_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 getinsufficient_balance(orbilling_blockedat 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.readyfollows; fetchGET /api/v1/calls/{id}for the summary and the transcript, and checksummary.textto see whether the appointment stands.no_answer: nobody picked up. Withanswered_by: "machine"an answering machine did, andvoicemail_leftsays whether the assistant left a message.busyorfailed(errorsays why, likeunallocated_number).
// 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
- 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. - For every message the customer types, your backend calls
POST /api/v1/conversations/{id}/messagesand gets the reply in the same response. - 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. - The summary follows as the webhook
conversation.summary.readyand inGET /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 -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.
{
"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 -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?" }'{
"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 -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.
// 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:
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, withRetry-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_balanceand the message is not answered; past the spending cap you get402 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 -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"] }'{
"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" />.
<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:
<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.
- 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.
- The first message starts the conversation, and the reply streams in word by word.
- 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.
- The summary arrives at your webhook as
conversation.summary.ready, withdata.conversation.channelset to"widget", and the conversation is in the portal and inGET /api/v1/conversationslike 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_reacheduntil 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 -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_balancewithbalance_eur,required_eurandauto_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(scopeproject: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 withtest: true. For agents, the MCP toolget_balancedoes the same. - Webhooks: subscribe to
balance.low(the balance dropped below the threshold),balance.topped_up(a top-up was added) andbalance.reload_failed(an automatic top-up failed) to keep an eye on it from your app.
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, scopecalls: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.