Skip to main content

API Examples

Use this page to copy the right request shape for each extension flow.

  • Iframe bridge examples use postMessage actions such as session.get, state.get, and files.get.
  • Privileged examples use your own extension backend with Authorization: Bearer YOUR_APP_SCOPED_DEVELOPER_KEY and x-chastify-main-token.
  • Never send a Developer API key to iframe or browser code.
info

The iframe bridge is for UI bootstrap and low-risk session operations. Use it for reading context, storing extension-owned state, and resolving file URLs.

caution

Browser iframe code is user-controlled. Do not use iframe bridge requests to apply/remove time, complete tasks, clear unlock blockers, upload/delete runtime files, send notifications, write logs, or command devices.

Request and Response Format

Safe iframe bridge actions are sent inside this bridge request envelope:

{
"type": "chastify:ext:req", // required
"v": 1, // protocol version
"id": "req-123", // your unique request id
"nonce": "from-iframe-hash",
"action": "session.get",
"payload": {}
}

And you receive:

{
"type": "chastify:ext:resp",
"v": 1,
"id": "req-123", // same id you sent
"ok": true,
"data": {}
}

If ok is false, check error.code and error.message.

Backend-only examples use normal HTTPS requests from your server. They are not sent through the iframe bridge.

Session and Context

session.get

Use this first in almost every extension flow.

What it does:

  • Verifies your bridge connection is working.
  • Returns session context (lock state, role, extension config, capabilities).
  • Returns device capability information your UI can use before asking your backend to call device.command.

What it is used for:

  • Bootstrapping your UI.
  • Enabling/disabling features based on role and permissions.
  • Checking supported device commands before rendering device controls.

Example action payload:

{
"action": "session.get",
"payload": {}
}

Example session.get response excerpt (runtime lock data):

{
"ok": true,
"data": {
"lockData": {
"frozen": false,
"unlockable": false,
"trusted": true,
"taskAssigned": true,
"timeLockedSeconds": 1420,
"timeRemainingSeconds": 27800,
"maxTimeRemainingSeconds": 86400,
"taskPoints": 12,
"taskPointsRequired": 20,
"lockTitle": "Weekend Challenge",
"wearerUsername": "alice",
"keyholderUsername": "kh_bob",
"wearerLastSeenTimestamp": 1739640505123,
"keyholderLastSeenTimestamp": null
}
}
}

Privacy notes:

  • wearerLastSeenTimestamp / keyholderLastSeenTimestamp are returned only if that user has showOnlineStatus !== false.
  • If online status visibility is disabled, these fields are null.

Backend Extension Flows

These examples show the safe production pattern:

  1. The iframe reads mainToken and sessionId from the hash payload.
  2. The iframe sends them to your backend.
  3. Your backend validates your own game/task/business state.
  4. Your backend calls Chastify with an app-scoped Developer API key plus x-chastify-main-token.

Do not use sessionId alone as authentication. Treat mainToken as browser-visible launch context, and treat your Developer API key as a backend-only secret.

warning

Your backend must validate its own game/task state before calling Chastify. Passing mainToken and sessionId from the iframe only identifies the opened extension session; it does not prove the user completed a challenge.

Fetch Session Context Safely

Your iframe can use the safe bridge session.get for UI bootstrap. If your backend needs the same context before applying a privileged action, fetch it from your backend with both credentials.

Iframe:

const hash = JSON.parse(decodeURIComponent(window.location.hash.slice(1)));

await fetch("/api/my-extension/session-context", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
mainToken: hash.mainToken,
sessionId: hash.sessionId
})
});

Your backend:

app.post("/api/my-extension/session-context", async (req, res) => {
const { mainToken, sessionId } = req.body;

const response = await fetch(
`https://chastify.net/api/extensions/sessions/${encodeURIComponent(sessionId)}`,
{
headers: {
Authorization: `Bearer ${process.env.CHASTIFY_APP_DEVELOPER_KEY}`,
"x-chastify-main-token": mainToken
}
}
);

if (!response.ok) {
return res.status(response.status).json(await response.json());
}

const context = await response.json();

res.json({
role: context.role,
config: context.extensionConfig,
lockData: context.lockData
});
});

Apply Time From An Extension Backend

