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

# Webhooks

> Get notified when conversations arrive, status changes, or messages come in.

Webhooks let you act on events in miniDesk without polling. When a conversation
is created, a message arrives, a status changes or a conversation is assigned,
we send a POST request to a URL you choose.

## Events

miniDesk sends four event types. Every payload reuses the `/v1` shapes, so what
a trigger gives you matches what the API returns: `data.conversation` is the
conversation as `GET /v1/tickets/{id}` returns it, without its messages, and
`data.message` is one item of that conversation's messages.

Every payload has the same envelope:

| Field | What it is |
| - | - |
| `id` | The event id, `evt_` and 32 hex characters. The same across retries and resends, so you can drop duplicates. |
| `type` | One of the four types below, or `ping` for a test event. |
| `created_at` | When the event happened, ISO 8601 in UTC. |
| `data.conversation` | The conversation (not on `ping`). |
| `data.message` | The message, on `conversation.created` (the first message) and `message.received`. |
| `data.change` | What changed, on `conversation.status_changed` and `conversation.assigned`. |
| `data.actor` | Who caused it, when known: `type` is `member`, `customer`, `ai`, `integration` (an API key) or `system`, with an `id` and a `name`. A name, never an email. |

Ids are plain UUIDs. A payload never contains internal notes, AI drafts or
reasoning, knowledge base text, attachment links, or team members' emails.

### conversation.created

Fires when a customer starts a new conversation, from any channel including the
API. It is sent after triage has set category, priority and language (or failed,
or found AI turned off), so you can filter on those fields.

It does not fire for:

* conversations the team starts, from the dashboard or through the API with
  `first_message_from: "team"`;
* spam, and mail sent by machines (auto-replies, mailing lists);
* conversations brought in by the importer.

A chat held until the visitor leaves an email address fires when it is
released. If a person restores a conversation our spam filter had filed, it
fires then. A conversation a person filed as spam and later restores does not
fire again: it was announced when it arrived.

```json theme={null}
{
  "id": "evt_5f2c8e1a9b7d4c3e8f6a1b2c3d4e5f60",
  "type": "conversation.created",
  "created_at": "2026-10-16T21:02:11.482Z",
  "data": {
    "conversation": {
      "id": "6f1c2b9e-3d4a-4e8f-9b1a-2c3d4e5f6a7b",
      "reference": "ACME-1042",
      "status": "new",
      "priority": "normal",
      "category": "billing",
      "source": "email",
      "language": "en",
      "requester": {
        "id": "0b7e4c1d-8a2f-4d3e-9c5b-6a7f8e9d0c1b",
        "email": "sam@customer.com",
        "name": "Sam",
        "is_verified": false
      },
      "created_at": "2026-10-16T21:02:09.117Z",
      "updated_at": "2026-10-16T21:02:11.204Z"
    },
    "message": {
      "id": "a3d5f7b9-1c2e-4f6a-8b0d-9e1f2a3b4c5d",
      "author_type": "requester",
      "author_name": "Sam",
      "body": "I need help with my invoice.",
      "created_at": "2026-10-16T21:02:09.117Z"
    },
    "actor": {
      "type": "customer",
      "id": "0b7e4c1d-8a2f-4d3e-9c5b-6a7f8e9d0c1b",
      "name": "Sam"
    }
  }
}
```

The first message is included in `conversation.created`. It does not also fire
`message.received`.

### message.received

Fires when a customer writes in an existing conversation. Never fires for team
replies, AI replies, internal notes, or messages sent through the API.

Does not fire for conversations filed as spam, or while a chat is held for the
visitor's email address.

```json theme={null}
{
  "id": "evt_8b4c0d3f2e9a5b7c1d6e8f0a2b4c6d8e",
  "type": "message.received",
  "created_at": "2026-10-16T21:15:30.051Z",
  "data": {
    "conversation": {
      "id": "6f1c2b9e-3d4a-4e8f-9b1a-2c3d4e5f6a7b",
      "reference": "ACME-1042",
      "status": "open",
      "priority": "normal",
      "category": "billing",
      "source": "email",
      "language": "en",
      "requester": {
        "id": "0b7e4c1d-8a2f-4d3e-9c5b-6a7f8e9d0c1b",
        "email": "sam@customer.com",
        "name": "Sam",
        "is_verified": false
      },
      "created_at": "2026-10-16T21:02:09.117Z",
      "updated_at": "2026-10-16T21:15:30.012Z"
    },
    "message": {
      "id": "c7e9a1b3-5d2f-4a6c-9e8b-1f0a2b3c4d5e",
      "author_type": "requester",
      "author_name": "Sam",
      "body": "I'm still waiting for a reply.",
      "created_at": "2026-10-16T21:15:30.012Z"
    },
    "actor": {
      "type": "customer",
      "id": "0b7e4c1d-8a2f-4d3e-9c5b-6a7f8e9d0c1b",
      "name": "Sam"
    }
  }
}
```

