The Playfair API
Read your dashboards, answers and saved questions from your own tools. JSON over HTTPS with a Bearer key. It is read-only by construction: nothing you call here can write to Playfair or to your databases.
https://playfair.asrar.software/api/v1Authentication
Workspace admins create keys under API & webhooks in the app. A key is shown once; Playfair stores only its SHA-256 hash. Send it in the Authorization header of every request.
curl https://playfair.asrar.software/api/v1/dashboards \
-H "Authorization: Bearer pf_live_…"| Scope | Grants |
|---|---|
dashboards:read | List dashboards and read their tiles with the latest results. |
answers:read | Read answers (headline, SQL, chart, rows) and list saved questions. |
saved_questions:run | Execute a saved question’s SQL against its source (read-only) and return fresh rows. |
A key acts as the member who created it: it sees what they can see. If that member leaves the workspace or is removed, the key stops working. The API needs the workspace to be on Pro.
Rate limits
Each key may send 60 requests per minute (the default). Every response carries the current window; a 429 tells you how many seconds to wait.
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1790496060Errors
Errors use one JSON shape: a human sentence you can show, and a stable code you can branch on.
{
"message": "This API key is invalid or has been revoked.",
"code": "UNAUTHORIZED"
}| 401 | UNAUTHORIZED | Missing, invalid or revoked key |
| 402 | PLAN_LIMIT | The workspace is not on Pro |
| 403 | FORBIDDEN | Missing scope, suspended workspace or blocked network |
| 404 | NOT_FOUND | Not found, or not visible to this key |
| 422 | UNPROCESSABLE | The saved question cannot run (never answered, query error) |
| 429 | RATE_LIMITED | Too many requests in this minute |
List dashboards
/dashboardsdashboards:readDashboards visible to the member who created the key, most recently updated first.
| Parameter | In | Details |
|---|---|---|
page | query | integer, default 1 |
pageSize | query | integer, default 25, max 100 |
curl https://playfair.asrar.software/api/v1/dashboards \
-H "Authorization: Bearer $PLAYFAIR_API_KEY"{
"data": [
{
"id": "2b3c4d5e-6f70-4812-93a4-b5c6d7e8f901",
"name": "E-commerce overview",
"description": "Revenue, orders and returns for the store",
"visibility": "team",
"tileCount": 10,
"refreshPolicy": {
"mode": "interval",
"intervalMinutes": 60
},
"createdAt": "2026-03-02T09:12:00.000Z",
"updatedAt": "2026-09-26T17:40:11.000Z",
"url": "https://playfair.asrar.dev/app/acme/dashboards/2b3c4d5e-6f70-4812-93a4-b5c6d7e8f901"
}
],
"page": 1,
"pageSize": 25,
"total": 1
}Errors: 401 missing, invalid or revoked API key · 402 the workspace is not on the Pro plan · 403 the key lacks the required scope, or the workspace is suspended · 429 rate limit reached.
Get a dashboard
/dashboards/{id}dashboards:readA dashboard with its filters and every tile: kind, layout, chart spec, SQL and the latest cached result (no query is run).
| Parameter | In | Details |
|---|---|---|
idrequired | path | Dashboard id · uuid |
curl https://playfair.asrar.software/api/v1/dashboards/2b3c4d5e-6f70-4812-93a4-b5c6d7e8f901 \
-H "Authorization: Bearer $PLAYFAIR_API_KEY"{
"id": "2b3c4d5e-6f70-4812-93a4-b5c6d7e8f901",
"name": "E-commerce overview",
"description": "Revenue, orders and returns for the store",
"visibility": "team",
"refreshPolicy": {
"mode": "interval",
"intervalMinutes": 60
},
"createdAt": "2026-03-02T09:12:00.000Z",
"updatedAt": "2026-09-26T17:40:11.000Z",
"url": "https://playfair.asrar.dev/app/acme/dashboards/2b3c4d5e-6f70-4812-93a4-b5c6d7e8f901",
"filters": [
{
"key": "region",
"name": "Region",
"kind": "select",
"defaultValue": null
}
],
"tiles": [
{
"id": "7c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"kind": "answer",
"title": "Monthly revenue",
"subtitle": "Paid orders",
"status": "ready",
"layout": {
"x": 0,
"y": 3,
"w": 8,
"h": 5
},
"answerId": null,
"savedQuestionId": null,
"chart": {
"type": "line",
"x": {
"field": "month"
},
"y": {
"field": "revenue",
"format": "currency"
}
},
"sql": "select date_trunc('month', order_date)::date as month, sum(total) as revenue from orders where status = 'paid' group by 1 order by 1",
"markdown": null,
"label": null,
"lastRefreshedAt": "2026-09-27T06:00:04.000Z",
"result": {
"columns": [
{
"name": "month",
"type": "date"
},
{
"name": "revenue",
"type": "number"
}
],
"rows": [
{
"month": "2026-07-01",
"revenue": 412880.4
},
{
"month": "2026-08-01",
"revenue": 398112.9
}
],
"rowCount": 2,
"truncated": false,
"refreshedAt": "2026-09-27T06:00:04.000Z"
}
}
]
}Errors: 401 missing, invalid or revoked API key · 402 the workspace is not on the Pro plan · 403 the key lacks the required scope, or the workspace is suspended · 404 dashboard not found (or not visible to this key) · 429 rate limit reached.
Get an answer
/answers/{id}answers:readThe headline, narrative, confidence, caveats, SQL, chart spec and the stored rows of an answer.
| Parameter | In | Details |
|---|---|---|
idrequired | path | Answer id · uuid |
curl https://playfair.asrar.software/api/v1/answers/9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a \
-H "Authorization: Bearer $PLAYFAIR_API_KEY"{
"id": "9e8d7c6b-5a49-4382-9170-6f5e4d3c2b1a",
"question": "Monthly revenue this year",
"status": "ready",
"source": {
"id": "f291b88d-8e0f-4078-8fab-be512cac4979",
"name": "Sample store",
"kind": "postgres"
},
"headline": "Revenue reached €3.4M so far this year",
"headlineValue": {
"value": 3412880.4,
"formatted": "€3.4M"
},
"narrative": "Revenue grew steadily except in April, when Région Sud fell 45%.",
"sql": "select date_trunc('month', o.order_date)::date as month, sum(o.total) as revenue from orders o where o.status = 'paid' and o.order_date >= '2026-01-01' group by 1 order by 1",
"sqlEdited": false,
"chart": {
"type": "line",
"x": {
"field": "month"
},
"y": {
"field": "revenue",
"format": "currency"
}
},
"confidence": "high",
"confidenceReasons": [
"Uses the governed metric Net revenue"
],
"caveats": [
"Excludes 12 orders with no date"
],
"usedMetrics": [
{
"key": "net_revenue",
"name": "Net revenue"
}
],
"rowCount": 9,
"durationMs": 184,
"error": null,
"createdAt": "2026-09-27T08:14:03.000Z",
"url": "https://playfair.asrar.dev/app/acme/answers/9e8d7c6b-5a49-4382-9170-6f5e4d3c2b1a",
"result": {
"columns": [
{
"name": "month",
"type": "date"
},
{
"name": "revenue",
"type": "number"
}
],
"rows": [
{
"month": "2026-07-01",
"revenue": 412880.4
},
{
"month": "2026-08-01",
"revenue": 398112.9
}
],
"rowCount": 2,
"truncated": false,
"refreshedAt": "2026-09-27T06:00:04.000Z"
}
}Errors: 401 missing, invalid or revoked API key · 402 the workspace is not on the Pro plan · 403 the key lacks the required scope, or the workspace is suspended · 404 answer not found (or not visible to this key) · 429 rate limit reached.
List saved questions
/saved-questionsanswers:readShared saved questions of the workspace (and the key creator’s own).
| Parameter | In | Details |
|---|---|---|
page | query | integer, default 1 |
pageSize | query | integer, default 25, max 100 |
curl https://playfair.asrar.software/api/v1/saved-questions \
-H "Authorization: Bearer $PLAYFAIR_API_KEY"{
"data": [
{
"id": "0f1e2d3c-4b5a-4968-8778-695a4b3c2d1e",
"name": "Weekly revenue by region",
"question": "Revenue by region last week",
"tags": [
"weekly",
"regions"
],
"sourceId": "f291b88d-8e0f-4078-8fab-be512cac4979",
"lastAnswerId": "9e8d7c6b-5a49-4382-9170-6f5e4d3c2b1a",
"lastRunAt": "2026-09-22T07:00:00.000Z",
"updatedAt": "2026-09-22T07:00:00.000Z"
}
],
"page": 1,
"pageSize": 25,
"total": 1
}Errors: 401 missing, invalid or revoked API key · 402 the workspace is not on the Pro plan · 403 the key lacks the required scope, or the workspace is suspended · 429 rate limit reached.
Run a saved question
/saved-questions/{id}/runsaved_questions:runExecutes the saved question's SQL (its override, or the SQL of its latest answer) against its source through Playfair's read-only query engine and returns fresh rows. Nothing is written to your database; the run is recorded in the query log with origin api.
| Parameter | In | Details |
|---|---|---|
idrequired | path | Saved question id · uuid |
limit | query | integer, default 1000, max 5000 |
curl -X POST https://playfair.asrar.software/api/v1/saved-questions/5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d/run \
-H "Authorization: Bearer $PLAYFAIR_API_KEY"{
"savedQuestionId": "0f1e2d3c-4b5a-4968-8778-695a4b3c2d1e",
"name": "Weekly revenue by region",
"question": "Revenue by region last week",
"sql": "select r.name as region, sum(o.total) as revenue from orders o join regions r on r.id = o.region_id where o.status = 'paid' group by 1 order by 2 desc",
"columns": [
{
"name": "region",
"type": "string"
},
{
"name": "revenue",
"type": "number"
}
],
"rows": [
{
"region": "Île-de-France",
"revenue": 118220.1
},
{
"region": "Région Sud",
"revenue": 36214.5
}
],
"rowCount": 2,
"truncated": false,
"durationMs": 92,
"cached": false,
"costClass": "light",
"queryRunId": "4d5e6f70-8192-4a3b-9c4d-5e6f7a8b9c0d",
"computedAt": "2026-09-27T08:20:11.000Z"
}Errors: 401 missing, invalid or revoked API key · 402 the workspace is not on the Pro plan · 403 the key lacks the required scope, or the workspace is suspended · 404 saved question not found (or not visible to this key) · 422 the saved question has never run, or the query failed (invalid SQL, timeout) · 429 rate limit reached.
Outbound webhooks
Add HTTPS endpoints under API & webhooks and choose the events they receive. Playfair sends a JSON POST; any 2xx acknowledges it. Anything else is retried after 1 min, 5 min, 30 min, 2 h. The event id stays the same across retries, so you can de-duplicate. Private and loopback addresses are refused.
alert.triggeredA metric or saved question crossed an alert condition.schedule.sentA scheduled report was delivered to its recipients.answer.failedA question could not be answered (query error, timeout).{
"id": "evt_5f0c8b7e2a1d4c3b9e8f7a6b5c4d3e2f",
"type": "alert.triggered",
"createdAt": "2026-09-27T08:00:00.000Z",
"workspace": {
"id": "926aaf8c-7de5-412c-b0dd-25152d975149",
"slug": "acme",
"name": "Acme"
},
"data": {
"alertId": "6f1c2a8e-4b0d-4a51-9f0e-3c8d2b7a1e55",
"name": "Revenue in Région Sud below €40k/week",
"condition": {
"kind": "threshold",
"op": "<",
"value": 40000
},
"value": 36214.5,
"previous": 52480.1,
"link": "https://playfair.asrar.dev/app/acme/schedules?alert=6f1c2a8e"
}
}X-Playfair-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
X-Playfair-Event | The event type, e.g. alert.triggered |
X-Playfair-Delivery | Unique id of this attempt |
X-Playfair-Timestamp | Unix seconds, same as t= in the signature |
Verifying signatures
Each endpoint has its own signing secret (whsec_…). Compute an HMAC-SHA256 of <t>.<raw body> with it and compare in constant time. Reject timestamps older than five minutes.
import crypto from "node:crypto";
// rawBody: the request body as received (a string, before JSON.parse)
export function verifyPlayfairSignature(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
// Reject events older than 5 minutes (replay protection)
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Verify against the raw body. Parsing and re-serialising the JSON changes the bytes and breaks the signature.
OpenAPI
The machine-readable description (OpenAPI 3.1) lives at /api/v1/openapi.json. Import it into Postman, Insomnia or a client generator; this page is rendered from the same document.