The browser may ask your backend to apply a reward or punishment, but the browser must not decide whether the action is valid. Verify the action server-side first.

app.post("/api/my-extension/apply-reward", async (req, res) => {
const { mainToken, sessionId, runId } = req.body;

const run = await db.gameRuns.findUnique({ where: { id: runId } });
if (!run || run.sessionId !== sessionId || !run.serverVerifiedWin) {
return res.status(403).json({ error: "game_not_verified" });
}

const response = await fetch(
`https://chastify.net/api/extensions/sessions/${encodeURIComponent(sessionId)}/action`,
{
method: "POST",
headers: {
"content-type": "application/json",
Authorization: `Bearer ${process.env.CHASTIFY_APP_DEVELOPER_KEY}`,
"x-chastify-main-token": mainToken
},
body: JSON.stringify({
name: "remove_time",
params: 300
})
}
);

if (!response.ok) {
return res.status(response.status).json(await response.json());
}

res.json(await response.json());
});

Use add_time for punishments and remove_time for rewards.

Server-Verified Game Completion

For games such as Simon Says, do not trust a client-reported win. Create the run server-side, store the expected sequence or a hash of it, and verify the submitted input before calling Chastify.

A browser-visible memory game cannot prove a human played honestly, because the browser must receive the sequence to render the game. Server verification still prevents forged Chastify mutations and lets your backend enforce run ids, expiry, difficulty, cadence, scoring, and replay protection before applying rewards or punishments.

app.post("/api/simon/runs", async (req, res) => {
const { mainToken, sessionId } = req.body;
await verifySessionLaunch({ mainToken, sessionId });

const sequence = createSimonSequence();

const run = await db.simonRuns.create({
data: {
sessionId,
sequenceHash: hashSequence(sequence),
expiresAt: new Date(Date.now() + 5 * 60_000)
}
});

res.json({
runId: run.id,
sequence
});
});

app.post("/api/simon/runs/:runId/complete", async (req, res) => {
const { mainToken, sessionId, input } = req.body;
await verifySessionLaunch({ mainToken, sessionId });

const run = await db.simonRuns.findUnique({ where: { id: req.params.runId } });

if (!run || run.sessionId !== sessionId || run.expiresAt < new Date()) {
return res.status(403).json({ error: "run_invalid" });
}

const won = hashSequence(input) === run.sequenceHash;
await db.simonRuns.update({
where: { id: run.id },
data: { completedAt: new Date(), serverVerifiedWin: won }
});

if (won) {
await fetch(`https://chastify.net/api/extensions/sessions/${encodeURIComponent(sessionId)}/requirements/progress`, {
method: "POST",
headers: {
"content-type": "application/json",
Authorization: `Bearer ${process.env.CHASTIFY_APP_DEVELOPER_KEY}`,
"x-chastify-main-token": mainToken
},
body: JSON.stringify({
key: "simon_says_wins",
amount: 1
})
});
}

res.json({ won });
});

The exact run storage schema is yours. verifySessionLaunch should call Chastify with your app-scoped Developer API key and x-chastify-main-token before trusting sessionId. The important rule is that Chastify mutations happen only after your backend verifies the launch and result.

Scheduled Requirements

Your backend owns schedules, cadence checks, and proof validation. Use Chastify only to record trusted progress or update unlock blockers after your backend decides the requirement is satisfied.

async function recordDailyRequirementProgress({ sessionId, mainToken, userId }) {
const completed = await db.dailyCheckins.exists({
where: {
userId,
day: new Date().toISOString().slice(0, 10),
verified: true
}
});

if (!completed) return;

await fetch(`https://chastify.net/api/extensions/sessions/${encodeURIComponent(sessionId)}/requirements/progress`, {
method: "POST",
headers: {
"content-type": "application/json",
Authorization: `Bearer ${process.env.CHASTIFY_APP_DEVELOPER_KEY}`,
"x-chastify-main-token": mainToken
},
body: JSON.stringify({
key: "daily_checkin",
amount: 1
})
});
}

Current installed-extension session APIs require a valid iframe launch token in x-chastify-main-token; launch tokens currently expire after 10 hours. For scheduled jobs that run after that token expires, store your own pending proof and submit progress on the next valid extension launch, or use a first-party/built-in server flow designed for unattended background work.

