# Macro Webhooks: Run Events, Signatures and Custom Events

> Get a signed macro webhook POST from MAS when a run starts, completes, fails or stops, verify the X-MAS-Signature in Python or Node, and emit custom events.

Source: https://automationmacro.com/docs/webhooks (Integrate, updated 2026-09-04)

A macro webhook tells you what your macros did without opening the app. Macro Automation Studio (MAS) posts a signed JSON event to a URL you choose when a run starts, completes, fails or stops, and when a script emits its own event. Delivery comes from MAS's servers, so it works for emulators and cloud devices alike, whether or not the app is open. On a [cloud device](/cloud-devices) the run itself happens on MAS servers too, so the event arrives even when your computer is off.

## Before you start

- A receiver on the public internet with an HTTPS URL. Plain `http`, private addresses, loopback and link-local addresses are refused.
- MAS installed and signed in. Endpoints are created on the **Webhooks** page; they can also be managed over the [REST API](/docs/rest-api).
- A macro that runs on one of your devices. The app reports local runs; the cloud runner reports cloud runs.

## Create an endpoint

1. In MAS, open **Webhooks**.
2. Under **Add an endpoint**, enter the **Endpoint URL**.
3. Enter a **Description**.
4. Under **Send me**, tick the events you want: **Everything**, **Macro started**, **Macro completed**, **Macro failed**, **Macro stopped** or **Custom events from my scripts**. Pick at least one.
5. Under **Send me events from**, choose **All devices**, **Specific groups** or **Specific devices**, and pick the targets for a narrowed scope.
6. Click **Add endpoint**.
7. In the **Your signing secret** dialog, click **Copy**, store the secret in your receiver's configuration, then click **Done**.

The secret starts with `whsec_` and is shown once; the list shows only its first twelve characters. If you lose it, click the **Issue a new signing secret** icon on the endpoint's row. Deliveries are signed with the new secret straight away, so update the receiver first. An account can hold up to 10 endpoints. Everything on this page can also be done with an API key; see the [REST API](/docs/rest-api).

## Scope

Scope decides which devices an endpoint hears about. **All devices**, the default, covers every device on the account, including ones you add later. **Specific groups** covers the devices in the chosen groups, including devices added later. **Specific devices** covers exactly the devices you pick. An event that names no device passes every scope. A narrowed endpoint whose targets were all deleted delivers nothing; its row shows "Covers no devices" so you can fix it with **Devices**. See [Device groups](/docs/device-groups).

## Macro webhook events

| Event | When it fires |
|---|---|
| `macro.started` | A run began |
| `macro.completed` | A run ended normally |
| `macro.failed` | A run ended with an error or a non-zero exit code |
| `macro.stopped` | A run was stopped by you or by a stop request |
| `custom.<name>` | A script called `mas.webhook("<name>")` |
| `custom.*` | Every custom event, and no lifecycle events |
| `*` | Everything, including event types added later |
| `webhook.test` | The sample event sent by **Test**, delivered regardless of subscriptions |

Matching is exact per entry: an endpoint subscribed to `macro.failed` never receives `macro.completed`. Outcomes are separate types because most people running devices unattended want failures alone.

## The payload

Every delivery is a `POST` with a JSON body of four fields: `id`, `type`, `created_at` (UTC) and `data`. For a run event, `data` carries:

| Field | Meaning |
|---|---|
| `execution_id` | The run's id, the same one the runs API shows |
| `status` | `running`, `completed`, `failed` or `stopped` |
| `macro_name`, `macro_id` | The macro; `macro_id` is omitted when unknown |
| `device_name`, `device_id`, `device_port` | The device; `device_id` is omitted when unknown |
| `group_id` | The device group, when the device is in one |
| `execution_type` | `block` or `python` |
| `started_at` | Start time, UTC |
| `ended_at`, `duration_ms` | Present on `completed`, `failed` and `stopped` |
| `exit_code` | The script's exit code, when there is one |
| `error` | The error message on a failure |
| `proxy_exit_ip`, `proxy_country` | The exit IP and country of the proxy the run used, when a proxy was attached |

```json
{
  "id": "3c1c0a8e-9d2e-5f61-8b0a-2f7d4c1e9a10",
  "type": "macro.failed",
  "created_at": "2026-09-04T09:15:02Z",
  "data": {
    "execution_id": "a1b2c3d4",
    "status": "failed",
    "macro_name": "Daily tasks",
    "macro_id": 123,
    "device_name": "Farm 1",
    "device_id": 45,
    "device_port": 5555,
    "group_id": 7,
    "execution_type": "python",
    "started_at": "2026-09-04T09:10:00Z",
    "ended_at": "2026-09-04T09:15:02Z",
    "duration_ms": 302000,
    "exit_code": 1,
    "error": "ImageNotFoundError: image 42 is not in your library",
    "proxy_exit_ip": "203.0.113.7",
    "proxy_country": "US"
  }
}
```

