> For the complete documentation index, see [llms.txt](https://docs.multiset.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.multiset.ai/fundamentals/credentials/webhooks.md).

# Webhooks

Receive a signed HTTP callback when a map, map set, or tracked object changes state, instead of polling the API for status.

A map takes minutes to process, and an object or map set takes as long as its inputs need. Until now the only way to find out when one finished was to poll [Map Details](/fundamentals/rest-api-docs/map-details.md) on a timer.

Webhooks invert that. Register one HTTPS endpoint, subscribe to the events you care about, and MultiSet POSTs a signed JSON payload to your server the moment a resource changes state.

{% hint style="info" %}
Webhooks are configured in the **Developer Portal** under **Credentials -> Webhooks**, or over the REST API. For the endpoint reference see [Webhooks API](/fundamentals/rest-api-docs/webhooks.md).
{% endhint %}

## Quick start

1. **Build a receiver.** A single route that accepts `POST`, returns `200` quickly, and can echo a value back from the request body. See [Verifying your endpoint](#verifying-your-endpoint).
2. **Register it.** Portal, or `POST /v1/webhook/endpoint` with a `url` and the `events` you want.
3. **Save the signing secret.** It is returned once at registration and never again.
4. **Verify the signature** on every request before you act on it. See [Verifying the signature](#verifying-the-signature).

One endpoint per account. To change where events go, update or delete the existing endpoint.

## Verifying your endpoint

Registration is not complete until MultiSet has proved your URL reaches real code. As soon as you register, a signed `endpoint.verify` event is POSTed to it:

```json
{
  "id": "evt_verify_9f2b1c9d4e7a6b3f",
  "type": "endpoint.verify",
  "apiVersion": "2026-09-01",
  "occurredAt": "2026-09-08T09:14:22.184Z",
  "livemode": false,
  "accountId": "66eb01b11d9025665dbbba79",
  "data": { "challenge": "ch_4f8a2b7c1d9e3f6a8b2c5d7e9f1a3b4c" }
}
```

Your endpoint must reply `200` **with a JSON body that echoes the challenge back**:

```json
{ "challenge": "ch_4f8a2b7c1d9e3f6a8b2c5d7e9f1a3b4c" }
```

{% hint style="warning" %}
A bare `200` is not enough. A misconfigured proxy, CDN, or WAF will return `200` for a URL that reaches nothing at all, so echoing the challenge is what proves something actually parsed the body. The response must be valid JSON with `challenge` at the top level.
{% endhint %}

The challenge has a **10 second** timeout. On success the endpoint becomes `active` and events start flowing. On failure it stays `pending_verification`, the response tells you why, and you can fix your server and re-run verification without re-registering.

An endpoint only receives events while its status is `active`. Events raised while it is `pending_verification` or `disabled` are **dropped, not queued**.

## Events

Twelve event types, across three entities:

| Entity   | `created`                               | `pending`                         | `active`                               | `failed`               |
| -------- | --------------------------------------- | --------------------------------- | -------------------------------------- | ---------------------- |
| `map`    | Map record created, upload not finished | Upload landed, processing started | Processing finished, map is queryable  | Processing failed      |
| `mapset` | Map set created                         | Overlap computation started       | At least one overlap succeeded         | The only target failed |
| `object` | Object record created                   | Upload landed, tracking started   | Tracking finished, object is queryable | Tracking failed        |

Subscribe with exact names, an entity wildcard, a state wildcard, or everything:

| `events` entry | Matches                                       |
| -------------- | --------------------------------------------- |
| `map.active`   | That one type                                 |
| `map.*`        | Every `map` event                             |
| `*.failed`     | Every failure, on all three entities          |
| `*`            | Everything, including event types added later |

{% hint style="warning" %}
**Ignore types you do not recognise.** New event types can appear under a wildcard you already subscribe to, so a receiver that throws on an unknown `type` will start failing on a release it was not changed for. Switch on the types you handle and return `200` for the rest.
{% endhint %}

A typo in a subscription is rejected at registration, so `map.faild` fails loudly instead of silently never firing.

### The `data` block

`data` is shaped per entity. Every event carries the resource's codes, its new `status`, and the status it came from:

{% tabs %}
{% tab title="map" %}

```json
{
  "mapId": "6710c22dee25fb59e9f3c67b",
  "mapCode": "MAP_BTTE1MOXYVT8",
  "mapName": "Warehouse Level 1",
  "status": "active",
  "previousStatus": "pending",
  "source": { "provider": "unity", "fileType": "zip" }
}
```

{% endtab %}

{% tab title="mapset" %}

```json
{
  "mapSetId": "6710c9a1ee25fb59e9f3c701",
  "mapSetCode": "MSET_W6NTBJ0ALVUN",
  "name": "Warehouse, all floors",
  "status": "active",
  "previousStatus": "pending"
}
```

{% endtab %}

{% tab title="object" %}

```json
{
  "objectId": "6710d4b2ee25fb59e9f3c74a",
  "objectCode": "OBJ_QQYKIBHXZE01",
  "objectName": "Forklift",
  "trackingType": "cad",
  "status": "active",
  "previousStatus": "pending"
}
```

{% endtab %}
{% endtabs %}

A `failed` event adds an `error` object:

```json
"error": {
  "type": "USER_ERROR",
  "message": "The uploaded ZIP archive could not be read. It may be incomplete, corrupted or password-protected, please re-upload it."
}
```

`type` is `USER_ERROR` when the input was the problem and re-uploading a good file will fix it, or `SYSTEM_ERROR` when it was ours. `message` is truncated to 500 characters.

## The delivered request

`POST` with `Content-Type: application/json` and these headers:

| Header                | Value                                                                |
| --------------------- | -------------------------------------------------------------------- |
| `Multiset-Event-Id`   | `evt_1a0636bc890d38c1f72d2b9d122`, unique per event. Dedupe on this. |
| `Multiset-Event-Type` | `map.active`                                                         |
| `Multiset-Signature`  | `t=1788876000,v1=5257a869e7...`                                      |
| `User-Agent`          | `Multiset-Webhooks/1.0`                                              |

The body wraps your `data` block in a fixed envelope:

```json
{
  "id": "evt_1a0636bc890d38c1f72d2b9d122",
  "type": "map.active",
  "apiVersion": "2026-09-01",
  "occurredAt": "2026-09-08T09:12:44.001Z",
  "livemode": true,
  "accountId": "66eb01b11d9025665dbbba79",
  "data": { }
}
```

`livemode` is `false` for verification and test events, `true` for real ones.

## Verifying the signature

Anyone who learns your URL can POST to it. The signature is what tells you a request came from MultiSet, so verify it before you act on the body.

`Multiset-Signature` holds a timestamp and an HMAC:

```
t=1788876000,v1=5257a869e7c8f2b1d4a6c9e3f7b2d8a1c4e6f9b3d7a2c5e8f1b4d6a9c3e7f2b5
```

`v1` is `HMAC_SHA256(secret, "{t}.{rawBody}")`, hex encoded. This is the same scheme Stripe uses, so if you already verify Stripe webhooks the code carries over.

{% hint style="warning" %}
Compute the HMAC over the **raw request body bytes**, exactly as received. If your framework parses JSON and you re-serialise it, key order or whitespace can change and the signature will not match. In Express, use `express.raw({ type: 'application/json' })` on the webhook route only.
{% endhint %}

{% tabs %}
{% tab title="Node.js" %}

```javascript
const crypto = require('node:crypto');
const express = require('express');

const app = express();
const SECRET = process.env.MULTISET_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

function verify(header, rawBody) {
  const parts = Object.fromEntries(
    String(header || '').split(',').map((kv) => kv.split('=').map((s) => s.trim()))
  );
  if (!parts.t || !parts.v1) return false;

  // Reject replays of an old, validly signed request.
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');

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

app.post('/multiset-hooks', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');

  if (!verify(req.get('Multiset-Signature'), raw)) {
    return res.status(400).send('bad signature');
  }

  const event = JSON.parse(raw);

  // Verification challenge: echo it back.
  if (event.type === 'endpoint.verify') {
    return res.status(200).json({ challenge: event.data.challenge });
  }

  // Acknowledge first, then do the slow work out of band.
  res.status(200).send('ok');

  switch (event.type) {
    case 'map.active':
      queue.add('map-ready', { mapCode: event.data.mapCode, eventId: event.id });
      break;
    case 'map.failed':
      queue.add('map-failed', { mapCode: event.data.mapCode, error: event.data.error });
      break;
    default:
      break; // Unknown types are fine, we already returned 200.
  }
});
```

{% endtab %}

{% tab title="Python" %}

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

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["MULTISET_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300


def verify(header: str, raw_body: bytes) -> bool:
    try:
        parts = dict(kv.strip().split("=", 1) for kv in (header or "").split(","))
        timestamp, received = parts["t"], parts["v1"]
    except (ValueError, KeyError):
        return False

    if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        SECRET, f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, received)


@app.post("/multiset-hooks")
def hooks():
    raw = request.get_data()

    if not verify(request.headers.get("Multiset-Signature"), raw):
        return "bad signature", 400

    event = json.loads(raw)

    if event["type"] == "endpoint.verify":
        return {"challenge": event["data"]["challenge"]}, 200

    if event["type"] == "map.active":
        enqueue_map_ready(event["data"]["mapCode"], event["id"])

    return "ok", 200
```

{% endtab %}
{% endtabs %}

The timestamp is there so you can reject a replay of an old but validly signed request. A tolerance of **300 seconds** matches what MultiSet uses.

## Responding

Return any `2xx` to acknowledge. Anything else, or no response within **10 seconds**, counts as a failure and is retried.

{% hint style="info" %}
**Acknowledge first, process afterwards.** Do the slow work in a background job rather than inside the request. A handler that spends 15 seconds building a mesh preview will time out, be retried, and build it again.
{% endhint %}

Redirects are **not** followed. A `301` or `302` is recorded as `redirect_refused`, because the destination has not been through the address checks the original URL passed. Register the final URL.

## Retries

A failed delivery is attempted **five times** with a widening gap:

| Attempt | Sent                       |
| ------- | -------------------------- |
| 1       | immediately                |
| 2       | 1 minute after attempt 1   |
| 3       | 5 minutes after attempt 2  |
| 4       | 30 minutes after attempt 3 |
| 5       | 2 hours after attempt 4    |

After the fifth attempt the delivery is marked `exhausted` and never tried again. The whole chain spans about **2 hours 36 minutes**, so a receiver that comes back inside that window still gets its events, and one that is down longer does not.

{% hint style="info" %}
An `exhausted` delivery is not recoverable through the API: there is no replay endpoint. If your receiver was down for longer than the retry window, reconcile by listing the resources you care about, for example with [Map details](/fundamentals/rest-api-docs/map-details.md), rather than waiting for the events to come back.
{% endhint %}

**Sustained failure disables the endpoint.** After **20 consecutive failed deliveries** the endpoint is set to `disabled` and delivery stops. Fix your server, then re-run verification to re-enable it, which also resets the failure count. Consecutive means across events, not attempts within one chain, so a single flaky hour will not disable you.

## Idempotency and ordering

**Delivery is at-least-once.** A timeout on your side after you already processed the event still counts as a failure, so the same event can arrive twice. Keep a record of `Multiset-Event-Id` values you have handled and ignore repeats.

**Events can arrive out of order.** Use `occurredAt`, set by the service that raised the event, rather than the order requests reach you. A `map.active` arriving before the `map.pending` for the same map is possible under retry.

## Test events

Send a sample event to your endpoint on demand from the portal, or with `POST /v1/webhook/endpoint/{id}/test`. It is signed exactly like a real event, carries `livemode: false`, is never retried, and reports back what your server answered, including the response code, the timing, and the first 2KB of your response body.

This is the fastest way to debug a receiver: it turns a silent integration into a request you can watch.

## Delivery log

Every attempt is recorded. Query it from the portal or with `GET /v1/webhook/delivery`, filtered by event type, status, date range, or `resourceId` to see everything that happened to one map. Fetch a single delivery by its event id to read the exact payload that was sent, plus one entry per attempt with the response code and timing.

Use it to answer "did you send it?" without guessing.

## Endpoint requirements

* **HTTPS** in production.
* **Publicly resolvable.** Private, loopback, and link-local addresses are rejected, and the address is re-checked at delivery time, not just at registration, so an endpoint whose DNS is later repointed inward stops being delivered to.
* **Responds within 10 seconds.**
* **No redirects.**
* **Returns JSON containing `challenge`** for the `endpoint.verify` event.

## Rotating the secret

`POST /v1/webhook/endpoint/{id}/rotate-secret` returns a new secret once. The previous secret **stops verifying immediately**, so there is no overlap window: deploy code that reads the new secret from configuration, then rotate, or accept a brief period of signature failures. Those deliveries are retried, so nothing is lost if you rotate and redeploy inside the retry window.

Rotate if the secret may have been exposed, for example in a log, a screenshot, or a committed config file.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.multiset.ai/fundamentals/credentials/webhooks.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
