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.
Webhooks
Events, payloads, signatures, retries and managing endpoints.
A webhook is an HTTPS POST from Hypamoon to your server when something
happens: a call ends, an SMS is delivered, a form entry comes in, the balance
runs low. Webhooks save you from polling, and they're signed so you can verify
that they came from us. The examples below use a call; every event uses the
same envelope and the same signature.
Events
| Event | When |
|---|---|
call.completed |
A call has ended. |
call.summary.ready |
The summary of a call is ready. Arrives after call.completed. |
message.delivered |
An SMS reached the phone. |
message.failed |
An SMS could not be delivered. |
conversation.summary.ready |
A chat conversation was closed and its summary is ready. |
document.created |
A new document in a collection with webhook_on_insert, from the API or from a form on your site. |
balance.low |
The prepaid balance dropped below the threshold. |
balance.topped_up |
A top-up was added to the balance. |
balance.reload_failed |
An automatic top-up failed. |
order.paid |
An order on your site was paid (payments). |
email.sent |
An email was accepted for delivery. One event per recipient. |
email.delivered |
An email reached the recipient's mail server. |
email.delayed |
Delivery is delayed; it is retried automatically. |
email.bounced |
An email bounced, with a short reason. |
email.complained |
The recipient marked an email as spam. |
webhook.test |
Only when you request it with POST /webhooks/{id}/test. No subscription needed. |
You choose the events for each endpoint. Each call event is delivered at
most once per endpoint, not counting retries. Internal call legs
(forwarding to your team's phones) never trigger an event. A call without a
summary, such as a missed call, only sends call.completed.
In test mode, a simulated call sends the same
events to the test project's endpoints, signed the same way. The call in the
payload has test: true, so your handler can keep test traffic separate.
Email events
The email events come once per recipient, for every email of the project
(the Mail module). Pass app_id when you create the endpoint to get only the
emails of one app; such an endpoint takes only email events. The payload has
no subject and no content:
{
"id": "evt_3b2f6c1d9e8a4f7b8c6d5e4f3a2b1c0d",
"type": "email.bounced",
"created_at": "2026-10-15T09:30:05.000Z",
"data": {
"email_id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"app_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"recipient": "sam@example.org",
"recipient_field": "to",
"status": "bounced",
"reason": "The address does not exist or does not accept email.",
"occurred_at": "2026-10-15T09:30:04.000Z",
"test": false
}
}With a test key nothing is sent, but email.sent and email.delivered still
arrive with test: true, so you can try your receiver.
Payload
Every event has the same envelope:
| Field | |
|---|---|
id |
The event ID: evt_ followed by 32 hex characters. It stays the same on every retry, so use it to skip duplicates. |
type |
The event type. |
created_at |
When the event happened. |
data |
The event data, which depends on the type. |
call.completed:
{
"id": "evt_9a8b7c6d5e4f40312a1b2c3d4e5f6a7b",
"type": "call.completed",
"created_at": "2026-09-21T14:12:52.004Z",
"data": {
"call": {
"id": "7f1c2d3e-4b5a-4c6d-8e7f-901a2b3c4d5e",
"direction": "inbound",
"from": "+447700900456",
"to": "+3185•••••42",
"status": "ai_handled",
"created_at": "2026-09-21T14:10:02.118Z",
"started_at": "2026-09-21T14:10:02.118Z",
"answered_at": "2026-09-21T14:10:03.004Z",
"ended_at": "2026-09-21T14:12:51.770Z",
"duration_sec": 169,
"hangup_cause": "normal_clearing",
"has_summary": false,
"summary_ready_at": null,
"test": false
}
}
}call.summary.ready contains the call and the summary:
{
"id": "evt_0123456789abcdef0123456789abcdef",
"type": "call.summary.ready",
"created_at": "2026-09-21T14:13:20.000Z",
"data": {
"call": { "id": "7f1c2d3e-4b5a-4c6d-8e7f-901a2b3c4d5e", "has_summary": true },
"summary": {
"text": "Caller wants a table for four on Friday at 19:00.",
"topics": ["reservation"],
"sentiment": "positive",
"action_items": ["Confirm the reservation by text message"],
"next_step": "Send confirmation"
}
}
}(The call object above is abbreviated; a real event includes all
call fields.)
webhook.test:
{
"id": "evt_3f2a9c0d4b1e4f6a8c7d5e3b2a1f0e9d",
"type": "webhook.test",
"created_at": "2026-10-01T09:30:00.000Z",
"data": { "endpoint_id": "2b4d6f80-1a3c-4e5f-8a7b-9c0d1e2f3a4b" }
}No transcript in webhooks. A webhook is stored until its last retry, and a
transcript shouldn't outlive the call itself. Fetch it with
GET /calls/{data.call.id} (scope calls:read).
The payload is built at delivery time, not when the event happened. If
the call has been deleted under the retention policy by then, the delivery is
dropped (call_deleted) and doesn't count against your endpoint.
Request headers
| Header | |
|---|---|
Content-Type |
application/json |
User-Agent |
Hypamoon-Webhooks/1 |
Hypamoon-Event |
The event type, the same as type in the body. |
Hypamoon-Delivery |
The delivery ID. The same on every retry of this event to this endpoint. |
Hypamoon-Signature |
t=<unix seconds>,v1=<signature>. See below. |
Verify the signature
Verify every request before you act on it. The signature proves that the body came from Hypamoon, unchanged, and that it was sent recently.
Hypamoon-Signature: t=1790000000,v1=965ffc19a79f6f3381432314c98440585b22a59b9830fd17c062de7a918e4a36- Split the header on
,and each part on the first=. Taket(the timestamp in Unix seconds) and everyv1value. - Build the signed message:
t, a dot, and the raw request body exactly as you received it. Don't use JSON you parsed and serialized again: a single space or a reordered key breaks the signature. - Compute HMAC-SHA256 over that message with your endpoint secret as the
key, and write it as lowercase hex. The key is the whole secret,
including the
whsec_prefix, as UTF-8. - Compare it with each
v1value in constant time. One match is enough. There can be more than onev1value while a secret is being rotated. - Reject the request if
tdiffers from your clock by more than 5 minutes. That stops anyone from replaying a captured request later.
Respond to a failed check with 400 and don't process the body.
Code
Copy one of these. Both are tested against the same test vector, which is generated with the code that signs our webhooks.
// Verify the Hypamoon-Signature header of a webhook request.
// Node.js 18 or later, no dependencies.
//
// Header format: Hypamoon-Signature: t=<unix seconds>,v1=<hex>
// v1 is the HMAC-SHA256 of `${t}.${rawBody}`, keyed with your endpoint secret
// (the whole string, including the whsec_ prefix). The header can hold more
// than one v1 value; one match is enough.
import { createHmac, timingSafeEqual } from 'node:crypto'
export const DEFAULT_TOLERANCE_SEC = 5 * 60
/**
* Returns { ok: true, timestamp } or { ok: false, reason }.
*
* rawBody must be the exact request body as received (a string or a Buffer),
* not JSON that you parsed and serialized again.
*/
export function verifyHypamoonSignature(secret, header, rawBody, options = {}) {
if (!header) return { ok: false, reason: 'missing signature header' }
let timestamp = null
const signatures = []
for (const part of String(header).split(',')) {
const [key, ...rest] = part.trim().split('=')
const value = rest.join('=')
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value)
else if (key === 'v1' && value) signatures.push(value)
}
if (timestamp === null) return { ok: false, reason: 'missing timestamp' }
if (signatures.length === 0) return { ok: false, reason: 'missing v1 signature' }
const now = options.now ?? Math.floor(Date.now() / 1000)
const tolerance = options.toleranceSec ?? DEFAULT_TOLERANCE_SEC
if (Math.abs(now - timestamp) > tolerance) return { ok: false, reason: 'timestamp outside tolerance' }
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8')
const expected = Buffer.from(
createHmac('sha256', secret).update(`${timestamp}.`, 'utf8').update(body).digest('hex'),
'utf8',
)
const match = signatures.some((candidate) => {
const given = Buffer.from(candidate, 'utf8')
return given.length === expected.length && timingSafeEqual(given, expected)
})
return match ? { ok: true, timestamp } : { ok: false, reason: 'signature mismatch' }
}
/** The header value Hypamoon sends. Handy for tests of your own handler. */
export function signHypamoonPayload(secret, timestamp, rawBody) {
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8')
const hex = createHmac('sha256', secret).update(`${timestamp}.`, 'utf8').update(body).digest('hex')
return `t=${timestamp},v1=${hex}`
}"""Verify the Hypamoon-Signature header of a webhook request.
Python 3.8 or later, standard library only.
Header format: Hypamoon-Signature: t=<unix seconds>,v1=<hex>
v1 is the HMAC-SHA256 of f"{t}.{raw_body}", keyed with your endpoint secret
(the whole string, including the whsec_ prefix). The header can hold more
than one v1 value; one match is enough.
"""
import hashlib
import hmac
import time
from typing import Optional, Union
DEFAULT_TOLERANCE_SEC = 5 * 60
class SignatureError(Exception):
pass
def _hex_signature(secret: str, timestamp: int, raw_body: bytes) -> str:
message = str(timestamp).encode("utf-8") + b"." + raw_body
return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
def verify_hypamoon_signature(
secret: str,
header: Optional[str],
raw_body: Union[bytes, str],
tolerance_sec: int = DEFAULT_TOLERANCE_SEC,
now: Optional[int] = None,
) -> int:
"""Returns the signed timestamp, or raises SignatureError.
raw_body must be the exact request body as received, not JSON that you
parsed and serialized again.
"""
if not header:
raise SignatureError("missing signature header")
timestamp = None
signatures = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1" and value:
signatures.append(value)
if timestamp is None:
raise SignatureError("missing timestamp")
if not signatures:
raise SignatureError("missing v1 signature")
current = int(time.time()) if now is None else now
if abs(current - timestamp) > tolerance_sec:
raise SignatureError("timestamp outside tolerance")
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
expected = _hex_signature(secret, timestamp, body)
expected_bytes = expected.encode("utf-8")
if not any(hmac.compare_digest(expected_bytes, c.encode("utf-8")) for c in signatures):
raise SignatureError("signature mismatch")
return timestamp
def sign_hypamoon_payload(secret: str, timestamp: int, raw_body: Union[bytes, str]) -> str:
"""The header value Hypamoon sends. Handy for tests of your own handler."""
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
return f"t={timestamp},v1={_hex_signature(secret, timestamp, body)}"# Check a signature by hand. body.json must hold the exact raw body.
t=1790000000
printf '%s.' "$t" | cat - body.json \
| openssl dgst -sha256 -hmac "$HYPAMOON_WEBHOOK_SECRET" -hexGet the raw body
Most frameworks parse JSON before your code runs. Ask for the raw bytes instead:
| Framework | Raw body |
|---|---|
| Next.js route handler | await request.text() |
| Express | express.raw({ type: "application/json" }) on the route, then req.body is a Buffer |
| Fastify | addContentTypeParser("application/json", { parseAs: "buffer" }, ...) |
| Hono, Cloudflare Workers | await c.req.text() / await request.text() |
| Flask | request.get_data() |
| Django | request.body |
| FastAPI | await request.body() |
Test vector
Use this to test your own implementation. With
now = 1790000010, it must pass; change one character of the body or the
secret, and it must fail.
| Secret | whsec_test_vector_only_do_not_use_in_production |
| Timestamp | 1790000000 |
| Header | t=1790000000,v1=965ffc19a79f6f3381432314c98440585b22a59b9830fd17c062de7a918e4a36 |
Body (one line, UTF-8, no trailing newline):
{"id":"evt_0123456789abcdef0123456789abcdef","type":"call.summary.ready","created_at":"2026-09-21T14:13:20.000Z","data":{"call":{"id":"7f1c2d3e-4b5a-4c6d-8e7f-901a2b3c4d5e","direction":"inbound","from":"+447700900123","to":"+447700900001","status":"ai_handled","created_at":"2026-09-21T14:10:02.118Z","started_at":"2026-09-21T14:10:02.118Z","answered_at":"2026-09-21T14:10:03.004Z","ended_at":"2026-09-21T14:12:51.770Z","duration_sec":169,"hangup_cause":"normal_clearing","has_summary":true,"summary_ready_at":null},"summary":{"text":"Caller wants a table for four on Friday at 19:00 and asks about the café terrace.","topics":["reservation","terrace"],"sentiment":"positive","action_items":["Confirm the reservation by text message"],"next_step":"Send confirmation"}}}Respond quickly
Respond with any 2xx status within 10 seconds. Anything else counts as
a failed attempt: a 3xx (we don't follow redirects), a 4xx, a 5xx, a
timeout or a connection error. Keep the response body small; we don't read it.
Do the real work after you respond, for example by putting the event on a queue. Fetching a transcript or calling your CRM during the request is the most common cause of timeouts.
Retries
When an attempt fails, we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. That's 8 attempts over about 45 hours. After the eighth failed attempt, we give up on the delivery.
This means:
- The same event can arrive more than once, for example when your server
processed it but responded too late. Store the event
idand skip any you've already handled. - Events can arrive out of order. A retried
call.completedcan arrive aftercall.summary.ready. Usecreated_atand the callid, not arrival order.
Disabled endpoints
After 50 failed attempts in a row, an endpoint is disabled: active
becomes false and any pending deliveries are dropped. A single successful
delivery resets the count. The count appears as consecutive_failures in
GET /webhooks.
Fix your server, then re-enable the endpoint:
curl -X POST https://api.hypamoon.com/api/v1/webhooks/$ENDPOINT_ID/enable \
-H "Authorization: Bearer $HYPAMOON_API_KEY"await fetch(`https://api.hypamoon.com/api/v1/webhooks/${endpointId}/enable`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.HYPAMOON_API_KEY}` },
});requests.post(
f"https://api.hypamoon.com/api/v1/webhooks/{endpoint_id}/enable",
headers={"Authorization": f"Bearer {os.environ['HYPAMOON_API_KEY']}"},
)This resets the count to zero. Events from while the endpoint was disabled
aren't sent again; use GET /calls to catch up.
Manage endpoints
All endpoint routes require the webhooks:write scope.
| Request | |
|---|---|
POST /webhooks |
Create an endpoint with { "url", "events" }. Returns 201 with the secret, shown only once. |
GET /webhooks |
List the endpoints. Never includes the secret. |
DELETE /webhooks/{id} |
Delete an endpoint and its pending deliveries. Returns 204. |
POST /webhooks/{id}/enable |
Re-enable a disabled endpoint. |
POST /webhooks/{id}/test |
Send a webhook.test event now and report the result. |
URL requirements: https, a domain name (no IP address), the default port,
no username or password, at most 2048 characters, and it must resolve to a
public address. We check this when you create the endpoint and again on every
delivery (invalid_webhook_endpoint).
A project can have at most 10 endpoints
(webhook_limit_reached).
An endpoint in GET /webhooks:
{
"id": "2b4d6f80-1a3c-4e5f-8a7b-9c0d1e2f3a4b",
"url": "https://example.com/hooks/hypamoon",
"events": ["call.completed", "call.summary.ready"],
"active": true,
"secret_prefix": "whsec_Xy7Q",
"created_at": "2026-10-01T09:00:00.000Z",
"last_success_at": "2026-10-01T09:30:01.204Z",
"last_failure_at": null,
"consecutive_failures": 0
}The response to POST /webhooks has the same fields, plus secret.
Test an endpoint
POST /webhooks/{id}/test sends a webhook.test event immediately and
reports the result:
{
"delivery_id": "0c9b8a7d-6e5f-4a3b-2c1d-0e9f8a7b6c5d",
"event_id": "evt_3f2a9c0d4b1e4f6a8c7d5e3b2a1f0e9d",
"status": "failed",
"response_status": 500,
"error": "non_2xx_status"
}status |
|
|---|---|
delivered |
Your server responded with a 2xx. |
pending |
The first attempt failed; it will be retried like any other delivery. |
failed |
We gave up on the delivery. |
You can't test a disabled endpoint; re-enable it first
(webhook_endpoint_disabled).
Delivery errors
error in the test result is a short code you can branch on:
| Code | Meaning |
|---|---|
timeout |
No response within 10 seconds. |
connection_failed |
We could not connect (DNS, TLS or refused connection). |
non_2xx_status |
Your server responded, but not with a 2xx. Redirects count too. response_status has the code. |
not_public_host |
The URL now resolves to an address that is not public. |
invalid_url |
The URL is not a valid https URL. |
response_too_large |
The response body was too large. |
endpoint_disabled |
The endpoint was switched off before this delivery went out. |
call_deleted |
The call was deleted before delivery. Not counted as a failure. |
summary_deleted |
The summary was deleted before delivery. Not counted as a failure. |
secret_unreadable |
A problem on our side. Contact us with the delivery_id. |
delivery_failed |
Any other failure. |