API Documentation

Sign in to access the API integration guide.

Don't have credentials? Request access

Confidential — for integration partners only

← Back to overview
Docs Integration Guide
v1.0
Integration Guide v2.0

API Integration Guide

Integrate DS Templates into your platform. Choose between a native deep integration with your CMS or a standalone white-label deployment.

OAuth 2.0 REST API PostMessage Iframe White-label
Confidential - Integration partners only

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.

Deep CMS Media bridge Full protocol

Option 2: Standalone Integration

White-label deployment using DS Templates' built-in media library and editor. Simpler integration without CMS media bridging.

White-label Built-in library Simple setup

Feature Comparison

FeatureOption 1: NativeOption 2: Standalone
Media libraryYour CMS media library via postMessage bridgeDS Templates built-in library
Branding / logoInjected from CMS via APIConfigured in DS Templates
Template editorEmbedded iframe with full controlEmbedded iframe, standard
PostMessage eventsFull protocol (editor + media + playback)Basic protocol (editor + playback)
Module configurationIframe-based, token authIframe-based, token auth
White-label domainSupportedSupported
Integration effortHigher - requires media resolutionLower - plug and play
Best forCMS platforms wanting native feelPartners 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.

Account model
text
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)
AccountDescription
integrator_adminTop-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_userOne 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 subuserCreated by the same provisioning call; its clientId / clientSecret are what you store against the customer record. Carries subusers:manage, templates:read and templates:write.
subuserOne 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:

SurfacePattern
REST APIhttps://templates.example.com/api/v1/...
OAuth token endpointhttps://templates.example.com/api/v1/token
Editor iframehttps://templates.example.com/embed/template-editor/...
Module config iframehttps://templates.example.com/embed/modules/...
Rendered templatehttps://<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
Request
Token Request
http
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>
ParameterRequiredDescription
grant_typeYesMust be client_credentials
client_idYesThe client identifier issued during provisioning
client_secretYesThe 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.