caution

Do not treat scheduled jobs as trusted just because they run on your server. The job still needs server-side proof, cadence checks, replay protection, and a valid Chastify authorization path before recording requirement progress.

Notifications

notifications.custom

Use this from your extension backend to send a custom extension notification to wearer, keyholder, or both.

Endpoint:

POST /api/extensions/sessions/:sessionId/notifications/custom
Authorization: Bearer YOUR_APP_SCOPED_DEVELOPER_KEY
x-chastify-main-token: MAIN_TOKEN_FROM_IFRAME_HASH

Example body:

{
"title": "Extension Reminder",
"message": "Your next challenge is ready.",
"showPageOverlay": false,
"target": "both"
}

Notes:

  • showPageOverlay defaults to false.
  • target defaults to wearer.
  • API creates notification type extension_app_message.

Extension State

Extension state is your extension-owned JSON data for the current lock session. State writes are backend-only and require an app-scoped Developer API key plus the session mainToken. Iframes can read state with state.get, but they cannot write state directly.

state.put

What it does:

  • Replaces the entire state object with the new data object.
  • Requires backend credentials.

When to use:

  • Initial save.
  • Full overwrite when you already have the complete new state.

Example:

curl -X PUT "https://chastify.net/api/extensions/sessions/$SESSION_ID/state" \
-H "Authorization: Bearer $DEVELOPER_KEY" \
-H "x-chastify-main-token: $MAIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": {
"counter": 1,
"notes": "first test"
}
}'

Field notes:

  • payload.data: any valid JSON value/object your extension needs.
  • Avoid Mongo-unsafe key names ($ prefix or keys containing .).
  • State is session-scoped and size-limited by the extension app's stateMaxBytes, defaulting to 64 KiB.
  • Store file ids in state, not binary files or signed URLs. Use files.get to refresh signed URLs before rendering media.

state.patch

What it does:

  • Applies a JSON merge patch to existing state.
  • Only changed keys need to be sent.
  • Requires backend credentials.

When to use:

  • Incremental updates from user interactions.
  • Updating one field without resending everything.

Example:

curl -X PATCH "https://chastify.net/api/extensions/sessions/$SESSION_ID/state" \
-H "Authorization: Bearer $DEVELOPER_KEY" \
-H "x-chastify-main-token: $MAIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": {
"counter": 2
}
}'

state.get

What it does:

  • Reads the current extension state.
  • Available through the iframe bridge.

When to use:

  • On iframe load.
  • After writes, if you want to re-sync local UI.

Example:

{
"action": "state.get",
"payload": {}
}

File Storage

Use files.* for binary media such as puzzle images, generated previews, or challenge photos. Store the returned file.id in backend-written state or your own database. Render with file.signedUrl, and refresh that signed URL with files.get when the iframe loads later.

Setup screens that run before a lock/session exists use staged uploads instead of files.upload. Staged files are temporary until the extension config is saved with a provider: "chastify_storage" and fileId reference. Chastify claims those files automatically on lock/template save; there is no separate browser-side claim call.

State/file split example from your backend:

curl -X PATCH "https://chastify.net/api/extensions/sessions/$SESSION_ID/state" \
-H "Authorization: Bearer $DEVELOPER_KEY" \
-H "x-chastify-main-token: $MAIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": {
"puzzleImageFileId": "file_record_id"
}
}'

Later, resolve the image before display:

const state = await bridge.request("state.get", {});
const file = await bridge.request("files.get", {
fileId: state.data.puzzleImageFileId
});
image.src = file.file.signedUrl;

files.capabilities

Check this before showing upload controls.

{
"action": "files.capabilities",
"payload": {}
}

Runtime files.upload

Runtime file upload mutates extension session data, so it is not an iframe bridge command. Upload runtime files from your backend with an app-scoped Developer API key and x-chastify-main-token.

files.get

Refresh one signed R2 URL from a stable file id.

{
"action": "files.get",
"payload": {
"fileId": "file_record_id"
}
}

files.list

{
"action": "files.list",
"payload": {}
}

Runtime files.delete is also backend-only for the same reason as upload.

Lock Actions

Add time

