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.

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:

JSON
{
  "id": "evt_3b2f6c1d9e8a4f7b8c6d5e4f3a2b1c0d",
  "type": "email.bounced",
  "created_at": "2026-10-15T09:30:05.000Z",
  "data": {
    "email_id": "7c1e2d3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
    "app_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
    "recipient": "sam@example.org",
    "recipient_field": "to",
    "status": "bounced",
    "reason": "The address does not exist or does not accept email.",
    "occurred_at": "2026-10-15T09:30:04.000Z",
    "test": false
  }
}

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:

JSON
{
  "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:

JSON
{
  "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:

JSON
{
  "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
  1. Split the header on , and each part on the first =. Take t (the timestamp in Unix seconds) and every v1 value.
  2. 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.
  3. 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.
  4. Compare it with each v1 value in constant time. One match is enough. There can be more than one v1 value while a secret is being rotated.
  5. Reject the request if t differs 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.mjs
// 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.py
"""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" -hex

Get 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):

JSON
{"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 id and skip any you've already handled.
  • Events can arrive out of order. A retried call.completed can arrive after call.summary.ready. Use created_at and the call id, 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:

JSON
{
  "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:

JSON
{
  "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.
Hypamoon is the infrastructure your chat or agent builds on for a business. Hypamoon API, version 1.hypamoon.com