Successful response
200 OK
json
{
    "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 response
400 Bad Request
json
{
    "error": "invalid_client",
    "error_description": "Client authentication failed",
    "message": "Client authentication failed"
}
error valueCause
invalid_clientWrong client_id or client_secret, or the client is inactive
invalid_grantgrant_type is not supported for this client
invalid_scopeA requested scope is not registered against your client
invalid_requestA required parameter is missing or malformed
Token caching

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

ScopeGranted toPermits
users:createintegrator_adminPOST /api/v1/users/provision
templates:readintegrator_admin, subuserGET /api/v1/templates, GET /api/v1/template-categories, GET /api/v1/modules (integrator_admin); plus GET /api/v1/my-templates (subuser only)
subusers:manageadmin subuser (the initial subuser returned by POST /users/provision)All /api/v1/subusers/* routes
oauth-apps:manageintegrator_adminAll /api/v1/integrator/oauth-apps routes: registering your own OAuth application credentials (see OAuth module setup)
templates:writesubuserPOST /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.

Scope strategy

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

Bearer token usage
http
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

curl example
bash
# 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.

POST/api/v1/users/provision

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.

Request body
Provision customer
json
{
    "email": "customer-42@partner-cms.example",
    "organisationId": "ORG-12345"
}
FieldTypeRequiredDescription
emailstringYesA 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.
organisationIdstringNoYour 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.
Response - newly created (201)
201 Created
json
{
    "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.

FieldTypeDescription
userIdstringIdentifier of the admin subuser created by this call, not of the integrator_user. Treat as opaque.
emailstringEchoes the request body.
organisationIdstring | nullThe customer's organisation code, or null when none was given.
clientIdstringOAuth client_id for the admin subuser.
clientSecretstringOAuth client_secret. Only present when created is true. Store it server-side; never expose it to a browser.
createdbooltrue if the account was just provisioned, false if it already existed.
Error responses
StatuserrorCause
400email is requiredBody missing or email field absent
400organisationId must be a non-empty string of at most 64 charactersorganisationId is not a string, empty, or too long
401No OAuth client foundCaller is not authenticated against an OAuth client
403OAuth client does not have the required scopesToken does not carry users:create
409organisationId is already linked to another userAnother customer in your tenant already has this organisationId
409This user is already linked to another organisationIdThe existing account for this email has a different organisationId
What the returned credentials can do

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.

Store the secret

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

POST/api/v1/users/{id}/client-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.

200 OK
json
{
    "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.

StatuserrorCause
403OAuth client does not have the required scopesToken does not carry users:create
404User not foundThe 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

POST/api/v1/subusers

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.

Request body
Create Subuser
json
{
    "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
        }
    }
}
FieldTypeRequiredDescription
emailstringYesUnique identifier for this subuser. Does not need to be a deliverable mailbox.
firstNamestringNoDefaults to email if omitted
lastNamestringNoDefaults to the integrator_admin's client name
scopesstring[]NoOAuth 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.templateEditorobjectNoPer-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.
Response - newly created (201)
201 Created
json
{
    "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.

StatuserrorCause
400email is requiredBody missing or email empty
400scopes must be a non-empty array of stringsscopes was sent but was not a non-empty array
400Unsupported scope(s): …scopes contained a value other than templates:read / templates:write
409email unavailableA user with this email exists under a different integrator_admin
409Integrator admin has no OAuth clientThe 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 valueResult
omittedtemplates: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

GET/api/v1/subusers

Lists every subuser provisioned under the calling integrator_user. clientSecret is never returned on list.

Response
200 OK
json
{
    "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

GET/api/v1/subusers/{id}

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

PATCH/api/v1/subusers/{id}

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.

Request body
PATCH Subuser
json
{
    "firstName": "Alice",
    "lastName": "Andersson-Williams",
    "scopes": ["templates:read"],
    "permissions": {
        "templateEditor": {
            "fontColor": true
        }
    }
}
FieldTypeRequiredDescription
firstNamestringNoNew first name
lastNamestringNoNew last name
scopesstring[]NoReplaces the subuser's scopes (e.g. downgrade to view-only with ["templates:read"]). Same validation as create.
permissions.templateEditorobjectNoSparse update - only the keys you include are written.
rotateSecretbooleanNotrue 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.

StatuserrorCause
403Cannot modify yourselfThe token's subject is the same subuser as {id}
404Subuser not foundSee GET
409Subuser has no OAuth clientrotateSecret was true but the subuser has no credentials to rotate; nothing was changed. Contact DS Templates.

Rotate subuser secret

POST/api/v1/subusers/{id}/client-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.

200 OK
json
{
    "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.

StatuserrorCause
403OAuth client does not have the required scopesToken does not carry subusers:manage
404Subuser not foundSee GET
409Subuser has no OAuth clientThe subuser has no credentials to rotate - contact DS Templates

Delete subuser

DELETE/api/v1/subusers/{id}

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.

FieldWhen true, the subuser may
elementPositioningMove and resize elements on the canvas
elementAnimationEdit per-element animation settings
elementColorUse the element background-colour control at all (false hides the button entirely). Freeform picker vs company palette is governed by elementColorPicker.
elementColorPickerInverted toggle. false exposes the freeform colour picker (and hides the company palette); true restricts to the company palette. Default is false.
fontSizeChange font size
fontTypeChange font family
fontColorChange font colour from the brand palette
fontStylingToggle 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.

GET/api/v1/templates

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.

Query parameters
ParameterTypeRequiredDescription
categorystringNoFilter to a single category by name. URL-encode the value. If omitted, all categories are returned.
Response
200 OK
json
[
    {
        "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" }
                ]
            }
        ]
    }
]
FieldTypeDescription
categoryNamestringSource label (English). Also the value to pass to the category filter.
translationsobjectLocalised category labels keyed by BCP-style locale (nl_NL, en_GB, de_DE, fr_FR, …)
items[].namestringDisplay name of the template
items[].slugstringStable identifier for the template; used in editorUrl and previewUrl
items[].isRealtimebooleantrue if the template auto-refreshes its data on a live screen (e.g. live weather, occupancy)
items[].dynamicDurationbooleantrue 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[].isPrivatebooleanfalse for generic catalogue templates; true for custom templates made for specific customers (the Huisstijl templates category).
items[].imagePathstringFull-resolution preview image URL (JPG)
items[].thumbnailPathstringLow-resolution WebP derivative (525×290) of imagePath, for fast gallery loading. May briefly 404 for a freshly added template; fall back to imagePath.
items[].editorUrlstringIframe URL for opening this template in the editor
items[].previewUrlstringStandalone preview URL (no editing), e.g. for hover-previews
items[].modulesarrayModules 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[].namestringLocalised module display name, in the request locale
items[].modules[].slugstring | nullThe 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[].userIdsarray of UUIDOnly in Huisstijl templates: the main users (customers) this custom template is linked to. All of their subusers have access, except those in excludedSubuserIds.
items[].organisationIdsarray of stringOnly 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[].excludedSubuserIdsarray of UUIDOnly 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.

TokenHuisstijl templates category contains
integrator_adminThe custom templates of all your customers. A template linked to several customers lists all of them in userIds.
subuserOnly 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.

Custom template category
json
{
    "categoryName": "Huisstijl templates",
    "translations": { /* ... */ },
    "items": [
        {
            "name": "Acme lobby screen",
            "slug": "acme_lobby_screen",
            /* ... */
            "isPrivate": true,
            "userIds": ["01HRX..."],
            "organisationIds": ["ORG-12345"],
            "excludedSubuserIds": []
        }
    ]
}
Caching with the integrator_admin token

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

