> ## Documentation Index
> Fetch the complete documentation index at: https://docs.minimo.it/llms.txt
> Use this file to discover all available pages before exploring further.

# Inbound Webhook

> Register an endpoint, receive the normalized inbound envelope, and verify its signature

## How it works

When a customer messages one of your company's WhatsApp numbers, Minimo delivers a
`whatsapp.message.received` event to every active webhook you have registered. The body is a
**stable, provider-agnostic Minimo envelope** — not the raw Meta/360dialog payload — so your bot
codes against one shape regardless of which transport the number rides.

Delivery is **signed** (HMAC-SHA256), **retried** with exponential backoff, and **idempotent** per
message. You register and manage your endpoints through the API-key-scoped CRUD below.

<Info>
  This is different from the Meta-facing webhook described in [Inbound &
  Webhooks](/api-reference/messaging-channels/whatsapp/inbound-webhooks) (which Minimo manages to talk to Meta). Here,
  **your** backend is the receiver and you register its URL.
</Info>

***

## Register a webhook

```
POST https://api.minimo.it/public/v1/webhooks/registrations
```

**Permission:** `WhatsApp`.

### Request body

| Field | Type | Required | Description |
| - | - | - | - |
| `name` | string | yes | Human-readable label (min 1 char). |
| `url` | string | yes | HTTPS endpoint that receives the events. |
| `events` | string\[] | yes | Events to subscribe to. Currently only `whatsapp.message.received`. |
| `secret` | string | no | Shared secret used to HMAC-sign the body (min 16 chars). **Strongly recommended** — without it, events are delivered **unsigned**. |
| `method` | `"POST"` \| `"GET"` | no | HTTP method used to call your URL (default `POST`). |
| `headers` | `{ key, value }[]` | no | Extra static headers to send on every delivery (e.g. an auth header for your endpoint). |

```bash theme={null}
curl https://api.minimo.it/public/v1/webhooks/registrations \
  -H "Authorization: Bearer mn-abc123-xyz789" \
  -H "Content-Type: application/json" \
  -X POST \
  -d '{
    "name": "IUS legal bot",
    "url": "https://bot.example.com/minimo/webhook",
    "events": ["whatsapp.message.received"],
    "secret": "a-long-random-shared-secret-32chars"
  }'
```

### Response

```json theme={null}
{
  "id": 42,
  "name": "IUS legal bot",
  "url": "https://bot.example.com/minimo/webhook",
  "events": ["whatsapp.message.received"],
  "status": "active",
  "method": "POST",
  "headers": {},
  "hasSecret": true,
  "created": "2026-10-02T15:34:00.000Z"
}
```

<Warning>
  The `secret` is **never returned** by the API — only `hasSecret` tells you whether one is set. Store the secret when
  you create the registration; if you lose it, set a new one with an update.
</Warning>

***

## Manage registrations

<AccordionGroup>
  <Accordion title="List registrations — GET /public/v1/webhooks/registrations">
    Returns all of the company's webhook registrations (array of the public shape above).

    ```bash theme={null}
    curl https://api.minimo.it/public/v1/webhooks/registrations \
      -H "Authorization: Bearer mn-abc123-xyz789"
    ```
  </Accordion>

  <Accordion title="Get one — GET /public/v1/webhooks/registrations/:id">
    Returns a single registration, or `404` if it doesn't belong to your company.

    ```bash theme={null}
    curl https://api.minimo.it/public/v1/webhooks/registrations/42 \
      -H "Authorization: Bearer mn-abc123-xyz789"
    ```
  </Accordion>

  <Accordion title="Update — PUT /public/v1/webhooks/registrations/:id">
    All fields optional; only the ones you send are changed. Use `status` to pause/resume
    delivery without deleting the registration.

    | Field | Type | Description |
    | - | - | - |
    | `name` | string | New label. |
    | `url` | string | New endpoint. |
    | `secret` | string | Rotate the signing secret (min 16 chars). |
    | `events` | string\[] | New subscription list. |
    | `status` | `"active"` \| `"inactive"` | Pause or resume delivery. |
    | `method` | `"POST"` \| `"GET"` | Delivery method. |
    | `headers` | `{ key, value }[]` | Replace the static headers. |

    ```bash theme={null}
    curl https://api.minimo.it/public/v1/webhooks/registrations/42 \
      -H "Authorization: Bearer mn-abc123-xyz789" \
      -H "Content-Type: application/json" \
      -X PUT \
      -d '{ "status": "inactive" }'
    ```
  </Accordion>

  <Accordion title="Delete — DELETE /public/v1/webhooks/registrations/:id">
    Soft-deletes the registration; delivery stops immediately.

    ```bash theme={null}
    curl https://api.minimo.it/public/v1/webhooks/registrations/42 \
      -H "Authorization: Bearer mn-abc123-xyz789" \
      -X DELETE
    # { "success": true }
    ```
  </Accordion>

  <Accordion title="Delivery logs — GET /public/v1/webhooks/registrations/:id/logs">
    Returns the most recent delivery attempts (up to 100, newest first) — one row per attempt,
    including the final dead-letter row of a failed delivery. Each row carries the delivered
    `payload`, the receiver's `response`, the `statusCode`, an `errorMessage` when it failed, and
    `sentAt` / `createdAt` timestamps.

    ```bash theme={null}
    curl https://api.minimo.it/public/v1/webhooks/registrations/42/logs \
      -H "Authorization: Bearer mn-abc123-xyz789"
    ```
  </Accordion>
