Deployment
Dashboard Export API

Dashboard Export API

⚠️

This feature is still in Beta. APIs and behaviour may change. Reach out to the Embeddable team if you have questions or run into issues.

The Dashboard Export API renders a published Embeddable dashboard server-side and returns it as a print-quality PDF — the same charts, tables, filters and data the user would see in the browser, captured to a file.

Use it to:

  • generate scheduled reports (e.g. a monthly PDF per customer)
  • attach dashboards to emails, invoices or external documents
  • archive a point-in-time snapshot of a dashboard

Because you export with a security token, the PDF is scoped by exactly the same row-level security, filters and context as the embedded dashboard — a user only ever exports data they are allowed to see.

Open APIs In Bruno

The Export API is asynchronous: you start a job, then poll for its result. See Timing & throughput before you build against it — the first exports after a quiet period can take a couple of minutes to begin.

How it works

Create an export-enabled token

Call the Tokens API with export: true. This token carries the RLS, filters and context for the export, and can only be used with this API.

Start an export job

POST the token (plus any viewport, variables or context) to start a job. The API immediately returns a jobId — it does not wait for the PDF to render.

Poll for the result

GET the job by its jobId until its status becomes SUCCEEDED (or FAILED).

Download the PDF

A succeeded job returns a short-lived downloadUrl (it expires after about 15 minutes). Download the file directly from it.

Requirements

Before you export, make sure:

  • You have an export-enabled token — create it with export: true. A normal embedding token is rejected, and an export token cannot be used for normal embedding.
  • The dashboard is published — export targets a saved version of an Embeddable, resolved from the token.
  • Your components meet the minimum SDK versions — see below.

SDK versions

Dashboard export requires the dashboard's components to have been built with these SDK package versions (or newer):

PackageMinimum version
@embeddable.com/react2.13.6
@embeddable.com/sdk-core4.4.0
@embeddable.com/sdk-react4.3.6

If the components were built and pushed with older versions, the export is rejected with a 400. To fix it, update these packages in your components project, push your components again, and re-publish the dashboard.

1. Start an export job

Each request starts one export — one dashboard, one PDF. To generate many, call this endpoint once for each dashboard; Embeddable renders them in parallel.

Endpoint

POST https://api.<region>.embeddable.com/api/v1/dashboard-exports

Learn more about specifying region here.

Example Request

// Important: Always call this server-side, never from client-side code
fetch('https://api.<region>.embeddable.com/api/v1/dashboard-exports', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
    'Authorization': `Bearer ${apiKey}` // Keep your API key secure
  },
  body: JSON.stringify({
    //
    // required
    //
    token: '<export-token>', // a security token created with `export: true`
    format: 'PDF',
 
    //
    // optional
    //
    viewport: {
      width: 1280,  // render width in px (defaults to 1280)
      height: 800,  // optional; acts as a minimum height (see below)
    },
    variables: {          // override the dashboard's variables
      'date-range': { from: '2025-01-01', to: '2025-12-31' }
    },
    clientContext: {      // same clientContext you'd pass when embedding
      theme: 'light',
      timezone: 'Europe/Berlin'
    },
    styles: {             // CSS applied to the dashboard element
      background: '#ffffff'
    },
    animationSettleMs: 500 // extra wait for animations before capture
  })
})
  .then((res) => res.json())
  .then(console.log);

Example Response

{
  "jobId": "4a752795-ee35-4e6d-9cf9-9972174a28cf"
}

The API responds with 202 Accepted and a Retry-After header — the job has been queued, not yet rendered. Store the jobId and move on to polling.