### conversation.status\_changed

Fires when the status changes, whoever changed it: a team member, the
customer replying, our AI, an API key, or the system.

```json theme={null}
{
  "id": "evt_9c5d1e4f3a0b6c8d2e7f9a1b3c5d7e9f",
  "type": "conversation.status_changed",
  "created_at": "2026-10-16T21:30:45.338Z",
  "data": {
    "conversation": {
      "id": "6f1c2b9e-3d4a-4e8f-9b1a-2c3d4e5f6a7b",
      "reference": "ACME-1042",
      "status": "resolved",
      "priority": "normal",
      "category": "billing",
      "source": "email",
      "language": "en",
      "requester": {
        "id": "0b7e4c1d-8a2f-4d3e-9c5b-6a7f8e9d0c1b",
        "email": "sam@customer.com",
        "name": "Sam",
        "is_verified": false
      },
      "created_at": "2026-10-16T21:02:09.117Z",
      "updated_at": "2026-10-16T21:30:45.301Z"
    },
    "change": {
      "field": "status",
      "from": "open",
      "to": "resolved"
    },
    "actor": {
      "type": "member",
      "id": "e2f4a6b8-0c1d-4e3f-8a5b-7c9d1e2f3a4b",
      "name": "Alex"
    }
  }
}
```

### conversation.assigned

Fires when the assignee changes, including when a conversation becomes
unassigned.

`change.field` is `"assignee"`. `change.from` and `change.to` are member ids, as
`GET /v1/members` lists them, and `null` means nobody. The conversation object
does not carry the assignee: read it from `change.to`.

```json theme={null}
{
  "id": "evt_0d6e2f5a4b1c7d9e3f8a0b2c4d6e8f0a",
  "type": "conversation.assigned",
  "created_at": "2026-10-16T21:45:12.907Z",
  "data": {
    "conversation": {
      "id": "6f1c2b9e-3d4a-4e8f-9b1a-2c3d4e5f6a7b",
      "reference": "ACME-1042",
      "status": "open",
      "priority": "normal",
      "category": "billing",
      "source": "email",
      "language": "en",
      "requester": {
        "id": "0b7e4c1d-8a2f-4d3e-9c5b-6a7f8e9d0c1b",
        "email": "sam@customer.com",
        "name": "Sam",
        "is_verified": false
      },
      "created_at": "2026-10-16T21:02:09.117Z",
      "updated_at": "2026-10-16T21:45:12.870Z"
    },
    "change": {
      "field": "assignee",
      "from": null,
      "to": "e2f4a6b8-0c1d-4e3f-8a5b-7c9d1e2f3a4b"
    },
    "actor": {
      "type": "member",
      "id": "e2f4a6b8-0c1d-4e3f-8a5b-7c9d1e2f3a4b",
      "name": "Alex"
    }
  }
}
```

## Headers

Every webhook request carries these headers:

* `Content-Type: application/json`
* `User-Agent: miniDesk-Webhooks/1`
* `miniDesk-Event-Id`: the event id from the payload
* `miniDesk-Event-Type`: the event type
* `miniDesk-Signature`: the signature of the payload (see below)

## Verifying the signature

Each webhook request is signed with your webhook's secret, the whole
`whsec_...` string. Verify the signature to make sure the request came from
miniDesk.

The `miniDesk-Signature` header looks like this:

```
t=1697480531,v1=3f9a1b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a
```

`t` is the Unix time, in seconds, when the request was sent. `v1` is the
HMAC-SHA256 hex digest of the string `<t>.<raw body>`, keyed with your secret.

Verify against the raw body, exactly as it arrived. Parsing the JSON and
serialising it again changes the bytes, and the signature will not match. In
Express, for example, read the body with `express.raw({ type: "application/json" })`.

Both examples below return `false` and never throw: on a missing or malformed
header, a `t` that is not a number, a `v1` that is not 64 hex characters, or a
request older than 5 minutes. They compare in constant time, on two values of
the same length.

### Node.js example