</AccordionGroup>

***

## The inbound envelope

Minimo `POST`s (or `GET`s, if you configured `method: "GET"`) this body to your URL. It is
**versioned** — bump of `version` signals a breaking change; additive fields do not bump it.

```json theme={null}
{
  "version": "1",
  "event": "whatsapp.message.received",
  "companyId": 16,
  "channel": {
    "provider": "meta",
    "phoneNumber": "393511234567",
    "channelId": 25
  },
  "contact": {
    "phone": "393391234567",
    "name": "Mario Rossi"
  },
  "message": {
    "wamid": "wamid.HBgLM...",
    "type": "text",
    "text": "Ciao, vorrei informazioni sul contratto",
    "isEcho": false,
    "timestamp": 1760000000,
    "timestampIso": "2025-10-09T07:33:20.000Z"
  },
  "raw": {}
}
```

### Fields

| Path | Type | Description |
| - | - | - |
| `version` | string | Envelope schema version. Currently `"1"`. Matches `MINIMO_WEBHOOK_ENVELOPE_VERSION`. |
| `event` | string | Always `whatsapp.message.received` (also in the `X-Minimo-Event` header). |
| `companyId` | number | The Minimo company (tenant) that owns the receiving number. |
| `channel.provider` | `"meta"` \| `"360dialog"` \| `"twilio"` | Which transport carried the message. The only provider-specific field — the rest of the shape is identical across providers. |
| `channel.phoneNumber` | string \| null | The business number (E.164 digits) that received the message. |
| `channel.channelId` | number \| null | Minimo `whatsapp_channel` id, when resolved. |
| `contact.phone` | string \| null | The sender's wa\_id / phone (digits). |
| `contact.name` | string \| null | The WhatsApp profile display name, when present. |
| `message.wamid` | string \| null | Provider message id. Also sent as the `X-Minimo-Delivery` header — use it to dedup. |
| `message.type` | string | Normalized type: `text`, `image`, `video`, `audio`, `document`, `reaction`, or a provider type passed through (e.g. `location`). |
| `message.text` | string \| null | Human/AI-facing text: the body, else a media caption, else a typed placeholder. `null` for a reaction. |
| `message.media` | object | Present only for an attachment. See below. |
| `message.reaction` | object | Present only when `type === "reaction"`. `{ targetWamid, emoji }` (`emoji` is `null` when a reaction was removed). |
| `message.isEcho` | boolean | `true` for a coexistence echo — a message the business sent from the WhatsApp app (not the API). |
| `message.timestamp` | number \| null | Provider timestamp, epoch seconds. |
| `message.timestampIso` | string \| null | Same timestamp, ISO-8601. |
| `raw` | object | **Escape hatch only.** The raw provider payload, verbatim. Not part of the stable contract and may change shape with the provider — always prefer the normalized fields above. |

### `message.media`

Present only when the message carries an attachment:

