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.
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):
| Package | Minimum version |
|---|---|
@embeddable.com/react | 2.13.6 |
@embeddable.com/sdk-core | 4.4.0 |
@embeddable.com/sdk-react | 4.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-exportsLearn 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 withexport: 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 to1280. 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" }. Abackgroundhere 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 (seeRetry-After).SUCCEEDED— the PDF is ready;downloadUrlis present.FAILED— the export could not be produced; seefailureReason.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 whenSUCCEEDED.downloadUrlExpiresAt— when thedownloadUrlstops 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 reportsEXPIRED.failureReason— present only whenFAILED: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 connectionconcurrencyis 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:
concurrency | Time to clear 300 queries | Result |
|---|---|---|
2 (Postgres default) | ~2 min | queries barely clear in time — many exports fail |
4 | ~1 min | comfortable headroom |
8 | ~30 sec | large 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,
POSTresponds with429 Too Many Requestsand aRetry-Afterheader — wait for running exports to finish, then retry.
Errors
| Status | Meaning |
|---|---|
202 Accepted | The export job was created. The body contains the jobId. |
400 Bad Request | Invalid 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 Forbidden | An export: true token was used somewhere other than this API. Export tokens can only be used with the Dashboard Export API. |
404 Not Found | No job with that jobId exists in your workspace. |
429 Too Many Requests | The 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()));