Parameters

  • token (required) — a security token created with export: true. It determines which dashboard and saved version is exported, and applies all row-level security, filters and roles.
  • format (required) — the output format. Currently only "PDF" is supported.
  • viewport (optional) — the size the dashboard is rendered at:
    • width — render width in pixels. Defaults to 1280. This is the layout width, so it controls how the responsive dashboard arranges itself.
    • height (optional) — a minimum height in pixels. A short dashboard is padded up to it; a taller dashboard extends past it. If omitted, the PDF is exactly as tall as the dashboard's content.
  • variables (optional) — values that override the dashboard's variables (for example a date range or a selected filter), so you can export a specific view.
  • clientContext (optional) — the same client context object you pass when embedding (e.g. theme, language, timezone).
  • styles (optional) — a map of CSS declarations applied to the dashboard element, e.g. { "background": "#ffffff" }. A background here also paints the page behind the dashboard. Up to 50 declarations.
  • animationSettleMs (optional) — extra time, in milliseconds, to let animations and transitions settle before the snapshot is taken. Increase it if charts animate in and you see a mid-animation frame in the PDF.
💡

The PDF is a faithful, single-page capture at the requested width: on-screen styling (not a print stylesheet), backgrounds included, rendered at high resolution. Multi-page / page-break handling is not part of this version.

2. Check status & download

Poll this endpoint with the jobId from step 1 until the job reaches a terminal status.

Endpoint

GET https://api.<region>.embeddable.com/api/v1/dashboard-exports/{jobId}

Example Request

// Important: Always call this server-side, never from client-side code
fetch(`https://api.<region>.embeddable.com/api/v1/dashboard-exports/${jobId}`, {
  method: 'GET',
  headers: {
    'Accept': 'application/json',
    'Authorization': `Bearer ${apiKey}` // Keep your API key secure
  }
})
  .then((res) => res.json())
  .then(console.log);

Example Response — still rendering

While the job is in progress it returns status: "PENDING" and a Retry-After header telling you how long to wait before polling again.

{
  "jobId": "4a752795-ee35-4e6d-9cf9-9972174a28cf",
  "status": "PENDING"
}

Example Response — ready to download

{
  "jobId": "4a752795-ee35-4e6d-9cf9-9972174a28cf",
  "status": "SUCCEEDED",
  "downloadUrl": "https://embeddable-dashboard-export...s3...amazonaws.com/4a752795-...?X-Amz-Signature=...",
  "downloadUrlExpiresAt": "2026-09-10T10:15:00Z",
  "expiresAt": "2026-09-11T09:00:00Z"
}

Field Reference

  • status — the job's lifecycle status:
    • PENDING — queued or rendering. Keep polling (see Retry-After).
    • SUCCEEDED — the PDF is ready; downloadUrl is present.
    • FAILED — the export could not be produced; see failureReason.
    • EXPIRED — the export succeeded earlier but the file has since been deleted (see File retention). Start a new export to get it again.
  • downloadUrl — a temporary, pre-signed link to the PDF. Present only when SUCCEEDED.
  • downloadUrlExpiresAt — when the downloadUrl stops working. It's short-lived (about 15 minutes); if it expires, just call this endpoint again for a fresh link.
  • expiresAt — when the file itself is permanently deleted. After this the job reports EXPIRED.
  • failureReason — present only when FAILED:
    • RENDER_ERROR — the dashboard could not be fully rendered — for example a widget didn't receive its data in time (see Database connections & concurrency), or a component errored. Re-running the same export usually won't help on its own — check the dashboard, keep its queries fast, and make sure your connection concurrency is high enough.
    • TIMEOUT — no result came back in time, typically transient backlog. A retry may succeed.

Downloading the file

When a job is SUCCEEDED, download the PDF from downloadUrl. The bytes stream straight from storage — they never pass through the Embeddable API.

⚠️

Don't put downloadUrl directly into an email or a saved document. The link expires after ~15 minutes. Instead, download the file and attach the downloaded PDF. If a link expires before you fetch it, request a new one from the same GET endpoint (until the file's expiresAt).

File retention

Each file is kept for 24 hours after it's produced (expiresAt), then permanently deleted. After that the job returns EXPIRED and you must start a new export.

Database connections & concurrency

An export is only marked SUCCEEDED once every widget on the dashboard has received its data. If even one widget fails to load its data in time, the whole export fails (FAILED with RENDER_ERROR) — a partially-loaded dashboard is never returned.