| Field | Type | Description |
| - | - | - |
| `kind` | `"image"` \| `"video"` \| `"audio"` \| `"document"` | Media kind (stickers are normalized to `image`). |
| `mimeType` | string \| null | Provider-reported MIME type. |
| `filename` | string \| null | Original filename, for documents. |
| `caption` | string \| null | Caption, when present. |
| `voice` | boolean | `true` for a push-to-talk voice note (OGG/Opus). |
| `providerMediaId` | string \| null | The provider's media id. Minimo never inlines media bytes — fetch them from the provider using this id. |

<Note>
  The envelope is delivered only for **inbound messages**. Delivery-status events (sent / delivered / read) and
  template-approval updates are **not** forwarded on this surface — see [Notes &
  Limits](/api-reference/whatsapp-transport/notes#current-limits).
</Note>

***

## Delivery headers

Every delivery carries these headers (plus any static `headers` you registered):

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-Minimo-Event` | The event name, e.g. `whatsapp.message.received`. |
| `X-Minimo-Delivery` | The message `wamid` — a stable, opaque delivery id. Dedup retries on this. |
| `X-Minimo-Webhook-Id` | The id of the registration that produced this delivery. |
| `X-Minimo-Signature` | HMAC-SHA256 of the request body, hex-encoded. **Only present when the registration has a `secret`.** |

***

## Verify the signature

The signature is the HMAC-SHA256 of the **exact raw request body** (the bytes on the wire),
hex-encoded, keyed by your registration's `secret`. Minimo serializes the body once and signs that
same string, so you must verify against the **raw** body — do not re-serialize a parsed object
first, or key reordering/spacing will break the check.

<CodeGroup>
  ```javascript Node theme={null}
  import crypto from 'crypto';

  // `rawBody` is the exact string received (e.g. from express.raw()).
  function verify(rawBody, signatureHeader, secret) {
    const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
  }

  app.post('/minimo/webhook', express.raw({ type: '*/*' }), (req, res) => {
    const sig = req.header('X-Minimo-Signature');
    if (!sig || !verify(req.body.toString('utf8'), sig, process.env.MINIMO_WEBHOOK_SECRET)) {
      return res.sendStatus(401);
    }
    const event = JSON.parse(req.body.toString('utf8'));
    // ... hand `event` to your bot, respond 2xx quickly ...
    res.sendStatus(200);
  });
  ```

  ```python Python theme={null}
  import hmac, hashlib

  def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
      expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature_header)

  # Flask example
  @app.post("/minimo/webhook")
  def webhook():
      sig = request.headers.get("X-Minimo-Signature", "")
      if not verify(request.get_data(), sig, MINIMO_WEBHOOK_SECRET):
          return "", 401
      event = request.get_json()
      # ... hand event to your bot, respond 2xx quickly ...
      return "", 200
  ```
</CodeGroup>

<Warning>
  If you register **without** a `secret`, deliveries arrive **unsigned** (no `X-Minimo-Signature` header). Always set a
  secret in production.
</Warning>

***

## Retries & idempotency

| Property | Value |
| - | - |
| Attempts | **5** before the delivery is dead-lettered. |
| Backoff | Exponential, base **5s**. |
| Per-attempt timeout | **10s** for the HTTP call to your URL. |
| Success | Any `2xx` response. Respond quickly — do the heavy work asynchronously. |
| Dead-letter | After the final failed attempt, a `webhook_logs` row records the failure (readable via the logs endpoint). |
| Idempotency | Enqueue is keyed on the message `wamid`, so a provider re-delivering the same message does not double-forward within the retry window. |

<Note>
  Retries and provider re-deliveries mean your endpoint **can** receive the same `wamid` more than
  once. Always dedup on `X-Minimo-Delivery` (the `wamid`) before acting on a message.

  A registration set to `inactive` (or deleted) is skipped on the next attempt — delivery stops
  cleanly, with no error and no retry.
</Note>

## Related

* [Send a Message](/api-reference/whatsapp-transport/send) — reply to what you receive
* [Read API](/api-reference/whatsapp-transport/conversations) — backfill history
* [Notes & Limits](/api-reference/whatsapp-transport/notes)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.