```javascript theme={null}
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

// rawBody: the request body as received, a string or a Buffer.
export function verifyWebhook(rawBody, header, secret) {
  if (typeof header !== "string") return false;
  if (typeof rawBody !== "string" && !Buffer.isBuffer(rawBody)) return false;

  const parts = {};

  for (const part of header.split(",")) {
    const index = part.indexOf("=");

    if (index > 0) parts[part.slice(0, index).trim()] = part.slice(index + 1).trim();
  }

  const { t, v1 } = parts;

  if (typeof t !== "string" || !/^\d{1,12}$/.test(t)) return false;
  if (typeof v1 !== "string" || !/^[0-9a-f]{64}$/i.test(v1)) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;

  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  const received = Buffer.from(v1, "hex");

  // timingSafeEqual throws on buffers of different lengths, so check first.
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
```

### Python example

```python theme={null}
import hashlib
import hmac
import re
import time

TOLERANCE_SECONDS = 300


def verify_webhook(raw_body, header, secret):
    """raw_body: the request body as received, as bytes."""
    if not isinstance(header, str):
        return False
    if isinstance(raw_body, str):
        raw_body = raw_body.encode()
    if not isinstance(raw_body, bytes):
        return False

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

    t = parts.get("t", "")
    v1 = parts.get("v1", "")

    if not re.fullmatch(r"[0-9]{1,12}", t):
        return False
    if not re.fullmatch(r"[0-9a-fA-F]{64}", v1):
        return False
    if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()

    # Both are 64 characters here.
    return hmac.compare_digest(v1.lower(), expected)
```

Refuse requests older than 5 minutes, as the examples do: they may be replays.

## Delivery and retries

We POST to your URL with a 10 second timeout. Redirects are not followed. Any
2xx response is a success.

If a delivery fails, we try again after 1 minute, 5 minutes, 30 minutes, 2
hours and 6 hours. Six tries in total, over about 9 hours. After that the
delivery is marked as failed.

The delivery history in the dashboard keeps every try: the time, the HTTP code
and how long it took. What your server answered is kept as plain text, cut to
2 KB. Response headers are not kept. When there was no HTTP answer, the history
shows one of four reasons, never the raw network error:

* **Could not connect**: the address did not answer, or the name no longer
  resolves.
