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 |
Account Hierarchy SHARED
Every partner integration uses a three-tier account model. Only the outer two tiers hold OAuth credentials of their own.
integrator_admin (one per partner, credentials issued by DST) │ │ POST /api/v1/users/provision ▼ integrator_user (one per partner customer - holds no credentials) │ └─▶ admin subuser (created by the same call - holds the credentials) │ │ POST /api/v1/subusers ▼ subuser (end-user that owns saved templates)
| Account | Description |
|---|---|
| integrator_admin | Top-level account, provisioned by DS Templates and handed to you during onboarding. Its OAuth client carries users:create, templates:read and oauth-apps:manage. It does not write templates or call subuser-specific endpoints. |
| integrator_user | One per customer in your CMS, created with POST /api/v1/users/provision. It has no OAuth client and you never hold a token for it: it is a container that owns the customer's subscription and their shared branding and media library. |
| admin subuser | Created by the same provisioning call; its clientId / clientSecret are what you store against the customer record. Carries subusers:manage, templates:read and templates:write. |
| subuser | One per person who edits templates, created with POST /api/v1/subusers using the admin subuser token. Carries templates:read and templates:write, or templates:read only when provisioned view-only. Owns the saved templates returned by GET /api/v1/my-templates. |
Base URLs
All paths in this guide are relative to your integrator-specific white-label host. For a partner whose white-label host is templates.example.com:
| Surface | Pattern |
|---|---|
| REST API | https://templates.example.com/api/v1/... |
| OAuth token endpoint | https://templates.example.com/api/v1/token |
| Editor iframe | https://templates.example.com/embed/template-editor/... |
| Module config iframe | https://templates.example.com/embed/modules/... |
| Rendered template | https://<integrator-key>.ds-templates.com/template/{id} (see Rendering) |
Your white-label host is configured by DS Templates during onboarding: you point a CNAME record at our servers and we issue and renew the SSL certificate. Once registered, all API and embed routes resolve under that domain. To set up or change a white-label host, contact your DS Templates integration contact.
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>
| 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 |
The issued token automatically carries the scopes registered on your client. These are fixed when the account is provisioned (see Access scopes). You do not request scopes at the token endpoint: a read-only subuser yields a read-only token and a read/write subuser yields a read/write token.
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"scope": "templates:read templates:write"
}The token endpoint follows RFC 6749 §5.1 exactly: fields are snake_case here (and only here) because the RFC mandates them. The rest of the API uses camelCase.
{
"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 | admin subuser (the initial subuser returned by POST /users/provision) | All /api/v1/subusers/* routes |
oauth-apps:manage | integrator_admin | All /api/v1/integrator/oauth-apps routes: registering your own OAuth application credentials (see OAuth module setup) |
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_user account holds no credentials of its own: subusers:manage sits on the admin subuser created alongside it. Calling an endpoint with a token that lacks the required scope returns 403 Forbidden; no token, an expired token or an invalid token returns 401 Unauthorized.
The integrator_admin carries users:create, templates:read and oauth-apps:manage. 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. The one per-subuser part is the Huisstijl templates category: with the integrator_admin token it holds all custom templates, each tagged with the main userIds it belongs to (and any excludedSubuserIds), so filter it per subuser when serving from your cache (see Custom templates). /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/* (the template editor and module configuration iframes), you may also pass the token as a ?token=... query parameter on the iframe URL. The platform validates it on the first request and re-uses it for subsequent requests within that iframe.
Once you have an access token it is safe to pass it to a browser where needed (for example to load the editor iframe); because of the one-hour lifetime you typically fetch a fresh token per editor session. Cache tokens against the client_id.
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...' \ | 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 .
Provisioning SHARED
With your integrator_admin credentials (issued during onboarding) you provision one integrator_user per customer. The returned credentials are what your customer record stores, and what you later use to provision subusers under that customer.
Creates an integrator_user under the calling integrator_admin, plus an initial subuser beneath it (the customer's admin subuser), and returns that subuser's clientId / clientSecret. Idempotent on email within the caller's scope: re-posting the same email returns the existing account, without its clientSecret.
Authorization: integrator_admin token, scope users:create.
{
"email": "customer-42@partner-cms.example",
"organisationId": "ORG-12345"
}| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | A unique identifier for this customer within your tenant. It becomes the admin subuser's account address (the integrator_user is given main.<email>). It does not need to be a deliverable mailbox. |
organisationId | string | No | Your own code for this customer (max. 64 characters), stored on the integrator_user. Unique within your tenant: one integrator_user per organisation. Shown to DS Templates staff when they link custom templates to a customer, and returned on those templates in organisationIds. Once set it cannot be changed; re-posting an existing email with an organisationId sets it if the account has none yet. |
{
"userId": "01HRX...",
"email": "customer-42@partner-cms.example",
"organisationId": "ORG-12345",
"clientId": "f1c2d3e4...",
"clientSecret": "9a8b7c6d...",
"created": true
}An existing account returns 200 OK with "created": false and the same fields except clientSecret. Secrets are stored hashed, so the platform cannot return an existing one. If you no longer have it, issue a new one with POST /api/v1/users/{id}/client-secret.
| Field | Type | Description |
|---|---|---|
userId | string | Identifier of the admin subuser created by this call, not of the integrator_user. Treat as opaque. |
email | string | Echoes the request body. |
organisationId | string | null | The customer's organisation code, or null when none was given. |
clientId | string | OAuth client_id for the admin subuser. |
clientSecret | string | OAuth client_secret. Only present when created is true. Store it server-side; never expose it to a browser. |
created | bool | true if the account was just provisioned, false if it already existed. |
| Status | error | Cause |
|---|---|---|
400 | email is required | Body missing or email field absent |
400 | organisationId must be a non-empty string of at most 64 characters | organisationId is not a string, empty, or too long |
401 | No OAuth client found | Caller is not authenticated against an OAuth client |
403 | OAuth client does not have the required scopes | Token does not carry users:create |
409 | organisationId is already linked to another user | Another customer in your tenant already has this organisationId |
409 | This user is already linked to another organisationId | The existing account for this email has a different organisationId |
The admin subuser's client carries subusers:manage, templates:read and templates:write: it can provision further subusers, list/update/delete them, and read and write templates, modules and media directly. For per-end-user isolation (each person owning their own templates), provision a dedicated subuser per person and use that subuser's token for their editor sessions.
The clientSecret is shown once, in the 201 Created response. Store clientId and clientSecret server-side against your customer record. If the secret is lost or leaked, rotate it.
Rotate an account secret
Issues a new clientSecret for an account provisioned under the calling integrator_admin, typically the admin subuser returned by POST /api/v1/users/provision. {id} is the userId from the provisioning response. No request body.
Authorization: integrator_admin token, scope users:create.
{
"userId": "01HRX...",
"clientId": "f1c2d3e4...",
"clientSecret": "5e4d3c2b..."
}The previous secret stops working immediately. New token requests must use the new secret; access tokens already issued with the old secret stay valid until they expire. The clientId does not change, and the new secret is only shown in this response.
| Status | error | Cause |
|---|---|---|
403 | OAuth client does not have the required scopes | Token does not carry users:create |
404 | User not found | The ID is not an account with credentials under the calling integrator_admin |
Subuser Management SHARED
A subuser is the account that ultimately edits templates and owns saved work. Each customer's admin subuser (returned by POST /users/provision) provisions one subuser per end-user in your CMS. All routes require a Bearer token from that admin subuser's credentials, with subusers:manage scope.
If the caller has the right scope but is not itself a provisioned subuser of an integrator_user (credentials generated outside the normal provisioning flow), endpoints return 403 with {"error": "Caller is not a provisioned subuser"}.
Create subuser
Creates a subuser under the same customer (integrator_user) as the calling admin subuser. Idempotent on email within the calling integrator_admin's scope: re-posting the same email returns the existing subuser.
{
"email": "alice@partner-cms.example",
"firstName": "Alice",
"lastName": "Andersson",
"scopes": ["templates:read", "templates:write"],
"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. Does not need to be a deliverable mailbox. |
firstName | string | No | Defaults to email if omitted |
lastName | string | No | Defaults to the integrator_admin's client name |
scopes | string[] | No | OAuth scopes the subuser's credentials will carry (read-only vs read/write). Omitting this grants both templates:read and templates:write. See Access scopes. |
permissions.templateEditor | object | No | Per-field UI toggles. Omitting the whole object grants full access. Any field you omit keeps its (exposed) default; set fields to restrict. See Editor permissions. |
{
"userId": "01HRX...",
"email": "alice@partner-cms.example",
"firstName": "Alice",
"lastName": "Andersson",
"clientId": "abc123...",
"clientSecret": "xyz789...",
"scopes": ["templates:read", "templates:write"],
"permissions": { "templateEditor": { /* ... */ } },
"created": true
}If the email already exists, returns 200 OK with "created": false and the existing subuser without clientSecret. Secrets are stored hashed, so only the 201 response can include one. If you no longer have it, rotate it.
| Status | error | Cause |
|---|---|---|
400 | email is required | Body missing or email empty |
400 | scopes must be a non-empty array of strings | scopes was sent but was not a non-empty array |
400 | Unsupported scope(s): … | scopes contained a value other than templates:read / templates:write |
409 | email unavailable | A user with this email exists under a different integrator_admin |
409 | Integrator admin has no OAuth client | The integrator_admin is wired up incorrectly - contact DS Templates |
Access scopes
The scopes array decides whether the subuser can only view templates in the editor or also save changes. It is independent of the permissions.templateEditor UI toggles.
scopes value | Result |
|---|---|
| omitted | templates:read and templates:write - full read/write access |
["templates:read", "templates:write"] | Full read/write access (explicit form of the default) |
["templates:read"] | View-only. The editor loads in read-only mode; any save returns 403. |
["templates:write"] | Read/write - templates:read is always added automatically |
Only templates:read and templates:write are assignable; any other value is rejected with 400. A view-only subuser opens the same editor, but the controls are disabled and the save endpoint rejects writes server-side. This is the correct lever for a "reader" subuser: the templateEditor toggles only hide UI affordances and do not make a subuser read-only. Change scopes later with PATCH.
List subusers
Lists every subuser provisioned under the calling integrator_user. clientSecret is never returned on list.
{
"subusers": [
{
"userId": "01HRX...",
"email": "alice@partner-cms.example",
"firstName": "Alice",
"lastName": "Andersson",
"clientId": "abc123...",
"scopes": ["templates:read", "templates:write"],
"permissions": { "templateEditor": { /* ... */ } }
},
{
"userId": "01HSY...",
"email": "bob@partner-cms.example",
"firstName": "Bob",
"lastName": "Brown",
"clientId": "def456...",
"scopes": ["templates:read"],
"permissions": { "templateEditor": null }
}
]
}Get single subuser
Returns a single subuser by ID. Same shape as one element of the list, without clientSecret. Returns 404 Subuser not found if the ID is not a subuser under the calling integrator_user (also for unrelated subusers, to avoid leaking ID existence).
Update subuser
Updates name, scopes and/or editor permissions, and can optionally rotate the secret. Omitted fields are left unchanged. Email cannot be changed: to re-assign, delete and re-create with the new email.
{
"firstName": "Alice",
"lastName": "Andersson-Williams",
"scopes": ["templates:read"],
"permissions": {
"templateEditor": {
"fontColor": true
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
firstName | string | No | New first name |
lastName | string | No | New last name |
scopes | string[] | No | Replaces the subuser's scopes (e.g. downgrade to view-only with ["templates:read"]). Same validation as create. |
permissions.templateEditor | object | No | Sparse update - only the keys you include are written. |
rotateSecret | boolean | No | true also issues a new clientSecret, exactly like the rotate endpoint. Defaults to false. |
Returns the updated subuser in the same shape as GET /api/v1/subusers/{id}. With "rotateSecret": true the response also carries the new clientSecret, and the previous secret stops working immediately. If the request fails or times out before you have stored it, rotate again.
| Status | error | Cause |
|---|---|---|
403 | Cannot modify yourself | The token's subject is the same subuser as {id} |
404 | Subuser not found | See GET |
409 | Subuser has no OAuth client | rotateSecret was true but the subuser has no credentials to rotate; nothing was changed. Contact DS Templates. |
Rotate subuser secret
Issues a new clientSecret for a subuser under the calling integrator_user, when the original was lost or may have leaked. Only the secret changes: the subuser, its clientId, scopes and templates stay as they are. No request body. Requires subusers:manage; the admin subuser may also rotate its own secret this way.
{
"userId": "01HRX...",
"email": "alice@partner-cms.example",
...
"clientId": "abc123...",
"clientSecret": "4f3e2d1c..."
}The previous secret stops working immediately. Access tokens already issued with the old secret stay valid until they expire. The new secret is only shown in this response.
| Status | error | Cause |
|---|---|---|
403 | OAuth client does not have the required scopes | Token does not carry subusers:manage |
404 | Subuser not found | See GET |
409 | Subuser has no OAuth client | The subuser has no credentials to rotate - contact DS Templates |
Delete subuser
Deletes a subuser and revokes its OAuth client. Returns 204 No Content. Returns 403 Cannot delete yourself if the token's subject is {id}, 404 if not found. Saved templates remain but are no longer reachable by API. Re-creating with the same email does not restore access; the new subuser gets its own empty workspace.
Editor Permissions
The permissions.templateEditor object controls which UI affordances are exposed inside the editor. For the standard toggles, true exposes the control and false hides it. Defaults are permissive: omit the object for full access, and any field you leave out keeps its exposed default.
| Field | When true, the subuser may |
|---|---|
elementPositioning | Move and resize elements on the canvas |
elementAnimation | Edit per-element animation settings |
elementColor | Use the element background-colour control at all (false hides the button entirely). Freeform picker vs company palette is governed by elementColorPicker. |
elementColorPicker | Inverted toggle. false exposes the freeform colour picker (and hides the company palette); true restricts to the company palette. Default is false. |
fontSize | Change font size |
fontType | Change font family |
fontColor | Change font colour from the brand palette |
fontStyling | Toggle bold / italic / underline |
These flags are pure UI gating inside the editor iframe and do not affect API access. Align your own partner-side UI with the flags you set. To make a subuser read-only, use scopes instead.
Template Library SHARED
Three endpoints power the template-picker and "My Templates" views. All require templates:read. The two catalogue endpoints (/templates, /template-categories) accept your integrator_admin token as well as a subuser token; /my-templates needs a subuser token.
Returns the catalogue of templates grouped by category. The returned editorUrl is the iframe src you load when the user chooses a template. The general catalogue is the same for either token; only the custom templates category depends on which token you use.
| Parameter | Type | Required | Description |
|---|---|---|---|
category | string | No | Filter to a single category by name. URL-encode the value. If omitted, all categories are returned. |
[
{
"categoryName": "Information",
"translations": {
"nl_NL": "Informatie",
"en_GB": "Information",
"de_DE": "Informationen",
"fr_FR": "Informations"
},
"items": [
{
"name": "Office welcome screen",
"slug": "info_welcome_screen",
"isRealtime": false,
"dynamicDuration": false,
"isPrivate": false,
"imagePath": "https://common.digitalsignage-templates.com/template_previews/nl/info_welcome_screen.jpg",
"thumbnailPath": "https://common.digitalsignage-templates.com/template_previews/nl/info_welcome_screen_converted.webp",
"editorUrl": "https://templates.example.com/embed/template-editor/new/info_welcome_screen",
"previewUrl": "https://templates.example.com/template/info_welcome_screen/preview",
"modules": [
{ "name": "Welcome module", "slug": "welcome-module" }
]
}
]
}
]| Field | Type | Description |
|---|---|---|
categoryName | string | Source label (English). Also the value to pass to the category filter. |
translations | object | Localised category labels keyed by BCP-style locale (nl_NL, en_GB, de_DE, fr_FR, …) |
items[].name | string | Display name of the template |
items[].slug | string | Stable identifier for the template; used in editorUrl and previewUrl |
items[].isRealtime | boolean | true if the template auto-refreshes its data on a live screen (e.g. live weather, occupancy) |
items[].dynamicDuration | boolean | true if the template determines its own slide length at runtime (notably video templates) and emits DST_TEMPLATE_DURATION once known. When false it will not announce a duration; use your own configured duration. Always keep a fallback regardless: a dynamic template with no duration-driving content may never emit the event. |
items[].isPrivate | boolean | false for generic catalogue templates; true for custom templates made for specific customers (the Huisstijl templates category). |
items[].imagePath | string | Full-resolution preview image URL (JPG) |
items[].thumbnailPath | string | Low-resolution WebP derivative (525×290) of imagePath, for fast gallery loading. May briefly 404 for a freshly added template; fall back to imagePath. |
items[].editorUrl | string | Iframe URL for opening this template in the editor |
items[].previewUrl | string | Standalone preview URL (no editing), e.g. for hover-previews |
items[].modules | array | Modules this template requires the user to have set up to display real data. Empty [] when none. Use it to warn the user or surface a setup link before they pick a template. |
items[].modules[].name | string | Localised module display name, in the request locale |
items[].modules[].slug | string | null | The module's catalogue identifier, same value as link in GET /api/v1/modules. null for bespoke modules outside the self-service catalogue; identify those by name. |
items[].userIds | array of UUID | Only in Huisstijl templates: the main users (customers) this custom template is linked to. All of their subusers have access, except those in excludedSubuserIds. |
items[].organisationIds | array of string | Only in Huisstijl templates: the organisationId values (see Provisioning) of the users in userIds. Users provisioned without one are not represented, so this can be shorter than userIds. |
items[].excludedSubuserIds | array of UUID | Only in Huisstijl templates: subusers of the users in userIds that have been denied access. Usually empty. |
The response may include additional per-template metadata that is not stable across versions; rely only on the fields documented above. The internal category Onze service is never returned.
Custom templates
Templates built for one specific customer (in their house style) are returned in a separate category, Huisstijl templates. Each item carries a userIds array listing the main users (customers) it is linked to. A link to a main user covers all of its subusers, including subusers added later. A customer can deny a template to specific subusers; those are listed in excludedSubuserIds.
If you passed an organisationId when provisioning a customer, it is listed in organisationIds, so you can match a template to your own customer record without storing our userId. Pass it as well when you ask DS Templates for a custom template: it is how we find the customer when linking the template to their account.
| Token | Huisstijl templates category contains |
|---|---|
| integrator_admin | The custom templates of all your customers. A template linked to several customers lists all of them in userIds. |
| subuser | Only the custom templates this subuser may use; userIds contains just its main user. |
The category is left out entirely when there are no custom templates for the token, and a template is not returned to the token of a subuser that is denied access to it.
{
"categoryName": "Huisstijl templates",
"translations": { /* ... */ },
"items": [
{
"name": "Acme lobby screen",
"slug": "acme_lobby_screen",
/* ... */
"isPrivate": true,
"userIds": ["01HRX..."],
"organisationIds": ["ORG-12345"],
"excludedSubuserIds": []
}
]
}Show each custom template only to subusers of the users in its userIds, minus those in excludedSubuserIds. The editor enforces this too: opening a custom template's editorUrl with a subuser token that is not linked to it is refused.
Template Categories
Returns the flat list of category names with translations, without their items. Useful for rendering a category picker independently of loading templates. Accepts an integrator_admin or subuser token.
[
{
"name": "Information",
"translations": {
"nl_NL": "Informatie",
"en_GB": "Information"
}
}
]name is the value to pass to GET /api/v1/templates?category=…. Onze service is excluded. Huisstijl templates is listed only when /templates would return custom templates for the same token.
Saved Templates (My Templates)
Returns every saved template belonging to the calling subuser. Powers the "My Templates" view. Requires a subuser token.
| Parameter | Type | Description |
|---|---|---|
search | string | Case-insensitive substring match against name |
createdAfter | integer (unix epoch seconds) | Templates created strictly after this timestamp |
createdBefore | integer (unix epoch seconds) | Templates created strictly before this timestamp |
modifiedAfter | integer (unix epoch seconds) | Templates last modified strictly after this timestamp |
modifiedBefore | integer (unix epoch seconds) | Templates last modified strictly before this timestamp |
[
{
"id": "01HRX...",
"name": "Reception screen - Q2",
"orientation": null,
"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://...",
"thumbnailCreatedAt": "2026-05-19T14:02:13+00:00",
"videoUrl": null,
"videoCreatedAt": null,
"duration": 15,
"modules": [
{ "name": "Welcome module", "slug": "welcome-module" }
],
"latestVersionId": "01HSY...",
"versions": [
{ "id": "01HRX...", "modifiedAt": "2026-04-12T08:30:00+00:00" },
{ "id": "01HSY...", "modifiedAt": "2026-05-19T14:02:11+00:00" }
]
}
]| Field | Type | Description |
|---|---|---|
id | UUID | Identifier of the root template (the original first saved) |
name | string | Display name |
orientation | string | null | Reserved for future use; always null today |
aspectRatio | string | One of 16:9, 9:16, 4:3, 1:1 |
url | string | Renderable URL for a signage player. See Rendering for the integrator-host and trackingId requirements. |
createdAt / modifiedAt | ISO 8601 string | When the root template was first saved / last modified |
thumbnailUrl | string | null | Pre-rendered thumbnail; null if not yet generated |
thumbnailCreatedAt | ISO 8601 string | null | When the thumbnail was generated. Compare with modifiedAt to detect stale thumbnails. |
videoUrl, videoCreatedAt | always null | Reserved for future use |
duration | integer (seconds) | How long this template should play in a playlist loop |
modules | array | Modules this saved template requires, inherited from its library template. Same shape as modules on /templates. |
latestVersionId | UUID | ID of the most recently saved version (equals id if never re-saved) |
versions[] | array | Every version including the root, sorted oldest-first, each with id and modifiedAt |
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 (e.g. a campaign's v3 in the lobby while v2 stays in meeting rooms).
- For editing: always pass
latestVersionId. The editor clones the version you give it into a new child on save, so editing an older version creates a divergent branch.
Module Catalogue SHARED
Some templates embed modules: independent feature areas that need their own setup (a Bundeling connection, a SolarEdge API key, a list of calamities, a poll, …). Each module exposes a configuration UI as an iframe (see Module iframes). This endpoint returns the catalogue so your CMS can present them.
Authorization: templates:read (subuser token, or your integrator_admin token for platform-level caching).
{
"baseUrl": "https://templates.example.com/embed/modules/%s?token=%s",
"categories": {
"simple-credential": [
{
"name": "Bundeling",
"link": "bundeling-module",
"translations": { "nl_NL": "Bundeling module", "de_DE": "Bundeling-Modul" }
},
{ "name": "Education", "link": "education-module", ... },
{ "name": "Humly", "link": "humly-module", ... },
{ "name": "Remote Device Management", "link": "mdm-module", ... },
{ "name": "Smart Event Manager", "link": "sem-module", ... },
{ "name": "SolarEdge", "link": "solar-edge-module", ... }
],
"manual-entry": [
// Birthday, Calamity, Events, Internal News, Meeting Room,
// Menu Board, Poll, Presence, Welcome
],
"oauth": [
// Google, LinkedIn, Microsoft, TeamLeader
]
}
}| Field | Type | Description |
|---|---|---|
baseUrl | string (sprintf template) | Two %s placeholders: first the module link, second the access token |
categories | object | Modules grouped by configuration style. Keys are stable; order within a category may change. |
categories[*][].name | string | English source label, stable for matching |
categories[*][].link | string | URL slug; substitute into baseUrl. Same value as modules[].slug on templates. |
categories[*][].translations | object | Localised module label keyed by BCP-style locale |
const baseUrl = response.baseUrl; // from /api/v1/modules const moduleLink = response.categories['simple-credential'][0].link; // "bundeling-module" const token = await getAccessToken(); // subuser token const iframeSrc = baseUrl.replace('%s', moduleLink).replace('%s', token); // → "https://templates.example.com/embed/modules/bundeling-module?token=eyJ..."
Media & Branding OPTION 1 ONLY
Wire partner-owned content into templates: register images and videos your CMS already hosts, and set a customer's logos and brand palette. Both run on a subuser token with templates:write.
The external-media model
When you register an external image or video, the platform does not receive the binary. It stores a small metadata record and treats fileName as an opaque identifier chosen by you. At render time the platform emits a placeholder data:external-media,<urlencoded fileName>, and the iframe asks your parent window for the real URL via the media resolution protocol. Your CMS is the source of truth for the binary: choose an identifier you can resolve cheaply. There is no upload step.
Registers an image record that templates can attach to image elements, slideshows and CSS backgrounds.
{
"fileName": "office-front.jpg",
"title": "Office building, exterior",
"source": "<integrator-key>"
}| Field | Type | Required | Description |
|---|---|---|---|
fileName | string | Yes | Your identifier, which the renderer will ask for at render time. Include the file extension. |
title | string | No | Human-readable label for the picker UI. Defaults to fileName. |
source | string | No | Your integrator key (the default). |
Returns 200 OK with { id, title, fileName, source }. Errors: 400 fileName missing, 401 not authenticated, 403 token lacks templates:write.
Registers a video record. Identical to the image endpoint plus an optional duration (number, seconds, default 10) that tells the player how long a video slide should stay on screen. The response echoes duration.
{
"fileName": "intro.mp4",
"title": "Showroom intro reel",
"source": "<integrator-key>",
"duration": 30
}Company branding
Branding lives on the customer (integrator_user) account, not on the individual subuser: it is shared across all subusers of that customer, and any subuser's write updates it for all of them. (External media, by contrast, is stored per subuser.)
- Logos: an ordered list. Templates that render a logo take the first one unless an element overrides it. Logos are external media too: their
filenameis resolved via the same postMessage handshake. - Colours: twenty named slots (
color1…color20). Template styles reference these by slot, so changing a slot recolours every element that uses it.
Replaces the customer's logo list and/or updates colour slots. Include logos, colors, or both; the response echoes the current state of whichever halves were addressed.
{
"logos": [
{ "filename": "primary-logo.png", "sortOrder": 0 },
{ "filename": "secondary-logo.png", "sortOrder": 1 }
],
"colors": {
"color1": "#0F4C81",
"color2": "#F4C430",
"color6": "#222222"
}
}| Field | Type | Required | Description |
|---|---|---|---|
logos | array | No | New logo list. Replaces the existing list in full. Omit to leave logos untouched; send [] to delete all. |
logos[].filename | string | Yes | Your identifier, resolved via postMessage at render time. Entries without a filename are silently skipped. No separate external-image registration needed. |
logos[].sortOrder | int | No | Display order. Defaults to 0. |
colors | object | No | Slots to update (merge-on-write). Slots not mentioned are preserved. Omit to leave colours untouched. |
colors.colorN | string | No | Value for slot N (1 through 20). Free-form string, typically a CSS hex code. Pass null to clear a slot. |
{
"logos": [
{ "sortOrder": 0, "filename": "primary-logo.png" },
{ "sortOrder": 1, "filename": "secondary-logo.png" }
],
"colors": {
"color1": "#0F4C81",
"color2": "#F4C430",
"color3": null,
...
"color6": "#222222",
...
"color20": null
}
}logos is the full list after the write ([] if none, or if the request had no logos key). colors contains all twenty slots, null for unset ones (returned as [] if the request had no colors key). Errors: 400 body not valid JSON / not an object, 401 not authenticated, 403 token lacks templates:write.
Editor Iframe SHARED
The template editor is a full editing UI hosted by the platform and embedded as an iframe in your CMS. Your CMS controls the surrounding chrome (save button, back link, status overlays) and exchanges postMessage events with the iframe to drive saves and read state.
# Create a new template from a catalogue entry ({slug} from /api/v1/templates) https://templates.example.com/embed/template-editor/new/{slug}?token={access_token} # Edit an existing template ({id} = latestVersionId from My Templates) https://templates.example.com/embed/template-editor/edit/{id}?token={access_token}&source=<integrator-key>
| Param | Required | Notes |
|---|---|---|
token | Yes | A subuser access token. Used to authenticate the iframe load and to authorise its outgoing calls (save, external-media registration). |
source | Yes, when editing | Your integrator identifier (<integrator-key>, issued at onboarding). Switches saving to the versioning flow. It must match exactly: an unrecognised value is silently ignored, and saving then mutates the existing template in place instead of creating a new version. Omitting it has the same effect. |
locale | No | Editor UI language (short or BCP-style code, e.g. nl or nl_NL). Cross-site iframes cannot rely on session cookies, so this is the only way to control the editor's language. Unknown locales fall back to en_GB. |
<iframe id="template-editor" src="https://templates.example.com/embed/template-editor/edit/01HRX...?token=eyJ...&source=<integrator-key>&locale=nl_NL" style="width:100%;height:100vh;border:none" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox" ></iframe>
The editor needs allow-scripts, allow-same-origin and allow-forms; allow-popups is required for media dialogs that open in a new window.
Set token once on the initial iframe src. The platform validates it on first load, and calls made from inside the iframe re-use it as a Bearer header (cross-site cookies cannot be relied on). When the token expires, the next save fails with a 401: listen for DST_SAVE_ERROR and reload the iframe src with a fresh token.
Save lifecycle
- User clicks Save in your CMS chrome; your CMS posts
DST_EDITOR_SAVEto the iframe. - Editor replies with
DST_SAVE_STARTED: show a spinner. - Editor posts either
DST_SAVE_COMPLETE(with the savedtemplateIdand amediasummary) orDST_SAVE_ERROR(with an error and, for validation failures, anerrorslist).
Editing with ?source=<integrator-key> clones the template and saves the changes on the clone. The clone's ID is returned in DST_SAVE_COMPLETE as templateId; the original is untouched. Store the returned templateId and open that ID next time. Screens pointing at the old ID keep rendering the old version until you re-target them.
DST_SAVE_COMPLETE.media lists every external-media fileName the saved template references, split into images and videos. Use it to mark those files as "in use" in your CMS so they are not deleted from under the template.
{
"templateId": "01HRY...",
"message": "Template saved",
"media": {
"images": ["office-front.jpg", "team-photo.png"],
"videos": ["intro.mp4"]
}
}Save errors
| HTTP | Editor emits | Likely cause |
|---|---|---|
200 with { success: true, … } | DST_SAVE_COMPLETE | Saved cleanly |
400 with { success: false, errors: […] } | DST_SAVE_ERROR with errors | Form validation failed |
401 | DST_SAVE_ERROR | Token expired or invalid - reload the iframe with a new one |
403 | DST_SAVE_ERROR | Token lacks templates:write (view-only subuser: "Read-only access: missing templates:write scope"), or the subuser lacks a required user permission |
| Non-JSON response | DST_SAVE_ERROR with "Server returned HTML instead of JSON" | Almost always an authentication redirect - treat as 401 |
Module Iframes SHARED
Each module from the module catalogue exposes its own configuration UI as an iframe. Unlike the editor, module iframes do not use a postMessage protocol: 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>
{link} is a link value from GET /api/v1/modules. You set token once on the initial src; an embedded script forwards it on every in-iframe link, form submit and AJAX call, so your parent CMS does not need to participate. token must be a subuser access token. Each module additionally requires per-feature user permissions configured by DS Templates. A 401 inside the iframe means the token expired: reload the src with a fresh one.
Module categories
| Category | Description | Examples |
|---|---|---|
| simple-credential | Short form for an API key or credentials. Validates and shows a "Connected" state. | Bundeling, SolarEdge, Humly, MDM |
| manual-entry | CRUD UI for user-maintained data: list view, edit form. Birthday, Welcome and Calamity also offer CSV import inside the iframe. | Birthday, Calamity, Events, Internal News, Meeting Room, Menu Board, Poll, Presence, Welcome |
| oauth | 3-legged OAuth via a popup window; the platform handles the handshake. Needs a one-time provider setup before "Connect" works. | LinkedIn, Microsoft, Google (available); TeamLeader (planned) |
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.
OAuth Module Setup SHARED
The oauth modules connect a template to a third-party service on the customer's behalf. Before the in-iframe "Connect" button works, someone has to register an OAuth application with the provider and wire its redirect URI and credentials to the platform. The runtime (popup handshake) needs nothing from your CMS.
| Module | Status | Credential model |
|---|---|---|
| Available | Customer-owned: the customer pastes their app's client id + secret into the module's form | |
| Microsoft | Available - six services, set up per service | Partner-owned: you own one app per service and register its credentials via the API |
| Available - Calendar and Analytics, set up per integration | Partner-owned | |
| TeamLeader | Planned - its URL returns 404 until it ships | Customer-owned |
In both models the redirect URI is registered in the provider's developer console and points at https://<platform-host>/embed/modules/<module-link>/authorize, where <platform-host> is the host from baseUrl in GET /api/v1/modules.
Providers match the redirect URI character for character. Register it exactly as shown, minding query strings and trailing slashes. Register one URI per host you test on (e.g. staging and production).
- In the LinkedIn Developer Portal, create (or reuse) an app for the customer's organisation.
- On the Products tab, request the Pages Data Portability API product (grants
r_dma_admin_pages_content). Approval can take time, so request it early. - Register the redirect URI
https://<platform-host>/embed/modules/linkedin-module/authorize, including theclientIdquery parameter that LinkedIn's embed flow carries. - In the module iframe, the customer pastes the app's client id and secret and clicks connect.
LinkedIn DMA tokens have a server-side lifecycle of their own, so a healthy-looking connection can occasionally return a 401 and need a re-connect. This is expected LinkedIn behaviour.
Microsoft
Create one Entra app registration per service (permissions differ per service, and separate apps keep each customer's consent scoped):
| Service | {target} | Delegated permissions (nothing more) |
|---|---|---|
| SharePoint | MS_SHAREPOINT | Graph: offline_access, User.Read, Sites.Read.All (admin consent) |
| Power BI | MS_POWERBI | Graph: offline_access; Power BI Service: Workspace.Read.All, Report.Read.All, Dashboard.Read.All, Dataset.Read.All |
| Microsoft Teams | MS_TEAMS | Graph: offline_access, User.Read, User.ReadBasic.All |
| Outlook calendar | MS_MEETING_ROOM | Graph: offline_access, User.Read, Calendars.Read.Shared, Place.Read.All (admin consent) |
| Outlook mailbox | MS_MAILBOX | Graph: offline_access, User.Read, Mail.ReadBasic (metadata only) |
| Outlook mailbox (advanced) | MS_MAILBOX_ADVANCED | Graph: offline_access, User.Read, Mail.Read (bodies and attachments) |
Per app: account type multi-tenant; a Web redirect URI of exactly https://<platform-host>/embed/modules/microsoft-module/authorize (no query string, no trailing slash); a client secret; and the permissions above, all Delegated, never Application. Add only what is listed: Entra puts every consented permission into live tokens.
Sites.Read.All (SharePoint) and Place.Read.All (Outlook calendar) need a Global Administrator of the customer's tenant to consent once, either by being the first to click "Grant access" or up front via https://login.microsoftonline.com/<customer-tenant-id>/adminconsent?client_id=<your-client-id>. Without it the popup returns AADSTS65001. Your portal may still show "No" for Sites.Read.All; treat it as requiring consent. Entra caps secrets at 24 months, and a lapsed secret breaks every customer at once.
| Integration | {target} | Scope |
|---|---|---|
| Google Calendar | GOOGLE_CALENDAR | https://www.googleapis.com/auth/calendar.readonly |
| Google Analytics | GOOGLE_ANALYTICS | https://www.googleapis.com/auth/analytics.readonly |
Both clients can live in one Google Cloud project. Enable the Google Calendar API (Calendar) and the Google Analytics Data API and Admin API (Analytics). Configure the OAuth consent screen as External with the scopes above, then create one Web application client per integration with the redirect URI https://<platform-host>/embed/modules/google-module/authorize (no query string, no trailing slash). Each user holds one Google account per integration.
Both scopes are sensitive. Until Google verifies the app, it stays in Testing: max. 100 hand-added test users, an "unverified app" warning, and refresh tokens that expire after 7 days. Verification is a review of your consent screen, privacy policy and domain ownership (no paid security assessment); start early.
Registering your credentials
For Microsoft and Google you register credentials yourself. These routes need an integrator_admin token with oauth-apps:manage; a subuser token is rejected.
Creates or replaces the credentials for one service. Rotating a secret is just another PUT. clientId and clientSecret are both required every time. tenantId is optional for Microsoft (default common; set a tenant id only to restrict consent to one directory) and does not apply to Google.
{
"clientId": "55f919ac-1f4e-4d2e-9a77-0c3b1f9a7e21",
"clientSecret": "your-entra-client-secret",
"tenantId": "common"
}{
"target": "MS_SHAREPOINT",
"configured": true,
"clientId": "55f919ac-1f4e-4d2e-9a77-0c3b1f9a7e21",
"clientSecret": "********f3a9",
"tenantId": "common",
"createdAt": "2026-09-22T11:04:19+00:00",
"updatedAt": null
}The secret is write-only: every response masks it, so keep your own copy. A client id may be registered against exactly one service (reusing one returns 409).
Lists every Microsoft and Google service, whether credentials are held (configured), and the exact permissions each service's app must carry (requiredScopes). Use it to confirm a registration landed and to check your app's permissions before telling a customer to grant access.
Removes the credentials for a service. Customers who already granted access keep their stored tokens, but once one expires it can no longer be refreshed and the module stops working until you register credentials again.
| Status | error | Cause |
|---|---|---|
400 | clientId and clientSecret are both required. | One of the pair was missing or empty |
400 | Request body must be a JSON object. | Body was not valid JSON |
400 | Unknown target '…'. Supported: … | {target} is not a Microsoft or Google service target |
400 | clientId, clientSecret or tenantId is too long. | Field exceeded its limit (255 / 1000 / 255 characters) |
403 | Only an integrator admin may manage OAuth applications. | The token belongs to a subuser |
403 | OAuth client does not have the required scopes (missing: oauth-apps:manage) | Scope not registered on your client - contact DS Templates |
404 | No application is configured for this target. | DELETE for a service you hold no credentials for |
409 | This clientId is already configured for … | Client id already registered against a different service |
409 | This clientId is already registered by another integrator. | Client id belongs to a different integration |
PostMessage Reference
Every window.postMessage event exchanged between your parent CMS and the platform's iframes. The editor and playback protocols live in different iframes; media resolution runs alongside either. Module iframes use no postMessage protocol.
Origin validation: the platform's iframes check event.origin against trusted origins before processing incoming messages, except DST_RENDERER_MEDIA_URL_RESPONSE, which is accepted from any origin. Your CMS should likewise validate the iframe's origin against the URL it loaded. The platform posts to the event.origin of the most recent incoming message, or '*' before any message has been received.
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 an "Open X module" button. Open that module's configuration iframe; moduleLink is the catalogue link (see Module catalogue). |
DST_EDITOR_OPEN_BRANDING_SETTINGS | {} | User clicked "add company logo". Open your company-branding UI. If the parent ignores this, the button does nothing. |
The editor also emits three notification events that need no reply; ignore them unless you want to react:
| Event | Payload | Meaning |
|---|---|---|
DST_ASPECT_RATIO_CHANGED | { aspectRatio } | The template's aspect ratio changed. Useful for resizing the iframe. |
DST_PREVIEW_DATA_UPDATED | { key, previewUrl } | A fresh live-preview render is available at previewUrl. |
DST_PREVIEW_UPDATE_ERROR | { error } | A live-preview refresh failed. |
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 its expected runtime during pre-render | templateId, templateName, duration (seconds) |
setDuration | Legacy alias of DST_TEMPLATE_DURATION | duration (seconds) |
pauseSlideTimer / resumeSlideTimer | A popup or overlay inside the template opened/closed - pause/resume your external slide timer | - |
All events are sent as window.parent.postMessage({ msg, … }, '*'). startTime and stopTime are ms epoch; playDuration is in ms.
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 } parent: tear down or swap iframe
The DST_TEMPLATE_DURATION step only happens for dynamic-duration templates. If the parent never sends 'initIntroAnimation', the template never starts playing and emits no DST_TEMPLATE_* events (intros, ticking content and videos don't run). If it never sends 'exitIntroAnimation', the template plays indefinitely; there is no platform-side timeout.
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, or a logo set via POST /api/v1/company-branding/set (see Media & branding).
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. At startup it scans [data-external-media] attributes, <style> text and inline style="…" attributes for data:external-media,… references and sends one request per unique filename. If your parent never replies, the placeholders stay in the DOM and no media renders.
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 |
400 | organisationId must be a non-empty string of at most 64 characters | organisationId is not a string, empty, or too long |
404 | User not found | POST /users/{id}/client-secret for an ID that is not an account with credentials under the caller |
409 | organisationId is already linked to another user | Another customer in your tenant already has this organisationId |
409 | This user is already linked to another organisationId | The existing account for this email has a different organisationId |
Subuser management
| Status | error | When |
|---|---|---|
400 | email is required | Request body missing or email field absent |
400 | scopes must be a non-empty array of strings | scopes was sent but was not a non-empty array |
400 | Unsupported scope(s): … | scopes contained a value other than templates:read / templates:write |
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 |
409 | Subuser has no OAuth client | Secret rotation for a subuser without credentials - contact support |
For the /api/v1/integrator/oauth-apps errors, see OAuth module setup.
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."
]
}| Status | Response body | When |
|---|---|---|
400 | { success: false, error: "Validation failed", errors: [...] } | Form validation failed. errors aggregates human-readable messages across all fields. |
403 | { success: false, error: "Read-only access: missing templates:write scope" } | View-only subuser; the save was rejected server-side |
401 | (server-rendered HTML login page) | Token expired. Surfaces as DST_SAVE_ERROR with "Server returned HTML instead of JSON". |
The embedded editor always saves via XHR, so you always get the JSON envelope with a 400 on validation errors; check success. The editor's DST_SAVE_ERROR postMessage passes the errors array through verbatim - render it as a list under your save button.
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 and driving its playback lifecycle via postMessage.
| Use case | URL | Auth |
|---|---|---|
| Render saved template for playback | https://{integrator-host}/template/{id} | None - but must be from a recognised integrator host + trackingId |
| Render catalogue preview by slug | https://templates.example.com/template/{slug}/preview | None (public). Returned as previewUrl by /api/v1/templates. |
Required: integrator host
Saved templates created via the partner API may only be rendered from a host the platform recognises as one of your integrator domains. You are issued a production and a staging host at onboarding:
| Use | Host |
|---|---|
| Production | <integrator-key>.ds-templates.com |
| Staging | <integrator-key>-staging.ds-templates.com |
<integrator-key> is the same identifier you pass as source to the editor. A request from any other host (including templates.example.com itself) returns 400: "API templates must be served from a recognized integrator domain."
Required: trackingId
Every render request must carry a ?trackingId= query parameter, or it returns 400: "trackingId is required for this template." It is a stable identifier for the device displaying the template (e.g. screen serial number, device UUID), used for hourly usage counting and deduplication. You do not need to register it ahead of time.
- 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 on window.load: no preRenderFinished, no way to start or stop it, no DST_TEMPLATE_* events. Useful for thumbnails, not for screens. With it, the template enters parent-driven mode: pre-renders, emits preRenderFinished, then waits for 'initIntroAnimation'. See the Playback Protocol.
Optional query parameters
| Param | Type | Effect |
|---|---|---|
locale | locale string | Render in a specific locale. Short (nl) and BCP-style (nl_NL) codes both work. Unknown locales fall back to en_GB. |
timezone | IANA timezone | e.g. Europe/Amsterdam; used for server-side time formatting. Falls back to the platform default if missing or invalid. |
screen | string | Optional screen identifier. Reserved for future use. |
aspectRatio | string | Force 16:9 or 9:16. Defaults to the template's saved aspect ratio. |
The preview URL accepts locale, aspectRatio and playlist too (use ?playlist=true for animated in-CMS thumbnails). Preview HTML is cached per (slug, locale); saved-template rendering is not cached, so to pick up changed data (a new calamity, new brand colours) simply reload the iframe.
<iframe src="https://<integrator-key>.ds-templates.com/template/01HRY...?trackingId=screen-A4F2&playlist=true&locale=nl_NL" sandbox="allow-scripts allow-same-origin" allow="autoplay" ></iframe>
allow="autoplay" is needed for any template that contains video, otherwise the browser blocks playback until user interaction. No cookie or token is needed on the render path.
Parent-side render loop
const RENDER_HOST = 'https://<integrator-key>.ds-templates.com'; function renderSlide({ templateId, durationFallbackMs }) { return new Promise((resolve) => { const iframe = document.createElement('iframe'); iframe.src = `${RENDER_HOST}/template/${templateId}` + `?trackingId=${screenId}&playlist=true&locale=${locale}`; iframe.sandbox = 'allow-scripts allow-same-origin'; iframe.allow = 'autoplay'; let templateDurationMs = null; let stopTimer = null; const stop = () => iframe.contentWindow.postMessage('exitIntroAnimation', '*'); const onMessage = (event) => { // Option 1: answer media resolution requests from the same iframe if (event.data?.type === 'DST_RENDERER_MEDIA_URL_REQUEST') { iframe.contentWindow.postMessage({ type: 'DST_RENDERER_MEDIA_URL_RESPONSE', fileNameWithExtension: event.data.fileNameWithExtension, mediaUrl: resolveMediaUrl(event.data.fileNameWithExtension), }, '*'); return; } switch (event.data?.msg) { case 'preRenderFinished': iframe.contentWindow.postMessage('initIntroAnimation', '*'); stopTimer = setTimeout(stop, templateDurationMs ?? durationFallbackMs); break; case 'DST_TEMPLATE_DURATION': // Dynamic-duration template told us how long it expects to play templateDurationMs = event.data.duration * 1000; if (stopTimer) { clearTimeout(stopTimer); stopTimer = setTimeout(stop, templateDurationMs); } break; case 'DST_TEMPLATE_STOP': window.removeEventListener('message', onMessage); if (stopTimer) clearTimeout(stopTimer); resolve(); break; } }; window.addEventListener('message', onMessage); // before the iframe loads document.body.appendChild(iframe); }); }
- Prefer
DST_TEMPLATE_DURATIONwhen it fires, but always keep a fallback timer: static-duration templates never emit it. ThedynamicDurationflag on/api/v1/templatestells you up front which kind a template is. - Wait for
DST_TEMPLATE_STOPbefore tearing down the iframe, so the template can clean up. - Install the media-resolution handler before the iframe loads. The first request is sent during pre-render; a late listener misses it and the placeholder never resolves.
Conventions SHARED
| Topic | Convention |
|---|---|
| Field naming | All JSON fields are camelCase. The only exception is the token endpoint (access_token, token_type, expires_in), mandated by RFC 6749. |
| Identifiers | Every id, userId, templateId is an opaque string (ULIDs today). Stable, globally unique, safe to log and cache on; do not parse them. |
| Timestamps | ISO 8601. No endpoint requires timestamps in request bodies (the my-templates filters take unix epoch seconds). |
| Locales | Short codes (nl, en, de, fr) and BCP-style codes (nl_NL, en_GB, en_US, de_DE, fr_FR) both work. Unknown locales fall back to en_GB. |
| Idempotency | POST /users/provision and POST /subusers are idempotent on email: re-posting returns the existing record with created: false and 200 OK, without clientSecret. Safe for at-least-once retries. Secrets are only shown on creation, via the /client-secret rotate endpoints, or on PATCH /subusers/{id} with "rotateSecret": true. Write endpoints (library, branding, editor save) are not idempotent. |
| Pagination | None. List endpoints return the full collection; filter client-side. |
| Versioning | /api/v1 is the major version. Breaking changes will introduce /api/v2; non-breaking additions ship in place. |
| Encoding | All partner endpoints are application/json, UTF-8. No binary uploads: media uses the external-media registration model. |