Endpoint: POST /api/extensions/sessions/:sessionId/action

What it does:

  • Adds or removes time from the lock countdown depending on deltaSeconds.

When to use:

  • Reward/penalty buttons.
  • Game outcomes (win adds time, lose removes time).

Example:

{
"name": "add_time",
"params": 300 // +300 sec = +5 minutes
}

Field notes:

  • Positive value adds time.
  • Negative value removes time (if permitted by server rules).

Freeze

Endpoint: POST /api/extensions/sessions/:sessionId/action

What it does:

  • Freezes lock progression for a duration.

When to use:

  • Cooldown mechanics.
  • Reward checkpoints.

Example:

{
"name": "freeze",
"params": { "durationSeconds": 120 }
}

You can also call it without durationSeconds:

{
"name": "freeze",
"params": {}
}

Field notes:

  • durationSeconds is optional.
  • If omitted, the current default is 3600 seconds (1 hour).
  • Accepted range is 60 to 86400 seconds.

Unfreeze

Endpoint: POST /api/extensions/sessions/:sessionId/action

What it does:

  • Ends active freeze and resumes normal timer behavior.

When to use:

  • Manual override in extension workflows.
  • “Cancel freeze” controls.

Example:

{
"name": "unfreeze",
"params": {}
}

Pillory

Endpoint: POST /api/extensions/sessions/:sessionId/action

What it does:

  • Starts a pillory period for the active lock session.

When to use:

  • Penalty mechanics after failed tasks/challenges.
  • Escalation flows that temporarily restrict lock interaction.

Example:

{
"name": "pillory",
"params": {
"durationSeconds": 600,
"reason": "Missed scheduled check-in"
}
}

Field notes:

  • name must be pillory.
  • params.durationSeconds is required.
  • params.reason is optional.
  • Requires pillory to be enabled in the session configuration.

End pillory

Endpoint: POST /api/extensions/sessions/:sessionId/action

What it does:

  • Ends the current active pillory session immediately.

Example:

{
"name": "pillory.end",
"params": {}
}

Field notes:

  • name must be pillory.end.
  • Fails with pillory_not_active if the lock is not currently in pillory.

Assign task

Endpoint: POST /api/extensions/sessions/:sessionId/action

What it does:

  • Creates an active task run for the wearer from extension logic.
  • Can override an already-open task run.

Example:

{
"name": "task.assign",
"params": {
"taskText": "Clean your room",
"points": 10,
"verificationRequired": true,
"durationSeconds": 1800
}
}

Field notes:

  • taskText is required.
  • points is optional and clamped server-side.
  • verificationRequired defaults to false.
  • durationSeconds is optional (0 means no timer requirement).
  • Requires Tasks module enabled on the lock.

Start task timer

Endpoint: POST /api/extensions/sessions/:sessionId/action

What it does:

  • Starts or restarts the countdown window for the currently active timed task.

Example:

{
"name": "task.start_timer",
"params": {}
}

Field notes:

  • Requires an active task run.
  • Fails if the current task has no duration configured.

Complete task

Endpoint: POST /api/extensions/sessions/:sessionId/action

What it does:

  • Marks the active task run as completed or failed.

Example (success):

{
"name": "task.complete",
"params": {
"successful": true
}
}

Example (fail):

{
"name": "task.complete",
"params": {
"successful": false,
"reason": "Did not finish in time"
}
}

Trigger temporary opening

Endpoint: POST /api/extensions/sessions/:sessionId/action

What it does:

  • Starts a temporary hygiene opening window from extension logic.

Example:

{
"name": "hygienic_unlock.start",
"params": {
"durationSeconds": 900
}
}

Field notes:

  • Requires Hygienic Opening enabled on the lock.
  • Fails if a hygiene opening is already in progress.
  • durationSeconds is optional; lock default is used when omitted.

Metadata and Home Actions

metadata.patch

What it does:

  • Stores extension metadata used by lock-page UI.
  • Supports unlockBlockers and homeActions.
  • Supports homeActions[].intent for deep-link behavior when extension opens.

When to use:

  • Enforce lock-session unlock conditions owned by your extension.
  • Add quick actions on lock page that open your extension with intent.
  • Route users directly to a specific screen/workflow when they click a home action.