For a custom event, `data` is the dictionary your script passed, plus `execution_id` and, on a local run, `device_name`, added by MAS so a script cannot forge its origin. For `webhook.test`, `data` carries `message`, `endpoint_id` and `requested_at`. The payload never contains credentials. Delivery is at least once and the `id` of a run event is stable across re-reports, so treat `id` as your dedupe key.

## Headers

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `MacroAutomationStudio-Webhooks/1` |
| `X-MAS-Signature` | `t=<unix timestamp>,v1=<hex HMAC-SHA256>` |
| `X-MAS-Event-Id` | The event id, the same as `id` in the body |
| `X-MAS-Event-Type` | The event type, the same as `type` in the body |

## Verify the automation webhook signature with HMAC

The signature is HMAC-SHA256 with your endpoint's secret over the string `{t}.{body}`, where `t` is the timestamp from the header and `body` is the raw request body, byte for byte. The timestamp sits inside the signed value, so a captured request cannot be replayed later. Reject a delivery whose timestamp is more than five minutes from your clock, compare digests in constant time, and verify the raw bytes before parsing the JSON.

Python, standard library only:

```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(secret: str, body: bytes, header: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        return False
    if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
        return False
    signed = t.encode() + b"." + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

Node, built-in `crypto` only:

```js
const crypto = require("node:crypto");

const TOLERANCE_SECONDS = 300;

function verify(secret, rawBody, header) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("=", 2))
  );
  const { t, v1 } = parts;
  if (!t || !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("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Frameworks that parse JSON before your handler runs usually offer a raw-body option; use it for this route.

## Webhook delivery and retry

Answer with any `2xx` within 10 seconds and do the work after you respond; a slower receiver is treated as failed. Anything else, including a timeout, a `4xx`, a `5xx` or a connection error, is a failed attempt. MAS makes up to 6 attempts per delivery, waiting 30 seconds, then 2 minutes, 5 minutes, 15 minutes and 30 minutes between them. Redirects are followed up to three times, each hop checked against the same address rules as the original URL.

A delivery is `pending` until it succeeds or gives up, `succeeded` on a `2xx`, `failed` after an attempt with retries remaining, and `exhausted` when all 6 attempts failed. After 15 exhausted deliveries in a row the endpoint is switched off automatically and shows **Auto-disabled** with the reason; a successful delivery resets the count. To recover, fix the receiver and switch the endpoint back on with its toggle, which clears the streak.

## Test and redeliver in the app

Click **Test** on an endpoint's row to queue a `webhook.test` event; it is sent even when the endpoint subscribes only to failures. **Recent deliveries** lists every attempt with its **Event**, **Status**, **When**, **Tries** and **Result**, and refreshes on its own while something is still sending. **View response** opens **Delivery detail** with **What we sent**, **Response from your endpoint**, the **Event ID** and a **Send again** button; **Retry** in a row does the same. Redelivery is your own recovery path after you fix a receiver.

## Custom events from a script

A macro can send its own event in one line. MAS's servers deliver it, signed, retried and logged like a run event, on local and cloud runs alike; the script never touches a secret or an HTTP client.

```python
import mas

result = mas.webhook("level.reached", {"level": 40, "account": "alt-3"})
mas.log(f"webhook {result['event_type']} queued to {result['queued']} endpoints")
```

The name is namespaced under `custom.`, so `level.reached` arrives as `custom.level.reached` and a script can never fake a `macro.failed`. Names may contain letters, numbers, dots, dashes and underscores, up to 64 characters; `data` is optional, JSON-serialisable and up to 50 keys. The call returns `event_id`, `event_type` and `queued`, the number of endpoints the event went to; 0 means nothing is subscribed yet. Subscribe an endpoint to **Custom events from my scripts** (`custom.*`), to `*`, or to the exact name over the API. If the event cannot be queued, the call raises `RuntimeError`. The function is listed with the rest of the namespace on the [apps and device page](/docs/sdk/apps-and-device).

## Troubleshooting

### Nothing arrives and the delivery log is empty

The event did not match the endpoint. Check the **Send me** subscriptions, then the scope; a filtered event leaves no delivery row on purpose. Click **Test** to confirm the receiver itself works.

### Delivery shows exhausted

All 6 attempts failed. Open **View response** to read the status code and body your receiver returned, fix the receiver, then click **Send again**. Common causes are a `4xx` from the URL, a handler slower than 10 seconds, and a certificate error.

### Endpoint shows Auto-disabled

Fifteen deliveries in a row were exhausted. Fix the receiver, switch the endpoint back on with its toggle, and confirm with **Test**.

### Signature does not verify

Verify the raw request bytes, not a re-serialized body, with the `t` value from the header and the secret from the latest **Your signing secret** dialog; issuing a new secret invalidates the old one at once. A clock more than five minutes off also fails the check.

### The URL is refused

Endpoint URLs must use `https` and resolve to a public address. Private, loopback, link-local and reserved addresses are rejected at save time and again at send time, including after a redirect.
