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, campaign, and progress
Section titled “Polst, campaign, and progress”// 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}Embedded brand theme
Section titled “Embedded brand theme”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.
ETag revalidation
Section titled “ETag revalidation”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.
Theme uploads
Section titled “Theme uploads”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-urlRequest 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" }}publicUrlis the canonical URL the theme row should reference (no separatecdnUrlfield exists —publicUrlis what CloudFront fronts in prod).uploadUrlis valid forexpiresInseconds. PUT the raw bytes with the exactheadersmap 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.