GET/api/v1/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.

Response
200 OK
json
[
    {
        "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)

GET/api/v1/my-templates

Returns every saved template belonging to the calling subuser. Powers the "My Templates" view. Requires a subuser token.

Query parameters (all optional, AND semantics)
ParameterTypeDescription
searchstringCase-insensitive substring match against name
createdAfterinteger (unix epoch seconds)Templates created strictly after this timestamp
createdBeforeinteger (unix epoch seconds)Templates created strictly before this timestamp
modifiedAfterinteger (unix epoch seconds)Templates last modified strictly after this timestamp
modifiedBeforeinteger (unix epoch seconds)Templates last modified strictly before this timestamp
Response
200 OK
json
[
    {
        "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" }
        ]
    }
]
FieldTypeDescription
idUUIDIdentifier of the root template (the original first saved)
namestringDisplay name
orientationstring | nullReserved for future use; always null today
aspectRatiostringOne of 16:9, 9:16, 4:3, 1:1
urlstringRenderable URL for a signage player. See Rendering for the integrator-host and trackingId requirements.
createdAt / modifiedAtISO 8601 stringWhen the root template was first saved / last modified
thumbnailUrlstring | nullPre-rendered thumbnail; null if not yet generated
thumbnailCreatedAtISO 8601 string | nullWhen the thumbnail was generated. Compare with modifiedAt to detect stale thumbnails.
videoUrl, videoCreatedAtalways nullReserved for future use
durationinteger (seconds)How long this template should play in a playlist loop
modulesarrayModules this saved template requires, inherited from its library template. Same shape as modules on /templates.
latestVersionIdUUIDID of the most recently saved version (equals id if never re-saved)
versions[]arrayEvery 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.

GET/api/v1/modules

Authorization: templates:read (subuser token, or your integrator_admin token for platform-level caching).

Response
200 OK
json
{
    "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
        ]
    }
}
FieldTypeDescription
baseUrlstring (sprintf template)Two %s placeholders: first the module link, second the access token
categoriesobjectModules grouped by configuration style. Keys are stable; order within a category may change.
categories[*][].namestringEnglish source label, stable for matching
categories[*][].linkstringURL slug; substitute into baseUrl. Same value as modules[].slug on templates.
categories[*][].translationsobjectLocalised module label keyed by BCP-style locale
Building a module iframe URL
javascript
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.

POST/api/v1/library/external-image

Registers an image record that templates can attach to image elements, slideshows and CSS backgrounds.

