API Examples
Use this page to copy the right request shape for each extension flow.
- Iframe bridge examples use
postMessageactions such assession.get,state.get, andfiles.get. - Privileged examples use your own extension backend with
Authorization: Bearer YOUR_APP_SCOPED_DEVELOPER_KEYandx-chastify-main-token. - Never send a Developer API key to iframe or browser code.
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.
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/keyholderLastSeenTimestampare returned only if that user hasshowOnlineStatus !== false.- If online status visibility is disabled, these fields are
null.
Backend Extension Flows
These examples show the safe production pattern:
- The iframe reads
mainTokenandsessionIdfrom the hash payload. - The iframe sends them to your backend.
- Your backend validates your own game/task/business state.
- 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.
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.
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:
showPageOverlaydefaults tofalse.targetdefaults towearer.- 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
dataobject. - 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.getto 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:
durationSecondsis optional.- If omitted, the current default is
3600seconds (1 hour). - Accepted range is
60to86400seconds.
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:
namemust bepillory.params.durationSecondsis required.params.reasonis 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:
namemust bepillory.end.- Fails with
pillory_not_activeif 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:
taskTextis required.pointsis optional and clamped server-side.verificationRequireddefaults tofalse.durationSecondsis optional (0means 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.
durationSecondsis 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
unlockBlockersandhomeActions. - Supports
homeActions[].intentfor 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:
homeActionSlughomeAction(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+intentfrom 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.startwith params:{ durationSeconds, intensityPct, message? }shock.stopvibration.startwith params:{ durationSeconds, intensityPct, frequencyPct?, message? }vibration.stopall.stopshock.random.setwith params:{ enabled, minIntensityPct?, maxIntensityPct?, message? }(Lockink AA-A1012 only)shock.berserk.setwith params:{ enabled, message? }(Lockink AA-A1012 only)
Recommended flow:
- Call
session.geton load. - Read device capabilities from
deviceControl.supportedCommands. - Render only controls for supported commands.
- Send
device.commandwith validated values. - Refresh or trust response
activeflags for live UI state.
Parameter guidance:
durationSeconds: server clamps to safe limits (current policy max is 300s).intensityPct: expected percentage value (1-100style 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.getfirst and read supported commands. - Only show controls for commands that are supported in current session.
- Validate user inputs before sending command params.
device.commandrequires 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 ascompletion,win, orverification.requiredCount: how many trusted events must happen in the window.cadence.every: interval size.cadence.unit:dayorweek.- 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, orpillory.punishment.seconds: punishment duration foradd_time,freeze, orpillory.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.
Recommended requirement flow
- Configure
extensionRequirementsin the extension setup/config UI. - On runtime startup, call
session.getand read the active config. - Complete the extension activity in the UI.
- Send the completion to a trusted backend route for that extension.
- Let the backend validate the event and record requirement progress.
- Refresh local UI with
session.get/state.getafter 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
metricnames 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.
Recommended Action Order
- Call
session.geton startup. - Read state with
state.get. - Perform state writes from your backend with
PUT/PATCH /statewhen needed. - Run lock/device actions only when supported and visible in UI.
- For requirement-backed activities, report completion to a trusted backend route.
- Refresh local view after important writes/actions.