API Integration Guide
Integrate DS Templates into your platform. Choose between a native deep integration with your CMS or a standalone white-label deployment.
This document is confidential and intended for DS Templates integration partners only. The endpoints, data flows, and authentication methods described here can be adapted to your platform's needs. Contact us to discuss the optimal integration approach for your architecture.
Choose Your Integration Option
DS Templates supports two integration approaches. Select the one that best fits your platform.
Option 1: Native Integration
Deep CMS integration with your own media library, branding bridge, and full postMessage protocol. Templates feel completely native within your CMS.
Option 2: Standalone Integration
White-label deployment using DS Templates' built-in media library and editor. Simpler integration without CMS media bridging.
Feature Comparison
| Feature | Option 1: Native | Option 2: Standalone |
|---|---|---|
| Media library | Your CMS media library via postMessage bridge | DS Templates built-in library |
| Branding / logo | Injected from CMS via API | Configured in DS Templates |
| Template editor | Embedded iframe with full control | Embedded iframe, standard |
| PostMessage events | Full protocol (editor + media + playback) | Basic protocol (editor + playback) |
| Module configuration | Iframe-based, token auth | Iframe-based, token auth |
| White-label domain | Supported | Supported |
| Integration effort | Higher - requires media resolution | Lower - plug and play |
| Best for | CMS platforms wanting native feel | Partners wanting quick deployment |
Authentication SHARED
All /api/v1/* endpoints are protected by OAuth 2.0 using the client_credentials grant. You exchange a client_id and client_secret for an access token, then attach that token to subsequent requests as a Bearer token.
Tokens are short-lived (one hour). Refresh tokens are not issued for the client_credentials grant - when an access token expires, request a new one with the same credentials.
Token Endpoint
POST /api/v1/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials& client_id=<your_client_id>& client_secret=<your_client_secret>& scope=<space_separated_scopes>
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | Must be client_credentials |
client_id | Yes | The client identifier issued during provisioning |
client_secret | Yes | The client secret issued during provisioning |
scope | No | Space-separated list of requested scopes. Defaults to templates:read if omitted. |
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"scope": "templates:read templates:write"
}{
"error": "invalid_client",
"error_description": "Client authentication failed",
"message": "Client authentication failed"
}error value | Cause |
|---|---|
invalid_client | Wrong client_id or client_secret, or the client is inactive |
invalid_grant | grant_type is not supported for this client |
invalid_scope | A requested scope is not registered against your client |
invalid_request | A required parameter is missing or malformed |
Tokens are valid for one hour. Implement server-side caching so you are not requesting a new token on every API call. Refresh before expiry (60-second margin). On a 401 response, drop your cached token and request a fresh one before retrying. Never expose client_secret to a browser - the token exchange must happen server-to-server.
Available Scopes
| Scope | Granted to | Permits |
|---|---|---|
users:create | integrator_admin | POST /api/v1/users/provision |
templates:read | integrator_admin, subuser | GET /api/v1/templates, GET /api/v1/template-categories, GET /api/v1/modules (integrator_admin); plus GET /api/v1/my-templates (subuser only) |
subusers:manage | integrator_user | All /api/v1/subusers/* routes |
templates:write | subuser | POST /api/v1/library/external-image, POST /api/v1/library/external-video, POST /api/v1/company-branding/set, and all editor-iframe save routes |
The integrator_admin carries both users:create and templates:read. Catalogue endpoints (/templates, /template-categories, /modules) return the same data for every subuser, so cache the catalogue once at the partner-platform level using your integrator_admin token. /api/v1/my-templates returns per-subuser data and must be called with a subuser token.
Using a token
GET /api/v1/my-templates HTTP/1.1 Host: templates.example.com Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
For embedded routes under /embed/*, you may also pass the token as a ?token=... query parameter on the iframe URL.
Example: full token flow
# 1. Exchange credentials for an access token ACCESS_TOKEN=$(curl -s -X POST https://templates.example.com/api/v1/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'grant_type=client_credentials' \ -d 'client_id=01HRX...' \ -d 'client_secret=s3cr3t...' \ -d 'scope=templates:read' \ | jq -r .access_token) # 2. Call an API endpoint with the token curl -s https://templates.example.com/api/v1/my-templates \ -H "Authorization: Bearer $ACCESS_TOKEN" \ | jq .
Subuser Management SHARED
A subuser is the account that ultimately edits templates and owns saved work. Your integrator_user provisions one subuser per end-user in your CMS. All routes require a Bearer token with subusers:manage scope.
Create subuser
Creates a subuser under the calling integrator_user's account. Idempotent on email - re-posting the same email returns the existing subuser's credentials.
{
"email": "alice@partner-cms.example",
"firstName": "Alice",
"lastName": "Andersson",
"permissions": {
"templateEditor": {
"elementPositioning": true,
"elementAnimation": true,
"elementColor": true,
"elementColorPicker": true,
"fontSize": true,
"fontType": true,
"fontColor": false,
"fontStyling": false
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Unique identifier for this subuser |
firstName | string | No | Defaults to email if omitted |
lastName | string | No | Defaults to integrator_admin's client name |
permissions.templateEditor | object | No | Per-field toggles. Omitting grants full access. Set fields to false to restrict. |
{
"userId": "01HRX...",
"email": "alice@partner-cms.example",
"firstName": "Alice",
"lastName": "Andersson",
"clientId": "abc123...",
"clientSecret": "xyz789...",
"permissions": { /* ... */ },
"created": true
}If the email already exists under the same integrator_admin, returns 200 OK with "created": false and the existing credentials.
| Status | error | Cause |
|---|---|---|
400 | email is required | Body missing or email empty |
409 | email unavailable | Email exists under a different integrator_admin |
List subusers
Lists every subuser provisioned under the calling integrator_user. clientSecret is not returned on list.
{
"subusers": [
{
"userId": "01HRX...",
"email": "alice@partner-cms.example",
"firstName": "Alice",
"clientId": "abc123...",
"permissions": { /* ... */ }
}
]
}Get single subuser
Returns a single subuser by ID. Same shape as list, without clientSecret. Returns 404 if the ID is not under the calling integrator_user.
Update subuser
Updates name and/or editor permissions. Omitted fields are left unchanged. Email cannot be changed.
{
"firstName": "Alice",
"lastName": "Andersson-Williams",
"permissions": {
"templateEditor": {
"fontColor": true
}
}
}Sparse update - only included keys are written. Returns 403 if you try to modify yourself, 404 if not found.
Delete subuser
Deletes a subuser and revokes its OAuth client. Returns 204 No Content. Saved templates remain but are no longer reachable by API. Re-creating with the same email does not restore access.
Editor Permissions
The permissions.templateEditor object controls which UI affordances are exposed inside the editor. Defaults are permissive - omit the object for full access.
| Field | When true, the subuser may |
|---|---|
elementPositioning | Move and resize elements on the canvas |
elementAnimation | Edit per-element animation settings |
elementColor | Change element background colours from the brand palette |
elementColorPicker | Inverted toggle. false exposes the freeform colour picker; true restricts to company palette. |
fontSize | Change font size |
fontType | Change font family |
fontColor | Change font colour from the brand palette |
fontStyling | Toggle bold / italic / underline |
Template Library SHARED
Three endpoints power the template-picker and "My Templates" views. All run on a subuser token with templates:read scope.
Returns the catalogue of templates grouped by category. The returned editorUrl is the iframe src you load when the user chooses a template.
| Parameter | Type | Required | Description |
|---|---|---|---|
category | string | No | Filter to a single category by name. URL-encode the value. |
[
{
"categoryName": "Information",
"translations": {
"nl_NL": "Informatie",
"en_GB": "Information",
"de_DE": "Informationen"
},
"items": [
{
"name": "Office welcome screen",
"slug": "info_welcome_screen",
"isRealtime": false,
"imagePath": "https://common.digitalsignage-templates.com/template_previews/nl/info_welcome_screen.jpg",
"editorUrl": "https://templates.example.com/embed/template-editor/new/info_welcome_screen",
"previewUrl": "https://templates.example.com/template/info-welcome-screen/preview"
}
]
}
]| Field | Type | Description |
|---|---|---|
categoryName | string | Source label (English). Pass to category filter. |
translations | object | Localised category labels by BCP-style locale |
items[].name | string | Display name of the template |
items[].slug | string | Stable identifier for the template |
items[].isRealtime | boolean | true if auto-refreshes on live screen |
items[].imagePath | string | Thumbnail image URL |
items[].editorUrl | string | Iframe URL for the editor |
items[].previewUrl | string | Standalone preview URL |
Template Categories
Returns the flat list of category names with translations. Useful for rendering a category picker independently of loading templates.
[
{
"name": "Information",
"translations": {
"nl_NL": "Informatie",
"en_GB": "Information"
}
}
]Saved Templates (My Templates)
Returns every saved template belonging to the calling subuser. Powers the "My Templates" view.
| Parameter | Type | Description |
|---|---|---|
search | string | Case-insensitive substring match against name |
createdAfter | integer (unix epoch) | Templates created after this timestamp |
createdBefore | integer (unix epoch) | Templates created before this timestamp |
modifiedAfter | integer (unix epoch) | Templates modified after this timestamp |
modifiedBefore | integer (unix epoch) | Templates modified before this timestamp |
[
{
"id": "01HRX...",
"name": "Reception screen - Q2",
"aspectRatio": "16:9",
"url": "https://templates.example.com/template/01HRX...",
"createdAt": "2026-04-12T08:30:00+00:00",
"modifiedAt": "2026-05-19T14:02:11+00:00",
"thumbnailUrl": "https://...",
"duration": 15,
"latestVersionId": "01HSY...",
"versions": [
{ "id": "01HRX...", "modifiedAt": "2026-04-12T08:30:00+00:00" },
{ "id": "01HSY...", "modifiedAt": "2026-05-19T14:02:11+00:00" }
]
}
]Template Versioning
DS Templates retains every saved version. The id at top level is the root (original); each save creates a new child in versions[]. latestVersionId is always the most recent.
- For rendering on screens: pass whichever version ID you want to display. Different screens can show different versions.
- For editing: always pass
latestVersionId. The editor clones the version on save, keeping history linear.
Editor Iframe SHARED
The template editor is loaded in an iframe. The editorUrl from the template library response is the iframe source, with the token appended.
https://templates.example.com/embed/template-editor/new/{slug}?token={access_token}
https://templates.example.com/embed/template-editor/edit/{id}?token={access_token}<iframe id="template-editor" src="https://templates.example.com/embed/template-editor/new/info_welcome_screen?token=eyJ..." style="width:100%;height:100vh;border:none" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox" ></iframe>
Set token once on the initial iframe src. The platform validates the token on first request, establishes a session, and uses the session for subsequent requests. For editing existing templates, always use latestVersionId.
Module Iframes SHARED
Each module exposes its own configuration UI as an iframe. Unlike the editor, module iframes do not use postMessage - they are server-rendered pages that the user interacts with directly.
https://templates.example.com/embed/modules/{link}?token=<subuser-token><iframe src="https://templates.example.com/embed/modules/birthday-module/?token=eyJ..." sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox" ></iframe>
Module categories
| Category | Description | Examples |
|---|---|---|
| simple-credential | Short form for API key or credentials. Validates and shows "Connected" state. | Bundeling, SolarEdge, Humly, MDM |
| manual-entry | CRUD UI for user-maintained data: list view, edit form, optional CSV import. | Birthday, Calamity, Events, News, Meeting Room, Menu Board, Poll |
| oauth | 3-legged OAuth via popup window. Platform handles the OAuth handshake. | LinkedIn (available), Google/Microsoft/TeamLeader (coming soon) |
Module iframes do not emit a save signal. Configuration is persisted on each in-iframe save. A reasonable UX: open the iframe in a modal, provide a "Close" button outside the iframe, and optionally re-fetch state on close.
PostMessage Reference
Every window.postMessage event exchanged between your parent CMS and the platform's iframes.
Option 1 (Native): implement the full protocol - editor, playback, and media resolution.
Option 2 (Standalone): implement editor and playback protocols only. Media is handled by DS Templates internally.
Editor Protocol
Events for the editor iframe at /embed/template-editor/...
Editor to Parent
| Event | Payload | Meaning |
|---|---|---|
DST_EDITOR_READY | { templateSlug, action } | Editor booted. action is "new" or "edit". |
DST_FORM_CHANGED | { hasUnsavedChanges, field } | Form field changed (debounced 500ms) |
DST_SAVE_STARTED | {} | Save POST dispatched |
DST_SAVE_COMPLETE | { templateId, message, media, previewKey } | Save succeeded |
DST_SAVE_ERROR | { error, errors? } | Save failed |
DST_FORM_DATA_RESPONSE | { formData, previewKey } | Reply to DST_GET_FORM_DATA |
DST_FORM_VALIDATION_RESULT | { isValid, errors } | Reply to DST_VALIDATE_FORM |
DST_FORM_DATA_SET_COMPLETE | {} | Reply to DST_SET_FORM_DATA |
DST_EDITOR_MEDIA_SELECTION_REQUEST | { fileType, allowedFileTypes } | User clicked "add image/video". Open your CMS media picker. |
DST_EDITOR_OPEN_MODULE_SETTINGS | { moduleLink } | User clicked module settings button |
Parent to Editor
| Event | Payload | Effect |
|---|---|---|
DST_EDITOR_SAVE | {} | Trigger save flow |
DST_GET_FORM_DATA | {} | Request current form state |
DST_SET_FORM_DATA | { formData } | Restore form state |
DST_REQUEST_PREVIEW_UPDATE | {} | Force preview reload |
DST_VALIDATE_FORM | {} | Run validation without saving |
DST_EDITOR_MEDIA_SELECTION_RESPONSE | { fileNameWithExtension, displayName? } | User picked a file in your CMS picker |
Event listener example
const ORIGIN = 'https://templates.example.com'; const editor = document.getElementById('template-editor'); window.addEventListener('message', (event) => { if (event.origin !== ORIGIN) return; switch (event.data.type || event.data.msg) { case 'DST_EDITOR_READY': console.log('Editor ready'); break; case 'DST_SAVE_COMPLETE': console.log('Saved:', event.data.templateId); refreshUserTemplates(); break; case 'DST_SAVE_ERROR': console.error('Save failed:', event.data.error); break; } }); // Trigger save from your CMS UI function triggerSave() { editor.contentWindow.postMessage( { type: 'DST_EDITOR_SAVE' }, ORIGIN ); }
Playback Protocol
For templates in playback mode via /template/{slug}/preview?playlist=true. Without ?playlist=true the template auto-plays standalone.
Template to Parent
msg | When | Extra payload |
|---|---|---|
preRenderFinished | Template finished pre-rendering, ready to play | - |
DST_TEMPLATE_LAZY_LOAD | Pre-rendering state entered | templateId, templateName |
DST_TEMPLATE_PLAY | Template started playing | templateId, templateName, startTime |
DST_TEMPLATE_PAUSE | Template paused | templateId, templateName |
DST_TEMPLATE_RESUME | Template resumed | templateId, templateName |
DST_TEMPLATE_STOP | Template stopped | templateId, templateName, stopTime, playDuration |
DST_TEMPLATE_DURATION | Dynamic-duration template reports runtime | templateId, templateName, duration |
Parent to Template (raw strings)
event.data | Effect |
|---|---|
'playlistSlideRender' | Enter pre-rendering |
'initIntroAnimation' | Start the template (triggers intro animations) |
'exitIntroAnimation' | Stop the template |
Minimum playback flow
parent: load <iframe src="/template/{slug}/preview?playlist=true" /> iframe -> parent: { msg: "DST_TEMPLATE_LAZY_LOAD" } iframe -> parent: { msg: "preRenderFinished" } iframe -> parent: { msg: "DST_TEMPLATE_DURATION", duration: 30 } parent -> iframe: "initIntroAnimation" iframe -> parent: { msg: "DST_TEMPLATE_PLAY", startTime: 173... } ... template plays ... parent -> iframe: "exitIntroAnimation" iframe -> parent: { msg: "DST_TEMPLATE_STOP", playDuration: 30000 }
Media Resolution Protocol OPTION 1 ONLY
Runs alongside editor and playback protocols when the DOM contains references to external media registered via POST /api/v1/library/external-image or external-video.
Iframe to Parent
| Event | Payload | Meaning |
|---|---|---|
DST_RENDERER_MEDIA_URL_REQUEST | { fileNameWithExtension } | Iframe needs a real URL for a registered filename |
Parent to Iframe
| Event | Payload | Effect |
|---|---|---|
DST_RENDERER_MEDIA_URL_RESPONSE | { fileNameWithExtension, mediaUrl?, mediaBlob? } | Provide URL or Blob. If both are sent, mediaBlob wins. |
Flow
iframe -> parent: { type: "DST_RENDERER_MEDIA_URL_REQUEST", fileNameWithExtension: "office.jpg" } parent -> iframe: { type: "DST_RENDERER_MEDIA_URL_RESPONSE", fileNameWithExtension: "office.jpg", mediaUrl: "https://your-cdn.example/uploads/office.jpg" } iframe: updates all img.src, video.src, and inline-style references
The iframe caches resolved URLs in memory - duplicate requests for the same filename are deduplicated.
In Standalone mode, DS Templates handles all media internally through its built-in library. You do not need to implement the media resolution protocol. If you see DST_RENDERER_MEDIA_URL_REQUEST or DST_EDITOR_MEDIA_SELECTION_REQUEST events, you can safely ignore them.
Error Handling SHARED
Every JSON endpoint uses the same error shape: { "error": "Human-readable description" }. The HTTP status carries the semantics.
| Status | Meaning | Action |
|---|---|---|
200 | OK | Request succeeded |
201 | Created | New resource created |
204 | No Content | Success, no body (e.g. DELETE) |
400 | Bad Request | Fix the request. Show error near the offending field. |
401 | Unauthorized | Refresh token, then retry |
403 | Forbidden | Valid token but wrong scope or relationship. Do not retry. |
404 | Not Found | Resource does not exist under this caller's scope |
409 | Conflict | Existence constraint violated (e.g. duplicate email) |
500 | Server Error | Retry once after short delay. File a ticket with timestamp. |
401 means "we couldn't identify you" - token is missing, expired, or invalid. 403 means "we know who you are, but you can't do this" - wrong scope or relationship.
Common error messages
These are the canonical error strings the API returns. Treat the HTTP status as the source of truth.
Authentication and authorisation
| Status | error | When |
|---|---|---|
401 | No OAuth client found | Token decoded but no matching OAuth client (revoked, deleted, or never minted) |
403 | OAuth client does not have the required scopes | Token valid but the scope this endpoint requires is missing |
403 | Caller is not a provisioned subuser | Caller has subusers:manage scope but is not in the integrator_user role chain |
Provisioning (POST /users/provision)
| Status | error | When |
|---|---|---|
400 | email is required | Request body missing or email field absent |
Subuser management
| Status | error | When |
|---|---|---|
400 | email is required | Request body missing or email field absent |
403 | Cannot modify yourself | Caller attempted to PATCH the subuser record that issued the current token |
403 | Cannot delete yourself | Caller attempted to DELETE the subuser record that issued the current token |
404 | Subuser not found | The targeted subuser does not exist under this caller's integrator_user |
409 | email unavailable | Another subuser under the same integrator_admin already uses this email |
409 | Integrator admin has no OAuth client | Data-integrity issue - contact support |
Library and branding
| Status | error | When |
|---|---|---|
400 | fileName required | POST /library/external-image or external-video with no fileName |
400 | Invalid JSON body | POST /company-branding/set with a body that is not JSON or not an object |
Template editor save
The editor save endpoint wraps errors in a success envelope:
{
"success": false,
"error": "Validation failed",
"errors": [
"The first headline must not be empty.",
"Brand color 1 must be a valid hex code."
]
}Note: the wrapping HTTP status may be 200 - check the success field, not the status. The editor's DST_SAVE_ERROR postMessage passes through the errors array verbatim. A 401 inside the iframe means the token expired and returns server-rendered HTML instead of JSON.
Rendering (GET /template/{id})
| Status | Body | When |
|---|---|---|
400 | API templates must be served from a recognized integrator domain. | Host does not match a registered integrator host |
400 | trackingId is required for this template. | Render URL has no ?trackingId= query parameter |
Validation error shapes
Two slightly different shapes depending on context:
JSON-API endpoints (/api/v1/...) - a single error string:
{ "error": "email is required" }Editor-iframe save (/embed/template-editor/...) - JSON wrapped in success envelope (see above).
What to retry vs. surface
| Outcome | Retry? | Show to end user? |
|---|---|---|
401 | Yes, after refreshing the token. A bare retry will keep failing. | Internal - show "session expired, please reload" |
403 (scope) | No. Integrator is misconfigured. | Internal - file a ticket with DS Templates. |
403 (relationship) | No. Wrong token used for this surface. | Internal - likely a bug in your CMS token routing. |
400 (validation) | No, fix the request. | Yes - show the error string near the offending field. |
404 | No. | Yes if user-driven (stale link); silent if internal. |
409 | No, the conflict is durable. | Yes - explain in user terms. For email unavailable consider a "use existing" path. |
500 | Yes once, after a short delay. Don't loop. | Generic "something went wrong" - file a ticket with timestamp. |
Rendering Templates SHARED
Once a subuser has saved a template, embed it on a digital signage screen by loading the rendering URL in an iframe.
| Use case | URL | Auth |
|---|---|---|
| Render saved template for playback | https://{integrator-host}/template/{id} | None - but must be from recognised host + trackingId |
| Render catalogue preview by slug | https://templates.example.com/template/{slug}/preview | None (public) |
Required: integrator host
Saved templates may only be rendered from a host the platform recognises as one of your integrator domains. A request from any other host returns 400: "API templates must be served from a recognized integrator domain."
Required: trackingId
Every render request must carry a ?trackingId= query parameter - a stable identifier for the device displaying the template (e.g. screen serial number, device UUID). Used for usage counting and deduplication.
- One trackingId per screen, not per render - reusing is correct and expected
- Do not generate a fresh ID per render - that inflates counters
- Do not derive from end-user PII
Recommended: ?playlist=true
Without ?playlist=true, the template auto-plays with no external control. With it, the template enters parent-driven mode: pre-renders, emits preRenderFinished, then waits for 'initIntroAnimation'. See the Playback Protocol section above.