Request
json
{
    "fileName": "office-front.jpg",
    "title": "Office building, exterior",
    "source": "<integrator-key>"
}
FieldTypeRequiredDescription
fileNamestringYesYour identifier, which the renderer will ask for at render time. Include the file extension.
titlestringNoHuman-readable label for the picker UI. Defaults to fileName.
sourcestringNoYour integrator key (the default).

Returns 200 OK with { id, title, fileName, source }. Errors: 400 fileName missing, 401 not authenticated, 403 token lacks templates:write.

POST/api/v1/library/external-video

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.

Request
json
{
    "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 filename is 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.
POST/api/v1/company-branding/set

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.

Request
json
{
    "logos": [
        { "filename": "primary-logo.png", "sortOrder": 0 },
        { "filename": "secondary-logo.png", "sortOrder": 1 }
    ],
    "colors": {
        "color1": "#0F4C81",
        "color2": "#F4C430",
        "color6": "#222222"
    }
}
FieldTypeRequiredDescription
logosarrayNoNew logo list. Replaces the existing list in full. Omit to leave logos untouched; send [] to delete all.
logos[].filenamestringYesYour identifier, resolved via postMessage at render time. Entries without a filename are silently skipped. No separate external-image registration needed.
logos[].sortOrderintNoDisplay order. Defaults to 0.
colorsobjectNoSlots to update (merge-on-write). Slots not mentioned are preserved. Omit to leave colours untouched.
colors.colorNstringNoValue for slot N (1 through 20). Free-form string, typically a CSS hex code. Pass null to clear a slot.
Response
200 OK
json
{
    "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.

URL format
Editor URL
url
# 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>
ParamRequiredNotes
tokenYesA subuser access token. Used to authenticate the iframe load and to authorise its outgoing calls (save, external-media registration).
sourceYes, when editingYour 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.
localeNoEditor 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.
Embedding example
html
<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.

Token handling

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

  1. User clicks Save in your CMS chrome; your CMS posts DST_EDITOR_SAVE to the iframe.
  2. Editor replies with DST_SAVE_STARTED: show a spinner.
  3. Editor posts either DST_SAVE_COMPLETE (with the saved templateId and a media summary) or DST_SAVE_ERROR (with an error and, for validation failures, an errors list).
Versioning save under ?source=

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.

DST_SAVE_COMPLETE payload
json
{
    "templateId": "01HRY...",
    "message": "Template saved",
    "media": {
        "images": ["office-front.jpg", "team-photo.png"],
        "videos": ["intro.mp4"]
    }
}

Save errors

HTTPEditor emitsLikely cause
200 with { success: true, … }DST_SAVE_COMPLETESaved cleanly
400 with { success: false, errors: […] }DST_SAVE_ERROR with errorsForm validation failed
401DST_SAVE_ERRORToken expired or invalid - reload the iframe with a new one
403DST_SAVE_ERRORToken lacks templates:write (view-only subuser: "Read-only access: missing templates:write scope"), or the subuser lacks a required user permission
Non-JSON responseDST_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.

URL contract
Module iframe URL
url
https://templates.example.com/embed/modules/{link}?token=<subuser-token>
Example
html
<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

CategoryDescriptionExamples
simple-credentialShort form for an API key or credentials. Validates and shows a "Connected" state.Bundeling, SolarEdge, Humly, MDM
manual-entryCRUD 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
oauth3-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)
No "done" event

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.

ModuleStatusCredential model
LinkedInAvailableCustomer-owned: the customer pastes their app's client id + secret into the module's form
MicrosoftAvailable - six services, set up per servicePartner-owned: you own one app per service and register its credentials via the API
GoogleAvailable - Calendar and Analytics, set up per integrationPartner-owned
TeamLeaderPlanned - its URL returns 404 until it shipsCustomer-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.

Exact match

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).

LinkedIn

  1. In the LinkedIn Developer Portal, create (or reuse) an app for the customer's organisation.
  2. 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.
  3. Register the redirect URI https://<platform-host>/embed/modules/linkedin-module/authorize, including the clientId query parameter that LinkedIn's embed flow carries.
  4. 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)
