Start with the Embed Widget page — you need a publishable key and its signing secret before anything here applies.
When you need this
Anything in data-metadata comes from the page, so a visitor can change it in dev tools before the interview starts. That is fine for context you only want to read next to the transcript.
It is not fine when the interview acts on the value — asking enterprise-only questions, skipping a section for trial users, or splitting results by plan. For those, your server mints a short-lived JSON Web Token (JWT) signed with the key's signing secret, and the page passes it as data-signed-token instead of data-publishable-key. Deutero verifies the signature, so it knows the values came from your backend and not from the browser. Verified values are stored with provenance token (shown as verified in the dashboard) and override any page-supplied value of the same name.
How the flow fits together
- Keep the signing secret (
dtro_sk_…) in your server's secret manager. It must never reach the browser — anyone holding it can forge metadata for your studies. - When you render the page hosting the widget, mint a token for the currently signed-in end user. Mint one per page load; do not cache it in a cookie or reuse it across users.
- Render the widget script tag with
data-signed-token="…"in place ofdata-publishable-key. Everything else (data-survey-id,data-mode) stays the same. The token'skidheader tells Deutero which key — and therefore which organization, study scope and allowed origins — to check it against. - If your page loads the widget after render (a single-page app, a modal opened on click), fetch the token from your own endpoint first and set the attribute before you append the script element.
What the token must contain
| Field | Where | Value |
|---|---|---|
alg |
header | HS256 only. Other algorithms are rejected. |
typ |
header | JWT |
kid |
header | Your publishable key (dtro_pk_…). This is how Deutero finds the matching signing secret — a token without it cannot be verified. |
sub |
claim | Your own stable identifier for the end user (not their email, unless that is your id). It links repeat interviews by the same person. |
iat |
claim | Issued-at, Unix seconds. Up to 60s of clock skew is tolerated, so a slightly fast server clock is fine. |
exp |
claim | Expiry, Unix seconds. At most 900s (15 minutes) after iat; 5–10 minutes is a good default. The token only has to survive until the visitor starts the interview. |
metadata |
claim | A flat object of the values you want treated as verified. |
Metadata limits: at most 32 keys; names matching ^[A-Za-z_][A-Za-z0-9_]{0,63}$; scalar values only (string ≤ 1 KB, number, bool, null) — no nested objects or arrays; total serialized size within the key's metadata size limit. A bootstrap that exceeds any of these is rejected outright, so validate before you sign.
Minting the token on your server
Python
pip install pyjwt
import os
import time
import jwt # PyJWT
PUBLISHABLE_KEY = os.environ["DEUTERO_EMBED_PUBLISHABLE_KEY"] # dtro_pk_...
SIGNING_SECRET = os.environ["DEUTERO_EMBED_SIGNING_SECRET"] # dtro_sk_...
def mint_embed_token(user) -> str:
now = int(time.time())
return jwt.encode(
{
"sub": str(user.id), # your stable end-user id
"iat": now,
"exp": now + 600, # 10 minutes; hard cap is 900
"metadata": {
"plan": user.plan, # string, <= 1 KB
"seats": user.seat_count, # number
"is_trial": user.is_trial, # bool
},
},
SIGNING_SECRET,
algorithm="HS256",
headers={"kid": PUBLISHABLE_KEY},
)
# Flask: hand the token to a page that loads the widget client-side.
@app.get("/embed/interview-token")
@login_required
def embed_interview_token():
return {"token": mint_embed_token(current_user)}
TypeScript
npm install jose
import { SignJWT } from "jose";
const publishableKey = process.env.DEUTERO_EMBED_PUBLISHABLE_KEY!; // dtro_pk_...
const signingSecret = new TextEncoder().encode(
process.env.DEUTERO_EMBED_SIGNING_SECRET!, // dtro_sk_...
);
export async function mintEmbedToken(user: AppUser): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return new SignJWT({
metadata: {
plan: user.plan, // string, <= 1 KB
seats: user.seatCount, // number
is_trial: user.isTrial, // bool
},
})
.setProtectedHeader({ alg: "HS256", typ: "JWT", kid: publishableKey })
.setSubject(user.id) // your stable end-user id
.setIssuedAt(now)
.setExpirationTime(now + 600) // 10 minutes; hard cap is 900
.sign(signingSecret);
}
// Express: hand the token to a page that loads the widget client-side.
app.get("/embed/interview-token", requireLogin, async (req, res) => {
res.json({ token: await mintEmbedToken(req.user) });
});
Putting the token on the page
Render the token straight into the script tag from your server-side template:
<!-- your server mints <signed-token> (JWS, HS256) with the key's signing secret -->
<script async src="https://app.deutero.ai/widget/v1/deutero-widget.js"
data-signed-token="<signed-token>"
data-survey-id="<survey uuid>"
data-mode="chat"></script>
Or, if the widget is loaded later by client-side code, fetch the token from your endpoint first:
const { token } = await fetch("/embed/interview-token", {
credentials: "include",
}).then((r) => r.json());
const s = document.createElement("script");
s.async = true;
s.src = "<widget src above>";
s.dataset.signedToken = token; // instead of dataset.publishableKey
s.dataset.surveyId = "<study id>";
s.dataset.mode = "chat";
document.body.appendChild(s);
A tag may carry data-metadata as well, for values that do not need to be verified. Where a name appears in both, the signed value wins.
If a bootstrap is rejected
- Expired or too long-lived token —
expmust be within 15 minutes ofiat, and the token must still be in date when the visitor loads the page. - Bad signature or missing
kid— check you signed with the secret belonging to the publishable key in thekidheader. The secret is shown only once, at creation; if you lost it, create a new key and revoke the old one. - Wrong origin — the page's origin must exactly match one of the key's allowed origins (scheme, host and port).
- Wrong study — a study-scoped key rejects a bootstrap naming any other study.
- Revoked key — revoking takes effect on the next bootstrap; interviews already collected keep their recorded provenance.
Last updated: September 2026