* **Timed out**: no answer within 10 seconds.
* **Address not allowed**: the URL now points somewhere we refuse to call (see
  [URL requirements](#url-requirements)). We check again before every try.
* **Could not sign**: the webhook's stored secret could not be opened, so
  nothing was sent. Delete the webhook and create it again.

A failed delivery can be sent again from the dashboard with **Resend**: one more
try with the same event id and payload. A paused webhook cannot resend or send a
test event until it is resumed.

## Pausing

When a webhook has had no successful delivery for 3 days while deliveries kept
failing, we pause it and email the workspace owners once. A paused webhook gets
no new deliveries.

Resume it from **Settings → Webhooks** in the dashboard. A webhook created by an
API key can also be resumed by registering the same URL again with that key
(see [Create a webhook](#create-a-webhook)).

An owner can also pause a webhook by hand, for example after an unexpected
"webhook added" email. That pause stays until an owner resumes it. An API key
can neither resume nor delete a webhook an owner paused: registering the URL
again or deleting the webhook answers `409`. Otherwise the key could delete the
paused webhook and register a fresh, active one.

## At least once, no ordering

Delivery is at least once. You may receive the same event more than once.
Deduplicate on the `id` field.

Events may arrive out of order. A quick second customer message can arrive
before `conversation.created` while triage runs. Use `created_at` to order
events.

## Loop prevention

A webhook created by an API key never receives events caused by that same key.
A workflow that creates a conversation, changes a status, or reassigns through
its own key does not trigger itself.

Webhooks added in the dashboard belong to no key and receive everything. Events
caused by a different key do fire, and `data.actor` names that key.

## URL requirements

* `https://` only. Any port is allowed.
* No user name or password in the URL.
* Public addresses only. We refuse private and internal addresses,
  `minidesk.ai` and its subdomains, and our own servers' addresses. We check
  when you save the webhook and again before every try, on the address we
  actually connect to.
* Up to 10 webhooks per workspace.

Webhook URLs are secrets: a Zapier catch hook or an n8n webhook URL works on its
own, with no other key. We never log webhook URLs, and the emails we send owners
name only the host (`hooks.example.com`), never the full URL.

## Managing webhooks

### From the dashboard

Go to **Settings → Webhooks** (owners only). Owners see every webhook in the
workspace, whoever added it. Add a webhook by pasting a URL and choosing which
events to receive. The secret is shown once, when the webhook is created. After
that it is masked. Lost secret: delete the webhook and create it again.

![The Add webhook window: a URL and two of the four events ticked](https://mintcdn.com/minidesk/-b91qe10vakJKfoG/images/webhooks/add-webhook.png?fit=max\&auto=format\&n=-b91qe10vakJKfoG\&q=85\&s=e2de2ad5718c371bbcc702dab97b400d)

### From the API

An API key can register webhooks with the `/v1/webhooks` endpoints, and list,
read and delete only the ones it registered. A webhook added in the dashboard
or by another key is not listed, and its id answers `404`.

Workspace owners get an email every time an API key adds a webhook, naming the
key and the receiving host. Registering the same URL again does not email them
again.

Revoking an API key deletes the webhooks it created.

#### Create a webhook

```bash theme={null}
curl -X POST https://api.minidesk.ai/v1/webhooks \
  -H "Authorization: Bearer $MINIDESK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/minidesk",
    "events": ["conversation.created", "message.received"]
  }'
```

Returns `201 Created` with the webhook and its secret:

```json theme={null}
{
  "id": "5c9e2d7a-4b1f-4e3a-8d6c-0f1e2a3b4c5d",
  "url": "https://hooks.example.com/minidesk",
  "events": ["conversation.created", "message.received"],
  "status": "active",
  "created_at": "2026-10-16T22:00:00.000Z",
  "secret": "whsec_q3J0b2tLwz8vR1mN4pX7sY2aB6cD9eF0gH3iJ5kL8mN"
}
```

Keep the secret. It is never returned after creation.

If this key already registered the exact same URL, no second webhook is made.
You get the same webhook back with `200 OK`: same id and delivery history, the
events you sent replace the old ones, a new secret, and the webhook is active
again if it was paused for failing. If an owner paused it by hand, the answer
is `409 Conflict` and the webhook stays paused:

```json theme={null}
{
  "error": {
    "code": "conflict",
    "message": "An owner paused this webhook. Ask them to resume it in miniDesk."
  }
}
```

#### List webhooks

```bash theme={null}
curl https://api.minidesk.ai/v1/webhooks \
  -H "Authorization: Bearer $MINIDESK_TOKEN"
```

Returns the webhooks this key registered, newest first. No secret, masked or
otherwise.

```json theme={null}
{
  "data": [
    {
      "id": "5c9e2d7a-4b1f-4e3a-8d6c-0f1e2a3b4c5d",
      "url": "https://hooks.example.com/minidesk",
      "events": ["conversation.created", "message.received"],
      "status": "active",
      "created_at": "2026-10-16T22:00:00.000Z"
    }
  ]
}
```

#### Get a webhook

```bash theme={null}
curl https://api.minidesk.ai/v1/webhooks/5c9e2d7a-4b1f-4e3a-8d6c-0f1e2a3b4c5d \
  -H "Authorization: Bearer $MINIDESK_TOKEN"
```

Returns the same object as one item of the list, without a secret. `status:
"paused"` means nothing is being sent. Returns `404` if the webhook does not
belong to this API key.

#### Delete a webhook

```bash theme={null}
curl -X DELETE https://api.minidesk.ai/v1/webhooks/5c9e2d7a-4b1f-4e3a-8d6c-0f1e2a3b4c5d \
  -H "Authorization: Bearer $MINIDESK_TOKEN"
```

Returns:

```json theme={null}
{
  "deleted": true
}
```

Deleting a webhook removes it permanently. Its delivery history goes with it.

If an owner paused the webhook by hand, the answer is `409 Conflict` and the
webhook stays. Only an owner can resume or delete it, in **Settings → Webhooks**:

```json theme={null}
{
  "error": {
    "code": "conflict",
    "message": "An owner paused this webhook. Ask them to resume or delete it in miniDesk."
  }
}
```

#### Error responses

A refused URL returns `400 Bad Request`, with one message whatever the reason:

```json theme={null}
{
  "error": {
    "code": "validation_failed",
    "message": "Use a public https address. This one is not allowed.",
    "field": "url"
  }
}
```

The reason can be a URL that is not `https://`, carries a user name or
password, points at a private address or at one of ours, or does not resolve.

`409 Conflict` means the workspace already has 10 webhooks, or an owner paused
the webhook you are registering again or deleting. `404 Not Found` means the
webhook does not exist or belongs to another key.

## Test events

Send a test event from **Settings → Webhooks** in the dashboard. The test event
has type `ping` and carries no conversation:

```json theme={null}
{
  "id": "evt_1a2b3c4d5e6f47a8b9c0d1e2f3a4b5c6",
  "type": "ping",
  "created_at": "2026-10-16T22:30:00.000Z",
  "data": {}
}
```

The result appears in the delivery history like any other delivery.


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