Skip to content

Data shapes

All modes return the same JSON shapes, so a vote button written against the SDK, the script tag, or the REST API receives identical data. This page is the canonical reference for those shapes.

// Polst
{
"shortId": "a1b2c3d4e5f6",
"title": "Which pizza topping is better?",
"optionA": { "label": "Pineapple", "imageUrl": "https://cdn.polst.app/..." },
"optionB": { "label": "Mushroom", "imageUrl": "https://cdn.polst.app/..." },
"tallies": { "optionA": 1423, "optionB": 2817, "total": 4240 },
"status": "open", // "scheduled" | "open" | "closed"
"startsAt": "2026-04-01T00:00:00Z",
"endsAt": null,
"brand": { // see "Embedded brand theme" below
"slug": "acme",
"name": "Acme Co.",
"theme": { /* BrandTheme DTO, see below */ }
},
"createdAt": "2026-03-15T12:00:00Z"
}
// Campaign
{
"campaignId": "7e8f4686-1d4b-491b-8e6d-9f64d4bcd82e",
"title": "Spring Flavor Showdown",
"description": "...",
"polsts": [ /* ordered array of polst summaries */ ],
"totalSteps": 8,
"brand": {
"slug": "acme",
"name": "Acme Co.",
"theme": { /* BrandTheme DTO */ }
},
"status": "open",
"startsAt": "...",
"endsAt": null
}
// Caller progress on a campaign (device or user scoped)
{
"campaignId": "...",
"completedSteps": 3,
"totalSteps": 8,
"currentPolst": { /* polst shape */ },
"completed": false,
"summary": null // populated once completed=true
}

Every brand envelope — whether the compact { slug, name, theme } embedded inside a polst or campaign, or the full GET /brands/{slug} profile — carries a resolved theme sub-object so renderers never have to make a second round-trip to /brands/{slug}/theme. The resolver merges in defaults; brands with no customisations show version: 0.

// BrandTheme DTO (resolved — the shape embedded inside `brand.theme`)
{
"accent": "#ff00aa", // or null when unset
"background": null,
"foreground": null,
"muted": null,
"border": null,
"buttonBg": null,
"buttonFg": null,
"buttonBgHover": null,
"success": null,
"danger": null,
"radius": "MD", // "NONE" | "SM" | "MD" | "LG" | "FULL"
"density": "COMFORTABLE", // "COMPACT" | "COMFORTABLE"
"fontStack": "SYSTEM", // "SYSTEM" | "SERIF" | "MONO" | "BRAND"
"fontBrandUrl": null, // https, allow-listed CDN
"logoLightUrl": null, // https, project S3 bucket
"logoDarkUrl": null,
"faviconUrl": null,
"hideWatermark": false,
"customCss": null,
"version": 0
}

Colour fields are null when the brand has not chosen an override — renderers fall back to CSS-variable defaults. version bumps on every PATCH via /brands/me/theme and is the stable cache key the renderers use. See Brand theming & the watermark for how brands edit these values.

GET /polsts/{shortId}, GET /brands/{slug}, and GET /brands/{slug}/theme emit a weak ETag W/"theme-v{n}" where {n} is the brand’s theme version (0 for brands with no theme row). Renderers should cache the response body and revalidate with If-None-Match; the server returns 304 Not Modified on a match, short-circuiting the DB lookup. theme-v{n} is a theme-invalidation signal only — it does not mean the tallies are fresh. Clients that need live tallies call /polsts/{id}/results or refetch without If-None-Match.

Logo and favicon bytes are never PUT directly at the REST API — clients first request a presigned S3 URL, PUT the bytes there, then patch the theme row with the returned publicUrl. Two authenticated endpoints are involved (both require the manage scope, bearer auth, and an X-Polst-Idempotency-Key header):

POST /api/rest/v1/brands/me/theme/assets/upload-url

Request body:

{
"slot": "logo_light",
"contentType": "image/png",
"byteSize": 48273
}

slot is one of logo_light | logo_dark | favicon. contentType for logos is image/png, image/svg+xml, or image/webp; for a favicon, image/png or image/svg+xml. byteSize is an integer ≤ 2 MiB (2 * 1024 * 1024).

Response envelope ({ data: ... }):

{
"uploadUrl": "https://s3.us-east-1.amazonaws.com/...",
"key": "brand-assets/…/logo_light.png",
"publicUrl": "https://cdn.polst.app/brand-assets/…/logo_light.png",
"expiresIn": 900,
"method": "PUT",
"headers": { "Content-Type": "image/png" }
}
  • publicUrl is the canonical URL the theme row should reference (no separate cdnUrl field exists — publicUrl is what CloudFront fronts in prod).
  • uploadUrl is valid for expiresIn seconds. PUT the raw bytes with the exact headers map the server returned.

After the PUT succeeds, clients either PATCH the theme row directly —

PATCH /api/rest/v1/brands/me/theme
{ "logoLightUrl": "<publicUrl from the response>" }

— or, for processed assets (server-side resize / sanitise / dark-mode derivatives), call the follow-up endpoint:

POST /api/rest/v1/brands/me/theme/assets/processed
{ "slot": "logo_light", "key": "<key from the upload-url response>" }

which returns the updated theme DTO.

These flows require the manage scope — see Authentication & device identity. For the complete per-endpoint schemas, see the REST API reference.