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

# Briefing exports

> Request an asynchronous CSV export of the management briefing, poll it until it is ready, download it with a short-lived link and verify its checksum.

A briefing export is a CSV file of the management briefing, the same digest that `GET /v1/analytics/briefing` returns: alerts, delivery exceptions, pending approvals, consumption and cycle times. muveya builds the file in the background, so the flow has three steps: request it, poll it, download it.

`POST /v1/analytics/exports` is the only operation on `/v1` that writes anything, and what it writes is the export job itself. It changes nothing in your operation.

## Who can do it

A key with the scope `analytics:read` (see [Scopes](/docs/en/api-reference/scopes)). The same scope covers requesting, polling and downloading.

## The flow

```mermaid theme={null}
sequenceDiagram
  participant App as Your server
  participant API as api.muveya.com
  App->>API: POST /v1/analytics/exports
  API-->>App: 202, status pending, exportId
  loop every few seconds
    App->>API: GET /v1/analytics/exports/exportId
    API-->>App: 200, status pending or building
  end
  API-->>App: 200, status ready, downloadUrl, checksum
  App->>App: download the file and verify its SHA-256
```

## Step 1: request the export

<ParamField body="period" type="string" default="daily">
  The briefing cadence: `daily` or `weekly`. Any other value is refused with `400` and the code `common.invalid_request`. The body is optional; without it you get a daily briefing. Send no other field.
</ParamField>

<ParamField header="Accept-Language" type="string" default="en">
  The language of the `label` column in the CSV: `en`, `es` or `pt`. It is fixed when you request the export, because the file is built later. Metric keys, dimensions, units, values and evidence ids are the same in every language.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.muveya.com/v1/analytics/exports \
    -H "Authorization: Bearer $MUVEYA_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Accept-Language: es" \
    -d '{"period": "weekly"}'
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch("https://api.muveya.com/v1/analytics/exports", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MUVEYA_API_KEY}`,
      "Content-Type": "application/json",
      "Accept-Language": "es",
    },
    body: JSON.stringify({ period: "weekly" }),
  });
  const job = await response.json();
  console.log(response.status, job.exportId, job.status);
  ```

  ```python Python theme={null}
  import os
  import requests

  response = requests.post(
      "https://api.muveya.com/v1/analytics/exports",
      headers={
          "Authorization": f"Bearer {os.environ['MUVEYA_API_KEY']}",
          "Accept-Language": "es",
      },
      json={"period": "weekly"},
      timeout=30,
  )
  job = response.json()
  print(response.status_code, job["exportId"], job["status"])
  ```
</CodeGroup>

```json 202 Accepted theme={null}
{
  "exportId": "3f6c2a9e-0b1d-4c7a-9e55-1a2b3c4d5e6f",
  "status": "pending",
  "reportType": "briefing",
  "period": "weekly"
}
```

Keep the `exportId`: it is the only handle to the job.

<Note>
  There is no idempotency key for this operation. Every `POST` creates a new, independent export job. If a request times out before you receive the `exportId`, send it again; the extra job is harmless and is deleted with the others after 7 days.
</Note>

## Step 2: poll until it is ready

Call `GET /v1/analytics/exports/{exportId}` every few seconds (for example, every 3 seconds). Each poll counts toward your [rate limit](/docs/en/api-reference/rate-limits).

