# Getting events

> Have Amomic-AI POST a signed JSON event to your server when a customer writes, a call ends, or a ticket opens — how to add an endpoint, test it, and what every delivery looks like.

Source: https://docs.amomic.in/webhooks/overview

---

A webhook is an HTTPS endpoint you own. When something happens in your workspace — a customer writes on WhatsApp, a call finishes, a visitor leaves their phone number — Amomic-AI POSTs a signed JSON body to that URL. Your server does the rest.

**Who can set one up:** owners and admins, on every plan, from **Developers → Webhooks** in the console. Members see an **Admins only** message on the Developers page. There is no public API for managing endpoints; the console is the only place.

This page covers adding an endpoint and the delivery contract: what arrives, in what shape, with which headers, and the rules your endpoint has to follow. [Verifying signatures](/webhooks/signatures) covers the security half, [Event reference](/webhooks/events) lists every payload field, and [Retries & failures](/webhooks/reliability) covers what happens when your server has a bad day. The console side is also covered, screen by screen, in [The Developers page](/console/developers#webhooks).

## Why not just poll

Polling is the obvious alternative and it does not work well here, for two reasons.

**Most of what you want has no list endpoint.** There is no `GET /api/v1/messages` and no call list. An inbound WhatsApp message and a finished call are not things you can enumerate. To notice an inbound message by polling you would have to list conversations, spot a changed `updated_at`, then fetch that conversation's messages and diff them against what you already stored — three round trips to learn one fact you could have been told.

**Your budget is 120 requests per minute per key** ([rate limits](/api/rate-limits)). Polling conversations every five seconds burns 12 of those every minute doing nothing, and still leaves you up to five seconds behind. A webhook costs you nothing while the workspace is quiet.

Polling is still the right tool for reconciliation — catching up after your endpoint was down. [Retries & failures](/webhooks/reliability#after-an-outage-replay-and-reconcile) has that pattern.

## Add an endpoint

![The Webhooks tab with two enabled endpoints showing Last 200, one listing four event types and one receiving every event type, and a disabled endpoint with Last 503 and a note that it was disabled automatically after 15 consecutive failed deliveries](https://docs.amomic.in/screens/developers/webhooks.light.webp)

::: steps

::: step Have an https URL ready

The URL must start with `https://` and be 8 to 500 characters long. Plain `http://` is refused when you save: Amomic-AI does not send workspace events over an unencrypted connection, not even to `localhost` through a tunnel.

For local development, use a tunnel that terminates TLS for you and gives you an `https://` hostname.
:::

::: step Open the dialog

In the console, open **Developers**, then the **Webhooks** tab, and click **Add endpoint** (or **Add your first endpoint** if you have none yet).
:::

::: step Enter the URL and pick events

Paste the URL into **Endpoint URL**. Under **Events to send**, tick the events this endpoint should receive. Each one shows a plain description and its event name.

Leaving every box unticked is meaningful: the endpoint then receives **every** event type, including types added later. Tick specific events when your receiver would be confused by anything else.
:::

::: step Copy the signing secret

Click **Add endpoint**. The **Copy your signing secret** dialog shows the endpoint's secret, which starts with `whsec_`. It is shown **once** and cannot be read back later — not in the console, not by support. Put it in your secret manager before you click **Done**.
:::

:::

![The Add a webhook endpoint dialog with an https URL and three events ticked: a customer sent a message, a message was delivered, read or failed, and a call was read](https://docs.amomic.in/screens/webhooks/add-endpoint.light.webp)

A few things the dialog does not do:

- **No description field.** Name your URL path after its purpose (`/hooks/amomic/dispatch`) if you run several.
- **No editing afterwards.** An endpoint's URL and events can't be changed in the console. To change them, add a new endpoint with the new settings, move your receiver over to its secret, then delete the old one.
- **No secret rotation.** If you lose the secret, add a new endpoint and delete the old one. Running both side by side for a while, with your receiver accepting either secret, avoids a gap.

`ticket.replied` appears in the list under its event name, with no description. It is a normal event; see [Event reference](/webhooks/events#ticket-replied).

::: note Give it half a minute
A new endpoint, an **Enable**, or a deletion can take up to about 30 seconds to apply to every event. An event that happens in the first seconds after you add an endpoint may not reach it.
:::

## Send a test event

Click **Test** on the endpoint. The console says `Test webhook queued. Open deliveries to see the response.` — the test goes through the same delivery path as a real event, so it is queued, not sent while you wait.

Click **Deliveries** to open **Recent deliveries**. The test shows as `ping` with `queued` until it has been sent; close and reopen the dialog to see the result — your server's status code, how long it took, and the error text if it failed.

![Recent deliveries: a queued ping, a message.received delivered with 200 and its payload expanded, one ticket.created event tried three times — no response, then 502, then 200 on attempt 3 — a lead.created refused with 404, and a call.outcome delivered with 200](https://docs.amomic.in/screens/webhooks/deliveries.light.webp)

The test body is an ordinary envelope with the type `ping`:

```json title="A test event"
{
  "id": "evt_6c1f9a2e7b4d08f3a5e1c9b2",
  "type": "ping",
  "created_at": "2026-10-14T05:49:12Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": { "message": "Amomic-AI webhook test" }
}
```

It is signed like any other delivery and has a fresh `id` every time, so a receiver that deduplicates still processes each test. `ping` is not in the event list: you can't subscribe to it or opt out of it, and an endpoint subscribed to only `call.ended` still receives it. Your router needs a default branch that answers `200` for a `type` it doesn't know.

::: warning Tests only reach enabled endpoints
A test on a **Disabled** endpoint is queued but never sent, and stays `queued` in **Deliveries**. Click **Enable** first, then **Test**. A test that fails counts toward the [automatic switch-off](/webhooks/reliability#fifteen-failed-events-disables-the-endpoint), just like a real event.
:::

## The delivery

Every event is a single `POST` with a JSON body.

### Envelope

```json
{
  "id": "evt_1b7d4f9a2c6e80b3d5f7a9c1",
  "type": "message.received",
  "created_at": "2026-10-14T05:44:00Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "channel": "whatsapp",
    "message_id": "wamid.HBgMOTE5NzY3NDUyOTA4FQIAEhgUM0E4RjI1QjcxQzE5RDA0RTZBNTMA",
    "from": "+919767452908",
    "type": "text",
    "event_id": "evt_01JF9X2K6N0Q4T8W2Y6A0C4E8G"
  }
}
```

That is a complete `message.received`. It does not carry the message text or a conversation id — see [what `message.received` carries](/webhooks/events#message-received) and how to fetch the rest.

```params
[
  {"name": "id", "type": "string", "required": true, "description": "<code>evt_</code> followed by 24 hex characters. The same for every endpoint the event goes to, every retry, and every Replay. The value you deduplicate on. Also sent as the <code>Amomic-Event-Id</code> header."},
  {"name": "type", "type": "string", "required": true, "description": "One of the twenty event types, or <code>ping</code>. See <a href=\"/webhooks/events\">Event reference</a>."},
  {"name": "created_at", "type": "string", "required": true, "description": "When the event was sent out, in UTC, second precision, literal <code>Z</code> suffix — <code>2026-10-14T05:44:00Z</code>. REST resources use a different shape, with microseconds and a <code>+00:00</code> offset."},
  {"name": "tenant_id", "type": "string", "required": true, "description": "Your workspace id, <code>ten_</code>-prefixed. Useful if one receiver serves several workspaces."},
  {"name": "data", "type": "object", "required": true, "description": "The event payload. The fields depend on <code>type</code>; each event sends a fixed list of fields, and a field with no value is left out rather than sent as <code>null</code>."},
  {"name": "data.event_id", "type": "string", "required": false, "description": "An internal id, on every event except <code>ping</code>. It is <strong>not</strong> the top-level <code>id</code>. Deduplicate on the top-level <code>id</code>."}
]
```

::: warning `data.event_id` is not the dedupe key
Two ids ship in every event and they are not interchangeable. The top-level `id` matches `Amomic-Event-Id` and is the correct dedupe key. `data.event_id` is an internal identifier that rides along inside the payload. Keying your dedupe table on the wrong one is a bug you will not notice until the first retry.
:::

### Headers

```
Content-Type: application/json
User-Agent: Amomic-AI-Webhooks/<version>
Amomic-Signature: t=1792042140,v1=8f3ac91b0d7e4f2a6c5b8e1d0a9f3c72e4b6d8a1c3e5f7091b2d4a6c8e0f2a4b6
Amomic-Event-Id: evt_1b7d4f9a2c6e80b3d5f7a9c1
Amomic-Event-Type: message.received
Amomic-Delivery-Attempt: 1
```

| Header | Meaning |
|---|---|
| `Amomic-Signature` | Timestamped HMAC-SHA256 over the raw body, `t=<unix seconds>,v1=<lowercase hex>`. Verify it on every request — see [Verifying signatures](/webhooks/signatures). |
| `Amomic-Event-Id` | The envelope's `id`. Lets you deduplicate before you parse the body. |
| `Amomic-Event-Type` | The envelope's `type`. Lets you route before you parse the body. |
| `Amomic-Delivery-Attempt` | `1` to `4`. Above `1` means an earlier attempt failed and this is a retry. A Replay from the console starts again at `1`. See [Retries & failures](/webhooks/reliability). |
| `User-Agent` | Always begins `Amomic-AI-Webhooks/`. Handy for filtering your access logs; useless as proof of anything, since anyone can send it. |

::: danger Headers are not proof
The signature is the only thing that establishes an event came from Amomic-AI. Every other header can be forged by anyone who guesses your URL. Do not act on `Amomic-Event-Type` before you have verified `Amomic-Signature`.
:::

### The body is plain ASCII

The body is compact JSON — no spaces after `:` or `,` — and any character outside ASCII is escaped. A customer named प्रिया arrives as `"प्रिया"`. Every JSON parser turns that back into the right text; the point is only that you must verify the bytes you received, not a re-serialised copy. See [Verify against the raw bytes](/webhooks/signatures#verify-against-the-raw-bytes).

## Rules your endpoint has to follow

### Return 2xx immediately, then work

Each attempt has a **10-second total timeout** (5 seconds to connect). If your handler is still busy at 10 seconds, the connection is cut and the attempt counts as failed — even if your code goes on to finish. You then get a retry of the same event and process it twice.

The pattern that survives:

1. Verify the signature.
2. Write the event somewhere durable — a queue, a table, a log.
3. Return `200`.
4. Do the real work in a worker.

Anything that calls a third party, sends an email or writes to a slow database belongs in step 4.

### Deliveries are at-least-once

The same `id` can arrive more than once — a retry after a timeout, or a Replay from the console. Keep a table of processed event ids and check it first:

```sql
-- the whole mechanism
INSERT INTO amomic_events (event_id, received_at) VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- zero rows affected means you have seen this event; acknowledge and stop.
```

### Events are not ordered

Events are sent independently and retried independently. A `read` status can arrive before its `delivered`, and `call.outcome` can arrive before `call.ended`. Order by the envelope's `created_at`, never by arrival.

### Redirects are not followed

A `301`, `302` or `307` is a failed delivery and is not retried. Amomic-AI does not follow redirects — following one would mean sending a signed payload to a host you never registered.

If your platform redirects `http` to `https`, or `/hooks/amomic` to `/hooks/amomic/`, register the final URL.

### One event, one POST

There is no batching. Ten inbound messages are ten separate requests, each with its own signature and its own id.

## A minimal receiver

Both examples do the same four things: read the raw bytes, verify, enqueue, acknowledge. [Verifying signatures](/webhooks/signatures) has the fully commented version for four languages.

::: codegroup

```javascript Node.js
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.AMOMIC_WEBHOOK_SECRET; // the whsec_… string

function verify(rawBody, header, secret, toleranceSeconds = 300) {
  if (!header) return false;

  const fields = {};
  for (const piece of header.split(",")) {
    const i = piece.indexOf("=");
    if (i > 0) fields[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
  }
  if (!fields.t || !fields.v1) return false;

  const timestamp = Number.parseInt(fields.t, 10);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(fields.t + ".")
    .update(rawBody)          // Buffer — the bytes exactly as received
    .digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(fields.v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw gives you a Buffer. Do NOT register express.json() ahead of this
// route — if it runs first, the stream is consumed and the bytes are gone.
app.post("/hooks/amomic", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verify(req.body, req.get("Amomic-Signature"), SECRET)) {
    return res.status(400).send("bad signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));

  try {
    await queue.enqueue(event.id, event); // durable and fast — no business logic here
  } catch (err) {
    return res.status(500).send("could not enqueue"); // a 500 gets retried
  }

  return res.status(200).send("ok");
});

app.listen(8080);
```

```python Python
import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, Request, Response

app = FastAPI()
SECRET = os.environ["AMOMIC_WEBHOOK_SECRET"]  # the whsec_… string


def verify(raw_body: bytes, header: str | None, secret: str, tolerance_s: int = 300) -> bool:
    if not header:
        return False

    fields = {}
    for piece in header.split(","):
        key, sep, value = piece.partition("=")
        if sep:
            fields[key.strip()] = value.strip()

    raw_t, received = fields.get("t"), fields.get("v1")
    if not raw_t or not received:
        return False

    try:
        timestamp = int(raw_t)
    except ValueError:
        return False
    if abs(int(time.time()) - timestamp) > tolerance_s:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        raw_t.encode("utf-8") + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, received)


# Take the Request, not a Pydantic model. A typed body parameter makes FastAPI
# parse the JSON for you, and the bytes you need for verification are lost.
@app.post("/hooks/amomic")
async def amomic_webhook(request: Request) -> Response:
    raw = await request.body()

    if not verify(raw, request.headers.get("Amomic-Signature"), SECRET):
        return Response(status_code=400)

    event = json.loads(raw)

    try:
        await queue.enqueue(event["id"], event)  # durable and fast
    except Exception:
        return Response(status_code=500)  # a 500 gets retried

    return Response(status_code=200)
```

:::

::: warning The single most common integration bug
Verify against the **raw request bytes**. Parsing the body and serialising it again produces different bytes and a signature that never matches, however correct your HMAC code is. [Verifying signatures](/webhooks/signatures) explains how to avoid it in each framework.
:::

## Troubleshooting

| What you see | What it means |
|---|---|
| The test stays `queued` | The endpoint is **Disabled**, or the dialog has not been reopened since the test was sent. Enable it, test again, and reopen **Deliveries**. |
| `no response` with `ReadTimeout` | Your server took longer than 10 seconds. Acknowledge first, work afterwards. |
| `no response` with a connection or TLS error | Nothing reached your application: DNS, firewall, or an expired certificate. |
| A `3xx` status | A redirect. Register the final URL. |
| `400` from your own verifier | Usually a re-serialised body or the wrong secret. See [Troubleshooting](/webhooks/signatures#troubleshooting-signature-does-not-match). |
| Real events never arrive, the test works | The endpoint isn't subscribed to that event, or the event isn't sent for what you did — check [Event reference](/webhooks/events). |

## Next

::: cards

::: card Verify a delivery
[Verifying signatures](/webhooks/signatures) — the header format, the tolerance window, and verifiers in four languages.
:::

::: card What each event contains
[Event reference](/webhooks/events) — all twenty event types with the fields each one really sends.
:::

::: card When your endpoint fails
[Retries & failures](/webhooks/reliability) — the retry ladder, auto-disable, Replay, and catching up after an outage.
:::

::: card The console screens
[The Developers page](/console/developers) — keys, webhooks, logs and quickstart, as the console shows them.
:::

:::
