# Jobs and webhooks

Long-running work runs as a job: poll it, pass a webhook_url, or register webhook endpoints for job and billing events. Signatures, retries and resends.

Source: https://magistermarketing.com/docs/api/jobs-and-webhooks

Audits and AI visibility checks take from seconds to minutes, so they run as **jobs**. Starting one answers **202** right away:

```json
{
  "job_id": "8f14e45f-ceea-4e7a-9b1c-1f2a3b4c5d6e",
  "status": "running",
  "status_url": "/v1/jobs/8f14e45f-ceea-4e7a-9b1c-1f2a3b4c5d6e",
  "poll_after_seconds": 15,
  "credits_charged": 0,
  "request_id": "8f14e45f-ceea-4e7a-9b1c-1f2a3b4c5d6e"
}
```

A job is `running`, then `succeeded` or `failed`. It holds its credits while it runs, and counts toward your organization's 5 running jobs. A job that does not finish is closed as `failed` with `upstream_failed`: an AI visibility check or an AEO audit (`ai_visibility_check`, `aeo_check`) after 45 minutes, a full site audit (`site_audit`) after 24 hours. There are three ways to learn that it finished.

### 1. Poll the job

`GET /v1/jobs/{job_id}` (free, `read`) with **the key that started the job**. While it runs, wait `poll_after_seconds` between polls. When it finishes, the job carries its `result`, its final `credits_charged`, and for a success a `report_url` (the report as HTML; needs the key) and a `share_url` (the same report at a link anyone can open for 30 days). A failed job's `result` is the [error](https://magistermarketing.com/docs/api/errors).

### 2. Pass a webhook_url

Every job endpoint takes `webhook_url`: a public `https` address that receives the job's `job.succeeded` or `job.failed` event when it finishes. No setup is needed; it is signed like every delivery below.

### 3. Register webhook endpoints

In **Settings → Developers → Webhooks**, owners and admins register up to 10 `https` endpoints and choose their events. Registered endpoints receive:

| Event | When | `data` |
| --- | --- | --- |
| `job.succeeded` | A job finished successfully | The job, as `GET /v1/jobs/{job_id}` returns it, with its `report_url` and its `share_url` (a link anyone can open for 30 days) |
| `job.failed` | A job failed or timed out | The job, with the error in `result` |
| `balance.low` | The API balance fell below your alert threshold | `balance`, `threshold` |
| `auto_recharge.failed` | An auto-recharge charge was declined | `amount_usd`, `reason`, `auto_recharge_on`, `balance` |

A job's event goes to its own `webhook_url` (if any) and to every registered endpoint subscribed to it. Once the key that started the job is revoked or expires (a key is revoked when the person who created it leaves the organization), nothing more goes to the job's own `webhook_url`, not even a resend, because a key is often revoked after it leaks; registered endpoints, which only owners and admins add, still get the event. Every delivery is a `POST` with a JSON body of `event`, `created_at` and `data`:

```json
{
  "event": "job.succeeded",
  "created_at": "2026-01-01T00:01:03+00:00",
  "data": {
    "job_id": "8f14e45f-ceea-4e7a-9b1c-1f2a3b4c5d6e",
    "kind": "ai_visibility_check",
    "status": "succeeded",
    "result": {
      "score": 66
    },
    "created_at": "2026-01-01T00:00:00+00:00",
    "finished_at": "2026-01-01T00:01:02+00:00",
    "credits_charged": 10.12,
    "report_url": "https://api.magistermarketing.com/v1/jobs/8f14e45f-ceea-4e7a-9b1c-1f2a3b4c5d6e/report",
    "share_url": "https://api.magistermarketing.com/v1/reports/8f14e45f-ceea-4e7a-9b1c-1f2a3b4c5d6e.1769817663.0d4468395608c639b19dd7fc242ab86f"
  }
}
```

```json
{
  "event": "balance.low",
  "created_at": "2026-01-01T09:30:00.000Z",
  "data": { "balance": 812.4, "threshold": 1000 }
}
```

```json
{
  "event": "auto_recharge.failed",
  "created_at": "2026-01-01T09:30:00.000Z",
  "data": { "amount_usd": 50, "reason": "Your card was declined.", "auto_recharge_on": true, "balance": 312.5 }
}
```

### Verifying a delivery

Each delivery carries a `Magister-Signature` header, `t=<unix seconds>,v1=<hex>`, and a `Magister-Event` header naming the event (User-Agent `Magister-Webhooks/1`). `v1` is the hex HMAC-SHA256 of `<t>.<raw body>`, keyed with your organization's **signing secret** (Settings → Developers → Webhooks → Reveal; it starts with `whsec_`). One secret signs every delivery of the organization, its own `webhook_url`s included. Compute the HMAC over the body exactly as received, compare in constant time, and reject a timestamp more than five minutes old. In Node, as the Webhooks page shows:

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

// header: Magister-Signature: t=<unix seconds>,v1=<hex>
// rawBody: the body exactly as received, before any JSON parsing.
export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(String(header ?? '').split(',').map((part) => part.split('=')));
  const t = Number(parts.t);
  if (!parts.v1 || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  const given = Buffer.from(parts.v1);
  return given.length === expected.length && timingSafeEqual(Buffer.from(expected), given);
}
```

**Rotating the secret** — **Rotate** on the Webhooks page changes it at once; deliveries after that are signed with the new secret, so update your servers right away.

### Retries and resends

Answer with any 2xx within 10 seconds. Anything else (a 3xx, 4xx or 5xx, a timeout, or an address that no longer resolves to a public host; redirects are not followed) is retried 5 times over about a day: after 1 minute, 10 minutes, 1 hour, 6 hours, 16 hours. **Recent deliveries** on the Webhooks page shows each one's state and tries, and **Resend** sends one again. A delivery can arrive more than once (a resend, or a retry after your server answered too slowly), so make your handler idempotent: key on `event` and `data.job_id`.