SharePointMS_SHAREPOINTGraph: offline_access, User.Read, Sites.Read.All (admin consent)
Power BIMS_POWERBIGraph: offline_access; Power BI Service: Workspace.Read.All, Report.Read.All, Dashboard.Read.All, Dataset.Read.All
Microsoft TeamsMS_TEAMSGraph: offline_access, User.Read, User.ReadBasic.All
Outlook calendarMS_MEETING_ROOMGraph: offline_access, User.Read, Calendars.Read.Shared, Place.Read.All (admin consent)
Outlook mailboxMS_MAILBOXGraph: offline_access, User.Read, Mail.ReadBasic (metadata only)
Outlook mailbox (advanced)MS_MAILBOX_ADVANCEDGraph: 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.

Admin consent

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.

Google

Integration{target}Scope
Google CalendarGOOGLE_CALENDARhttps://www.googleapis.com/auth/calendar.readonly
Google AnalyticsGOOGLE_ANALYTICShttps://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.

Verification required before going live

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.

PUT/api/v1/integrator/oauth-apps/{target}

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.

Request
json
{
    "clientId": "55f919ac-1f4e-4d2e-9a77-0c3b1f9a7e21",
    "clientSecret": "your-entra-client-secret",
    "tenantId": "common"
}
201 Created (200 OK when replacing)
json
{
    "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).

GET/api/v1/integrator/oauth-apps

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.

DELETE/api/v1/integrator/oauth-apps/{target}

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.

StatuserrorCause
400clientId and clientSecret are both required.One of the pair was missing or empty
400Request body must be a JSON object.Body was not valid JSON
400Unknown target '…'. Supported: …{target} is not a Microsoft or Google service target
400clientId, clientSecret or tenantId is too long.Field exceeded its limit (255 / 1000 / 255 characters)
403Only an integrator admin may manage OAuth applications.The token belongs to a subuser
403OAuth client does not have the required scopes (missing: oauth-apps:manage)Scope not registered on your client - contact DS Templates
404No application is configured for this target.DELETE for a service you hold no credentials for
409This clientId is already configured for …Client id already registered against a different service
409This 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 difference

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

EventPayloadMeaning
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:

EventPayloadMeaning
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

EventPayloadEffect
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

PostMessage handler
javascript
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

msgWhenExtra payload
preRenderFinishedTemplate finished pre-rendering, ready to play-
DST_TEMPLATE_LAZY_LOADPre-rendering state enteredtemplateId, templateName
DST_TEMPLATE_PLAYTemplate started playingtemplateId, templateName, startTime
DST_TEMPLATE_PAUSETemplate pausedtemplateId, templateName
DST_TEMPLATE_RESUMETemplate resumedtemplateId, templateName
DST_TEMPLATE_STOPTemplate stoppedtemplateId, templateName, stopTime, playDuration
DST_TEMPLATE_DURATIONDynamic-duration template reports its expected runtime during pre-rendertemplateId, templateName, duration (seconds)
setDurationLegacy alias of DST_TEMPLATE_DURATIONduration (seconds)
pauseSlideTimer / resumeSlideTimerA 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.dataEffect
'playlistSlideRender'Enter pre-rendering
'initIntroAnimation'Start the template (triggers intro animations)
'exitIntroAnimation'Stop the template

Minimum playback flow

Playback sequence
text
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

EventPayloadMeaning
DST_RENDERER_MEDIA_URL_REQUEST{ fileNameWithExtension }Iframe needs a real URL for a registered filename

Parent to Iframe

EventPayloadEffect
DST_RENDERER_MEDIA_URL_RESPONSE{ fileNameWithExtension, mediaUrl?, mediaBlob? }Provide URL or Blob. If both are sent, mediaBlob wins.

Flow

Media resolution flow
text
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.

Not required for Option 2

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.

StatusMeaningAction
200OKRequest succeeded
201CreatedNew resource created
204No ContentSuccess, no body (e.g. DELETE)
400Bad RequestFix the request. Show error near the offending field.
401UnauthorizedRefresh token, then retry
403ForbiddenValid token but wrong scope or relationship. Do not retry.
404Not FoundResource does not exist under this caller's scope
409ConflictExistence constraint violated (e.g. duplicate email)
500Server ErrorRetry once after short delay. File a ticket with timestamp.
401 vs 403

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

StatuserrorWhen
401No OAuth client foundToken decoded but no matching OAuth client (revoked, deleted, or never minted)
403OAuth client does not have the required scopesToken valid but the scope this endpoint requires is missing
403Caller is not a provisioned subuserCaller has subusers:manage scope but is not in the integrator_user role chain

Provisioning (POST /users/provision)

StatuserrorWhen
400email is requiredRequest body missing or email field absent
400organisationId must be a non-empty string of at most 64 charactersorganisationId is not a string, empty, or too long
404User not foundPOST /users/{id}/client-secret for an ID that is not an account with credentials under the caller
409organisationId is already linked to another userAnother customer in your tenant already has this organisationId
409This user is already linked to another organisationIdThe existing account for this email has a different organisationId

Subuser management

StatuserrorWhen
400email is requiredRequest body missing or email field absent
400scopes must be a non-empty array of stringsscopes was sent but was not a non-empty array
400Unsupported scope(s): …scopes contained a value other than templates:read / templates:write
403Cannot modify yourselfCaller attempted to PATCH the subuser record that issued the current token
403Cannot delete yourselfCaller attempted to DELETE the subuser record that issued the current token
404Subuser not foundThe targeted subuser does not exist under this caller's integrator_user
409email unavailableAnother subuser under the same integrator_admin already uses this email
409Integrator admin has no OAuth clientData-integrity issue - contact support
409Subuser has no OAuth clientSecret rotation for a subuser without credentials - contact support

For the /api/v1/integrator/oauth-apps errors, see OAuth module setup.

Library and branding

StatuserrorWhen
400fileName requiredPOST /library/external-image or external-video with no fileName
400Invalid JSON bodyPOST /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:

Validation error
json
{
    "success": false,
    "error": "Validation failed",
    "errors": [
        "The first headline must not be empty.",
        "Brand color 1 must be a valid hex code."
    ]
}
StatusResponse bodyWhen
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})