Endpoint:

PATCH /api/extensions/sessions/:sessionId/metadata
Authorization: Bearer YOUR_APP_SCOPED_DEVELOPER_KEY
x-chastify-main-token: MAIN_TOKEN_FROM_IFRAME_HASH

Example body:

{
"unlockBlockers": ["Finish extension task"],
"homeActions": [
{
"slug": "tasks",
"title": "Tasks",
"description": "Open tasks panel",
"intent": {
"type": "open_panel",
"title": "Tasks",
"message": "Open tasks panel",
"payload": {
"panel": "regular-actions"
}
}
}
]
}

Field notes:

  • unlockBlockers: list of active lock-session unlock blockers from your extension.
  • You can include multiple blockers at once (up to 25), one per unmet condition.
  • Unlock remains blocked while any blocker exists across enabled extensions.
  • Chastify aggregates blockers from all extensions for the lock session.
  • Your extension should only add/remove its own blockers in its own metadata.
  • Do not clear blockers from other extensions; clear your array only when your own conditions are satisfied.
  • homeActions: quick action buttons displayed in lock experience.
  • homeActions[].slug: stable id for your action.
  • homeActions[].title: user-facing label.
  • homeActions[].description: optional helper text.
  • homeActions[].intent: optional deep-link instruction passed to your extension when opened.
  • In the extension card UI, these actions are shown as a menu/list by action title (internally keyed by slug).
  • When a user clicks one, Chastify opens the extension and passes:
    • homeActionSlug
    • homeAction (selected action object)
    • intent (normalized intent object) so your extension can immediately route to the correct view/action on load.

Intents: developer app example

Use this pattern in your extension app to react to menu-click intents on load.

import { useEffect, useRef } from "react";
import { parseHashPayload, type IframeHashPayload } from "../lib/ChastifyBridge";

export function useHomeActionIntent(
payload: IframeHashPayload,
routeToPanel: (panel: string) => void,
showToast: (message: string) => void,
) {
const handledRef = useRef<string>("");

useEffect(() => {
const homeActionSlug = payload?.homeActionSlug ?? null;
if (!homeActionSlug) return;

// Prevent duplicate handling if component re-renders.
const key = `${payload.lockId || "lock"}:${homeActionSlug}`;
if (handledRef.current === key) return;
handledRef.current = key;

const intent = payload?.intent ?? payload?.homeAction?.intent ?? null;
if (!intent) return;

if (intent.type === "open_panel") {
const panel = String(intent.payload?.panel || "");
if (panel) routeToPanel(panel);
return;
}

if (intent.message) {
showToast(String(intent.message));
}
}, [payload, routeToPanel, showToast]);
}

// Example app bootstrap
const payload = parseHashPayload();
if (!payload) throw new Error("Missing iframe hash payload");

What this example does:

  • Reads homeActionSlug + intent from iframe hash payload.
  • Handles each click only once per lock/action slug.
  • Routes to a panel when intent is open_panel.
  • Falls back to showing an intent message for custom intent types.

Device Command

device.command

What it does:

  • Sends a device control command (if supported for that session/device).
  • Lets your extension trigger standardized shock/vibration actions through Chastify.

When to use:

  • Triggering supported shock/vibration commands from extension logic.
  • Building interactive extension features (games, punishments, rewards, routines).

Endpoint:

POST /api/extensions/sessions/:sessionId/device-command
Authorization: Bearer YOUR_APP_SCOPED_DEVELOPER_KEY
x-chastify-main-token: MAIN_TOKEN_FROM_IFRAME_HASH

Example body:

{
"command": "shock.start",
"params": {
"durationSeconds": 30,
"intensityPct": 50
}
}

Common commands:

  • shock.start with params: { durationSeconds, intensityPct, message? }
  • shock.stop
  • vibration.start with params: { durationSeconds, intensityPct, frequencyPct?, message? }
  • vibration.stop
  • all.stop
  • shock.random.set with params: { enabled, minIntensityPct?, maxIntensityPct?, message? } (Lockink AA-A1012 only)
  • shock.berserk.set with params: { enabled, message? } (Lockink AA-A1012 only)