Several exports can render at the same time, and each one loads all its widgets' data at once — so exports reach your database as bursts of queries. Your connection's concurrency — how many of those queries run at once — is what keeps them moving: if it's too low, queries queue up, some widgets miss the 2-minute per-export limit, and their exports fail.

Setting concurrency

Set concurrency as high as your database can comfortably serve (within its connection limit — see the warning here), and keep queries fast so each export asks less of it. The more headroom your connection has, the more exports finish in time.

To see how much it matters: when 30 exports render at once, each with 10 widgets, that's 300 queries hitting your database together — at ~800 ms each, 300 × 0.8s = 240s of query time to clear:

concurrencyTime to clear 300 queriesResult
2 (Postgres default)~2 minqueries barely clear in time — many exports fail
4~1 mincomfortable headroom
8~30 seclarge headroom

Even a small increase has an outsized effect — so set concurrency well above your dashboard's widget count.

Timing & throughput

A few things to know when you're generating many PDFs — for example, one report per customer at the start of the month:

  • Startup delay. After you create an export, allow a couple of minutes before it begins processing. This affects the first exports after a quiet period; once exports are already running, new ones start without the wait.
  • Per-export time limit. Each export is given about 2 minutes to render. Most of that is waiting for the dashboard's data to load, so a dashboard with many widgets or slow queries can hit the limit and come back FAILED. Keeping queries fast is the best way to keep exports fast and reliable.
  • Throughput depends on the dashboard. An export can only be captured once the dashboard's data has loaded, so a dashboard that takes N seconds to load takes roughly N + a couple of seconds to export. Fast dashboards export in large numbers in parallel; slow ones are gated by their own load time.
  • Concurrent exports per workspace. There's no per-request rate limit, but each workspace can only have a limited number of exports in flight at once. Beyond that, POST responds with 429 Too Many Requests and a Retry-After header — wait for running exports to finish, then retry.

Errors

StatusMeaning
202 AcceptedThe export job was created. The body contains the jobId.
400 Bad RequestInvalid request — e.g. missing token or format, a non-positive viewport width, or too many styles. Also returned when the token was not created with export: true, when the security token is invalid, or when the dashboard's components are below the minimum SDK versions.
403 ForbiddenAn export: true token was used somewhere other than this API. Export tokens can only be used with the Dashboard Export API.
404 Not FoundNo job with that jobId exists in your workspace.
429 Too Many RequestsThe workspace has too many exports in flight. Retry after the delay in the Retry-After header.

Full example — start, poll, download

// Important: Always call this server-side, never from client-side code
const BASE = 'https://api.<region>.embeddable.com/api/v1/dashboard-exports';
const headers = {
  'Content-Type': 'application/json',
  'Accept': 'application/json',
  'Authorization': `Bearer ${apiKey}`,
};
 
// 1. Start the job
const { jobId } = await fetch(BASE, {
  method: 'POST',
  headers,
  body: JSON.stringify({ token: exportToken, format: 'PDF', viewport: { width: 1280 } }),
}).then((r) => r.json());
 
// 2. Poll until it's done. Respect the Retry-After header rather than a tight loop.
async function waitForExport(jobId) {
  while (true) {
    const res = await fetch(`${BASE}/${jobId}`, { headers });
    const job = await res.json();
 
    if (job.status === 'SUCCEEDED') return job;
    if (job.status === 'FAILED' || job.status === 'EXPIRED') {
      throw new Error(`Export ${job.status}: ${job.failureReason ?? ''}`);
    }
 
    const retryAfter = Number(res.headers.get('Retry-After')) || 2;
    await new Promise((r) => setTimeout(r, retryAfter * 1000));
  }
}
 
const job = await waitForExport(jobId);
 
// 3. Download the PDF and (for example) save or attach it — don't reuse the URL later, it expires.
const pdf = Buffer.from(await fetch(job.downloadUrl).then((r) => r.arrayBuffer()));

Related Topics