StatusBodyWhen
400API templates must be served from a recognized integrator domain.Host does not match a registered integrator host
400trackingId 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:

API error
json
{ "error": "email is required" }

Editor-iframe save (/embed/template-editor/...) - JSON wrapped in success envelope (see above).

What to retry vs. surface

OutcomeRetry?Show to end user?
401Yes, 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.
404No.Yes if user-driven (stale link); silent if internal.
409No, the conflict is durable.Yes - explain in user terms. For email unavailable consider a "use existing" path.
500Yes 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 caseURLAuth
Render saved template for playbackhttps://{integrator-host}/template/{id}None - but must be from a recognised integrator host + trackingId
Render catalogue preview by slughttps://templates.example.com/template/{slug}/previewNone (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:

UseHost
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

ParamTypeEffect
localelocale stringRender in a specific locale. Short (nl) and BCP-style (nl_NL) codes both work. Unknown locales fall back to en_GB.
timezoneIANA timezonee.g. Europe/Amsterdam; used for server-side time formatting. Falls back to the platform default if missing or invalid.
screenstringOptional screen identifier. Reserved for future use.
aspectRatiostringForce 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.

Putting it together
html
<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

Render one slide
javascript
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_DURATION when it fires, but always keep a fallback timer: static-duration templates never emit it. The dynamicDuration flag on /api/v1/templates tells you up front which kind a template is.
  • Wait for DST_TEMPLATE_STOP before 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

TopicConvention
Field namingAll JSON fields are camelCase. The only exception is the token endpoint (access_token, token_type, expires_in), mandated by RFC 6749.
IdentifiersEvery id, userId, templateId is an opaque string (ULIDs today). Stable, globally unique, safe to log and cache on; do not parse them.
TimestampsISO 8601. No endpoint requires timestamps in request bodies (the my-templates filters take unix epoch seconds).
LocalesShort 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.
IdempotencyPOST /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.
PaginationNone. 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.
EncodingAll partner endpoints are application/json, UTF-8. No binary uploads: media uses the external-media registration model.