Recommended flow:

  1. Call session.get on load.
  2. Read device capabilities from deviceControl.supportedCommands.
  3. Render only controls for supported commands.
  4. Send device.command with validated values.
  5. Refresh or trust response active flags for live UI state.

Parameter guidance:

  • durationSeconds: server clamps to safe limits (current policy max is 300s).
  • intensityPct: expected percentage value (1-100 style input, clamped server-side).
  • frequencyPct: expected percentage value (1-100) for vibration flows (clamped server-side).
  • For random mode, ensure minIntensityPct <= maxIntensityPct.

Safer UI pattern example:

// 1) Get capabilities first
const session = await bridge.request("session.get", {});
const supported = new Set(session?.deviceControl?.supportedCommands ?? []);

// 2) Only call command if supported
if (supported.has("shock.start")) {
await bridge.request("device.command", {
command: "shock.start",
params: { durationSeconds: 30, intensityPct: 50 }
});
}

Important:

  • Call session.get first and read supported commands.
  • Only show controls for commands that are supported in current session.
  • Validate user inputs before sending command params.
  • device.command requires write permission (locks:write) for the extension session.
  • Handle bridge/server errors gracefully (insufficient_scope, unsupported command, validation errors).

Extension Requirements

Extension requirements let an extension define recurring completion rules that show in the lock's Today Progress UI and can apply a penalty when the wearer misses a window.

Use this for server-trusted extension activities such as:

  • Complete 1 puzzle per day.
  • Complete 3 check-ins every 2 days.
  • Complete 5 extension actions per week.

Configuration shape

The requirement is stored in the extension session config under extensionRequirements:

{
"extensionRequirements": {
"enabled": true,
"metric": "completion",
"requiredCount": 1,
"cadence": {
"every": 1,
"unit": "day"
},
"punishment": {
"type": "add_time",
"seconds": 900,
"reason": "Missed extension requirement"
}
}
}

Fields:

  • enabled: turns the recurring requirement on/off.
  • metric: stable counter name. Use simple names such as completion, win, or verification.
  • requiredCount: how many trusted events must happen in the window.
  • cadence.every: interval size.
  • cadence.unit: day or week.
  • Window timezone is resolved by Chastify from the wearer's configured User.timezone. Extension config should not hardcode a timezone.
  • punishment.type: none, add_time, freeze, or pillory.
  • punishment.seconds: punishment duration for add_time, freeze, or pillory.
  • punishment.reason: optional audit/debug reason.

Progress model

Requirement progress is trusted server-side state, not iframe-local state.

Do not use state.patch / state.put to mark a requirement as complete. Those actions are backend-only general state writes, not requirement progress APIs. Requirement progress must be recorded only after the server validates the event that should count.

For example:

  • A puzzle extension should record progress only after the server validates the signed puzzle run and completed state.
  • A verification extension should record progress only after the server accepts the submitted proof.
  • A game extension should record progress only after the backend validates the game result or trusted completion event.

Runtime behavior

When configured:

  • The lock dashboard can show the requirement in Today Progress.
  • Chastify tracks progress per extension session and per cadence window.
  • The scheduled requirement job evaluates completed windows and applies the configured punishment once per missed window.
  • Punishments are idempotent per window, so retries do not stack duplicate penalties.
  1. Configure extensionRequirements in the extension setup/config UI.
  2. On runtime startup, call session.get and read the active config.
  3. Complete the extension activity in the UI.
  4. Send the completion to a trusted backend route for that extension.
  5. Let the backend validate the event and record requirement progress.
  6. Refresh local UI with session.get / state.get after completion.

Important:

  • Treat state.* as extension-owned storage only. Use dedicated trusted APIs for progress, attempts, rewards, and punishments.
  • Do not trust client-only completion flags for requirements.
  • Keep metric names stable; changing the metric starts counting into a different bucket.
  • Chastify uses the wearer's configured timezone for cadence windows. If no timezone is available on the user, the server falls back to UTC.
  • Keep punishments bounded and explainable in audit logs.
  1. Call session.get on startup.
  2. Read state with state.get.
  3. Perform state writes from your backend with PUT/PATCH /state when needed.
  4. Run lock/device actions only when supported and visible in UI.
  5. For requirement-backed activities, report completion to a trusted backend route.
  6. Refresh local view after important writes/actions.