| `status` | Meaning | What to do |
| - | - | - |
| `pending` | The job was accepted and is waiting to be built. | Keep polling. |
| `building` | The file is being composed. | Keep polling. |
| `ready` | The file is stored. The response now includes `byteSize`, `checksum`, `downloadUrl` and `downloadExpiresAt`. | Download it (step 3). |
| `failed` | The file could not be built. The response includes `error`. | See [When an export fails](#when-an-export-fails). |

```json 200 OK (building) theme={null}
{
  "exportId": "3f6c2a9e-0b1d-4c7a-9e55-1a2b3c4d5e6f",
  "status": "building",
  "reportType": "briefing",
  "period": "weekly"
}
```

```json 200 OK (ready) theme={null}
{
  "exportId": "3f6c2a9e-0b1d-4c7a-9e55-1a2b3c4d5e6f",
  "status": "ready",
  "reportType": "briefing",
  "period": "weekly",
  "byteSize": 1874,
  "checksum": "5d41c0e8b7a24f3e9c6b1d0a8f7e6d5c4b3a29180f1e2d3c4b5a69788796a5b4",
  "downloadUrl": "https://files.example.invalid/management-briefing-weekly.csv?signature=EXAMPLE",
  "downloadExpiresAt": "2026-09-17T14:05:00.000Z"
}
```

<ResponseField name="exportId" type="string" required>
  The job id you received when you requested the export.
</ResponseField>

<ResponseField name="status" type="string" required>
  `pending`, `building`, `ready` or `failed`.
</ResponseField>

<ResponseField name="reportType" type="string" required>
  The report in the file. Always `briefing` today.
</ResponseField>

<ResponseField name="period" type="string" required>
  `daily` or `weekly`, as requested.
</ResponseField>

<ResponseField name="byteSize" type="integer">
  The size of the file in bytes. Present once `ready`.
</ResponseField>

<ResponseField name="checksum" type="string">
  The SHA-256 of the file, as 64 lowercase hexadecimal characters. Present once `ready`.
</ResponseField>

<ResponseField name="downloadUrl" type="string">
  A signed download link. Present only when `ready`. A new link is issued on every poll.
</ResponseField>

<ResponseField name="downloadExpiresAt" type="string">
  When the current `downloadUrl` stops working (UTC).
</ResponseField>

<ResponseField name="error" type="string">
  A machine-readable reason. Present when `failed`. A job that failed once and was retried automatically keeps this field while `building` and after it becomes `ready`, so always check `status` first.
</ResponseField>

## Step 3: download and verify

* The `downloadUrl` works for **5 minutes** after the poll that returned it. It carries its own signature: do not add your API key or any other header to the download request.
* Download right away. Do not store the link or share it: while it is valid, anyone who has it can download the file. If it expired, poll the job again to get a fresh link.
* The file name is `management-briefing-daily.csv` or `management-briefing-weekly.csv`.
* Compare the SHA-256 of the bytes you received with `checksum`, and their length with `byteSize`. If they differ, download again.

<CodeGroup>
  ```bash cURL theme={null}
  curl -s -o management-briefing-weekly.csv "$DOWNLOAD_URL"
  sha256sum management-briefing-weekly.csv
  # The first value must equal the checksum field
  ```

  ```javascript JavaScript theme={null}
  import { createHash } from "node:crypto";
  import { writeFile } from "node:fs/promises";

  const file = Buffer.from(await (await fetch(job.downloadUrl)).arrayBuffer());
  const sha256 = createHash("sha256").update(file).digest("hex");
  if (sha256 !== job.checksum || file.length !== job.byteSize) {
    throw new Error("Export download is incomplete; download it again");
  }
  await writeFile("management-briefing-weekly.csv", file);
  ```

  ```python Python theme={null}
  import hashlib
  import requests

  file = requests.get(job["downloadUrl"], timeout=60).content
  if hashlib.sha256(file).hexdigest() != job["checksum"] or len(file) != job["byteSize"]:
      raise RuntimeError("Export download is incomplete; download it again")
  with open("management-briefing-weekly.csv", "wb") as handle:
      handle.write(file)
  ```
</CodeGroup>

## Complete example

<CodeGroup>
  ```javascript JavaScript theme={null}
  import { createHash } from "node:crypto";
  import { writeFile } from "node:fs/promises";

  const BASE_URL = "https://api.muveya.com";
  const auth = { Authorization: `Bearer ${process.env.MUVEYA_API_KEY}` };
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  async function exportBriefing(period = "weekly", language = "en") {
    const created = await fetch(`${BASE_URL}/v1/analytics/exports`, {
      method: "POST",
      headers: { ...auth, "Content-Type": "application/json", "Accept-Language": language },
      body: JSON.stringify({ period }),
    });
    if (created.status !== 202) throw new Error(`Export request failed: ${created.status}`);
    let job = await created.json();

    for (let attempt = 0; attempt < 100 && job.status !== "ready"; attempt += 1) {
      if (job.status === "failed") throw new Error(`Export failed: ${job.error}`);
      await sleep(3000);
      const polled = await fetch(`${BASE_URL}/v1/analytics/exports/${job.exportId}`, { headers: auth });
      if (polled.status === 429) {
        await sleep(Number(polled.headers.get("retry-after") ?? "1") * 1000);
        continue;
      }
      if (!polled.ok) throw new Error(`Export poll failed: ${polled.status}`);
      job = await polled.json();
    }
    if (job.status !== "ready") throw new Error("Export is taking too long; poll it again later");

    const file = Buffer.from(await (await fetch(job.downloadUrl)).arrayBuffer());
    if (createHash("sha256").update(file).digest("hex") !== job.checksum) {
      throw new Error("Checksum mismatch; poll again for a fresh link and retry");
    }
    await writeFile(`management-briefing-${period}.csv`, file);
    return job;
  }

  console.log(await exportBriefing("weekly", "es"));
  ```

  ```python Python theme={null}
  import hashlib
  import os
  import time
  import requests

  BASE_URL = "https://api.muveya.com"
  session = requests.Session()
  session.headers["Authorization"] = f"Bearer {os.environ['MUVEYA_API_KEY']}"

  def export_briefing(period="weekly", language="en"):
      created = session.post(
          f"{BASE_URL}/v1/analytics/exports",
          json={"period": period},
          headers={"Accept-Language": language},
          timeout=30,
      )
      if created.status_code != 202:
          raise RuntimeError(f"Export request failed: {created.status_code}")
      job = created.json()

      for _ in range(100):
          if job["status"] == "ready":
              break
          if job["status"] == "failed":
              raise RuntimeError(f"Export failed: {job['error']}")
          time.sleep(3)
          polled = session.get(f"{BASE_URL}/v1/analytics/exports/{job['exportId']}", timeout=30)
          if polled.status_code == 429:
              time.sleep(int(polled.headers.get("Retry-After", "1")))
              continue
          polled.raise_for_status()
          job = polled.json()
      else:
          raise RuntimeError("Export is taking too long; poll it again later")

      file = requests.get(job["downloadUrl"], timeout=60).content
      if hashlib.sha256(file).hexdigest() != job["checksum"]:
          raise RuntimeError("Checksum mismatch; poll again for a fresh link and retry")
      with open(f"management-briefing-{period}.csv", "wb") as handle:
          handle.write(file)
      return job

  print(export_briefing("weekly", "es"))
  ```
</CodeGroup>

## The CSV file

* Encoding UTF-8 with a byte order mark, so spreadsheet programs open accents correctly. Lines end with CRLF.
* One header row, then one row per briefing figure.
* Cells that contain a comma, a quote or a line break are quoted. A cell that starts with `=`, `+`, `-`, `@`, a tab or a carriage return is prefixed with a single quote (`'`), so that a spreadsheet never runs it as a formula.

| Column | Content |
| - | - |
| `metric` | The source metric, for example `LOW_STOCK`, `APPROVAL_BACKLOG`, `CONSUMPTION_TREND` or `ORDER_CYCLE_TIME`. |
| `dimension` | The part of a multi-row metric: an age band, a cycle stage or a consumption series. Empty for single-value metrics. |
| `label` | The human label, in the language you requested. |
| `value` | The number. **Empty** when the metric had no sample, never `0`. |
| `unit` | The unit of `value`: `count`, `orders`, `movements`, `hours`, or the catalog unit of a supply (`box`, `unit`, and so on). |
| `evidenceId` | The reproducible reference of the source metric, the same one the analytics operations return. |

```csv management-briefing-weekly.csv (excerpt, Accept-Language: en) theme={null}
metric,dimension,label,value,unit,evidenceId
LOW_STOCK,,Low stock alerts,3,count,LOW_STOCK
EXPIRING_STOCK,,Expiring stock alerts,1,count,EXPIRING_STOCK
PENDING_ORDERS,,Pending approvals,2,count,PENDING_ORDERS
APPROVAL_BACKLOG,within_24h,Approval backlog: under 24 hours,2,orders,APPROVAL_BACKLOG
CONSUMPTION_TREND,movements,Consumption movements,48,movements,CONSUMPTION_TREND:granularity=week
CONSUMPTION_TREND,66d0a1f0c0ffee0000000401:box,"Nitrile gloves, size M",14,box,CONSUMPTION_TREND:granularity=week
ORDER_CYCLE_TIME,submit_to_dispatch,Cycle time: submission to dispatch,26.5,hours,ORDER_CYCLE_TIME
ORDER_CYCLE_TIME,receive_to_close,Cycle time: receipt to close,,hours,ORDER_CYCLE_TIME
```

The figures are the same as those of `GET /v1/analytics/briefing` for the same period at the moment the file was built. Supply names appear as they are in your catalog. For what each metric means, see [Management analytics](/docs/en/reports/management-analytics).

## What the file never contains

The export is redacted by construction: it holds counts, quantities, durations, labels and evidence ids. It never contains costs, order values, patient references or the names of team members. It covers every location and warehouse of the dental clinic, like any other API key read.

## What muveya records

* Each request is recorded in the audit trail of your dental clinic, with the API key as the actor, the report type and the period.
* When the file is built, the audit trail also records its size and its SHA-256 checksum, so the file you hold can be checked against the record later.
* Export jobs are kept for **7 days** after the request. After that, polling the job returns `404` with the code `common.not_found`: request a new export.

## When an export fails

| `error` | Meaning | What to do |
| - | - | - |
| `schedule_failed` | The job could not be queued for building. You never poll such a job: the `POST` itself fails with `500` (`common.internal_error`) and returns no `exportId`. | Request a new export. |
| `processing_error` | Building the file failed. muveya retries it automatically, so the job can go back to `building` and end `ready`. | Keep polling for a few minutes. If it stays `failed`, request a new export. |

If new exports keep failing, write to [team@muveya.com](mailto:team@muveya.com) with the `exportId`.

## Errors

| Status | `code` | Cause |
| - | - | - |
| `400` | `common.invalid_request` | `period` is not `daily` or `weekly`, or the body is not valid JSON |
| `401` | `api_keys.invalid` | See [Authentication](/docs/en/api-reference/authentication#authentication-failures) |
| `403` | `api_keys.scope_missing` | The key lacks `analytics:read` |
| `404` | `common.not_found` | Polling an unknown `exportId`, an export of another dental clinic, or one older than 7 days |
| `429` | `common.too_many_requests` | See [Rate limits](/docs/en/api-reference/rate-limits) |
| `500` | `common.internal_error` | The export could not be queued. Request a new export |

## Related pages

* [Management analytics](/docs/en/reports/management-analytics)
* [Scopes](/docs/en/api-reference/scopes)
* [Errors](/docs/en/api-reference/errors)


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