App Platform
Build embedded apps for WorkSkedge with JWT authentication, postMessage API, and secure iframe integration.
Introduction to the App Platform
The WorkSkedge App Platform enables developers to build embedded applications that run seamlessly within the WorkSkedge interface. Your apps are loaded in secure iframes with full access to user context, authentication tokens, and real-time communication capabilities.
Secure Authentication
JWT tokens with user context, company data, and role-based permissions.
Real-time Communication
Bidirectional postMessage API for seamless app-to-platform integration.
Built-in Testing
Dev Launcher with token inspection and custom URL testing.
Architecture Overview
Your app runs inside an iframe within WorkSkedge. The platform handles authentication, passes JWT tokens with user context, and provides a postMessage API for real-time communication.
┌─────────────────────────────────────────────────────────┐
│ WorkSkedge Platform │
│ │
│ 1. User opens app → Platform generates JWT token │
│ 2. Token includes: userId, companyId, roles, etc. │
│ 3. Token passed via URL + postMessage │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Your Embedded App (iframe) │ │
│ │ │ │
│ │ 1. Receive & decode JWT token │ │
│ │ 2. Verify token on your server │ │
│ │ 3. Access user data from token claims │ │
│ │ 4. Use postMessage for communication │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ← postMessage: context updates, navigation, etc. │
│ → postMessage: ready, notify, refresh, etc. │
└─────────────────────────────────────────────────────────┘
Integration Comparison
Choose the right integration method for your use case:
| Feature | Embedded Apps | REST API | Webhooks |
|---|---|---|---|
| User Interface | Full UI inside WorkSkedge | External UI | No UI |
| User Context | Automatic via JWT | Manual API calls | Event payloads |
| Authentication | JWT tokens (1hr) | API keys | Webhook signatures |
| Real-time Updates | postMessage API | Polling required | Push notifications |
| Best For | Custom dashboards, forms, visualizations | Integrations, data sync, automation | Event-driven workflows, notifications |
Quick Start Workflow
Follow these steps to build your first embedded app:
Create Your App
Build a web application that accepts JWT tokens via URL parameters and postMessage.
Handle Authentication
Decode the JWT token client-side to access user context, then verify it on your server.
Implement postMessage
Use the postMessage API to communicate with WorkSkedge for token updates and navigation.
Test with Dev Launcher
Use the built-in Dev Launcher to test your app with real tokens before going live.
Deploy & Install
Deploy your app to production and install it for your WorkSkedge company.
Requirements Checklist
Before building your embedded app, ensure you have:
Next Steps
Session Token Verification Guide
When your app is embedded in WorkSkedge, it receives a signed JWT session token containing the authenticated user's identity, roles, and company context. You can verify these tokens using the Session Signing Secret provided in the app.installed webhook.
Token Verification Methods
Recommended: Self-Verification
Verify tokens yourself using the sessionSigningSecret from your installation. This is:
- Faster - No network call required
- More reliable - No external dependencies
- Offline capable - Works without internet
Alternative: API Verification
Call the WorkSkedge API to verify tokens. Use this if you prefer not to implement JWT verification.
Self-Verification (Recommended)
Node.js
const crypto = require('crypto');
function verifySessionToken(token, sessionSigningSecret) {
const parts = token.split('.');
if (parts.length !== 3) {
return { valid: false, error: 'Invalid token format' };
}
const [headerB64, payloadB64, signatureB64] = parts;
// Verify signature
const data = `${headerB64}.${payloadB64}`;
const expectedSignature = crypto
.createHmac('sha256', sessionSigningSecret)
.update(data)
.digest('base64url');
if (signatureB64 !== expectedSignature) {
return { valid: false, error: 'Invalid signature' };
}
// Decode payload
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
// Check expiration
if (payload.exp * 1000 < Date.now()) {
return { valid: false, error: 'Token expired' };
}
return { valid: true, payload };
}
// Usage
app.post('/api/protected', (req, res) => {
const { token, installId } = req.body;
// Get the session signing secret for this installation
const installation = await db.installations.findByInstallId(installId);
const result = verifySessionToken(token, installation.sessionSigningSecret);
if (!result.valid) {
return res.status(401).json({ error: result.error });
}
// Token is valid, user is authenticated
const { employeeId, employeeName, roles, companyId } = result.payload;
// ... handle request
});
Python
import hmac
import hashlib
import base64
import json
import time
def verify_session_token(token: str, session_signing_secret: str) -> dict:
parts = token.split('.')
if len(parts) != 3:
return {'valid': False, 'error': 'Invalid token format'}
header_b64, payload_b64, signature_b64 = parts
# Verify signature
data = f"{header_b64}.{payload_b64}"
expected_sig = base64.urlsafe_b64encode(
hmac.new(
session_signing_secret.encode(),
data.encode(),
hashlib.sha256
).digest()
).rstrip(b'=').decode()
if signature_b64 != expected_sig:
return {'valid': False, 'error': 'Invalid signature'}
# Decode payload
# Add padding if needed
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += '=' * padding
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
# Check expiration
if payload['exp'] < time.time():
return {'valid': False, 'error': 'Token expired'}
return {'valid': True, 'payload': payload}
# Usage
@app.route('/api/protected', methods=['POST'])
def protected_endpoint():
data = request.json
token = data.get('token')
install_id = data.get('installId')
installation = Installation.query.filter_by(install_id=install_id).first()
result = verify_session_token(token, installation.session_signing_secret)
if not result['valid']:
return jsonify({'error': result['error']}), 401
payload = result['payload']
# Token is valid, proceed with request
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
type TokenPayload struct {
Sub string `json:"sub"`
CompanyID string `json:"companyId"`
InstallID string `json:"installId"`
AppID string `json:"appId"`
EmployeeID *string `json:"employeeId"`
EmployeeName string `json:"employeeName"`
Email string `json:"email"`
Roles []string `json:"roles"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}
func VerifySessionToken(token, sessionSigningSecret string) (*TokenPayload, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, errors.New("invalid token format")
}
headerB64, payloadB64, signatureB64 := parts[0], parts[1], parts[2]
// Verify signature
data := headerB64 + "." + payloadB64
h := hmac.New(sha256.New, []byte(sessionSigningSecret))
h.Write([]byte(data))
expectedSig := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
if signatureB64 != expectedSig {
return nil, errors.New("invalid signature")
}
// Decode payload
payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadB64)
if err != nil {
return nil, errors.New("invalid payload encoding")
}
var payload TokenPayload
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return nil, errors.New("invalid payload format")
}
// Check expiration
if payload.Exp < time.Now().Unix() {
return nil, errors.New("token expired")
}
return &payload, nil
}
API Verification (Alternative)
If you prefer not to implement JWT verification yourself, call the WorkSkedge API:
async function verifyTokenViaAPI(token, installId) {
const response = await fetch(
'https://bktpmukmqrxpuiskvvaq.supabase.co/functions/v1/apps-verify-session-token',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, installId })
}
);
return response.json();
}
Response Format
{
"valid": true,
"payload": {
"userId": "uuid",
"companyId": "uuid",
"installId": "uuid",
"appId": "uuid",
"employeeId": "uuid",
"employeeName": "John Doe",
"email": "john@example.com",
"roles": ["administrator", "scheduler"],
"issuedAt": 1234567890,
"expiresAt": 1234571490
}
}
{
"valid": false,
"error": "Token expired"
}
Token Structure
The JWT session token contains the following claims:
{
// Standard JWT Claims
iss: "workskedge", // Issuer
sub: "uuid", // User ID
iat: 1234567890, // Issued at (Unix timestamp)
exp: 1234571490, // Expires at (Unix timestamp, 1 hour from issue)
// WorkSkedge Claims
companyId: "uuid", // Company ID
installId: "uuid", // App installation ID
appId: "uuid", // App catalog ID
employeeId: "uuid | null",// Employee ID (if user is linked to an employee)
employeeName: "string", // Display name
email: "string", // User email
roles: ["string"], // Array of user roles
nonce: "uuid" // Unique identifier to prevent replay attacks
}
Receiving Tokens in Embedded Apps
Initial Load (URL Parameter)
When your app loads in an iframe, extract the token from URL parameters:
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
const installId = urlParams.get('installId');
if (token) {
// Verify and use the token
const result = await verifyToken(token, installId);
if (result.valid) {
console.log('User:', result.payload.employeeName);
console.log('Roles:', result.payload.roles);
}
}
Token Refresh (postMessage)
Listen for token refresh messages from WorkSkedge:
window.addEventListener('message', (event) => {
// Validate origin
if (!event.origin.includes('workskedge')) return;
const message = event.data;
if (message.type === 'workskedge:context') {
const { token, tokenExpiry } = message.payload;
// Update your stored token
updateStoredToken(token);
console.log('Token refreshed, expires:', new Date(tokenExpiry));
}
});
Requesting Fresh Context
Your app can request updated context at any time:
window.parent.postMessage({
type: 'workskedge:request_context',
requestId: crypto.randomUUID()
}, '*');
Available Roles
Users can have one or more of these roles:
| Role | Description |
|---|---|
administrator |
Full company access, settings, user management |
manager |
Employee management, all projects, reports |
scheduler |
Schedule management, work orders, assignments |
project_owner |
Own projects, assigned employees, reports |
site_lead |
Own team, daily reports, schedule visibility |
site_worker |
Own schedule, timesheets, basic access |
Role-Based Access Control Example
function canEditWorkOrders(roles) {
return roles.some(r =>
['administrator', 'manager', 'scheduler', 'project_owner'].includes(r)
);
}
function canViewAllEmployees(roles) {
return roles.some(r =>
['administrator', 'manager', 'scheduler'].includes(r)
);
}
Security Best Practices
Always Verify Tokens
Always verify tokens for any sensitive operation, never trust client-side data alone.
Check Token Expiration
Check token expiration before using cached tokens. Tokens expire after 1 hour.
Validate the installId
Ensure the installId in the token matches your expected installation.
Use the Nonce
Use the nonce claim for additional replay attack prevention in critical operations.
Never Trust Client-Side Data
Always verify tokens on your server before performing sensitive operations.
Store Session Signing Secret Securely
Never expose the Session Signing Secret to clients. Store it server-side only.
Testing During Development
Using the Dev Launcher
On the Private Apps page, each app has a Launch dropdown:
| Option | Description |
|---|---|
| Launch Dev Mode | Opens your development URL with a valid JWT token |
| Launch Production | Opens your production URL with a valid JWT token |
| View Token Info | Shows the decoded JWT payload for debugging |
Development Workflow
- Create a Private App with
Supports Embeddingenabled - Set your Development URL (e.g.,
http://localhost:3000) - Use "Launch Dev Mode" to test with real authentication
- Inspect tokens with "View Token Info" to verify correct data
Troubleshooting
Token is null on load
- Ensure your app waits for the WorkSkedge embed to generate the token
- Check browser console for authentication errors
Token expired immediately
- Server time may be out of sync
- Check that
expclaim is being read as Unix timestamp (seconds, not milliseconds)
Verification fails with "Invalid signature"
- Ensure you're using the correct
sessionSigningSecretfor this installation - The app may have been uninstalled and reinstalled (new secret generated)
- Check that you're using the correct base64url encoding
postMessage not received
- Check that you're listening for messages on the correct origin
- Verify the iframe has proper sandbox permissions
Related Documentation
App Installation Guide
When a company installs your app, WorkSkedge notifies you via webhooks with everything you need to operate: API keys for making API calls, session signing secrets for verifying user tokens, and installation details. This guide explains the complete installation lifecycle.
app.installed webhook contains your API Key, Session Signing Secret, and company details in a single payload.
Quick Start
When a company installs your app, you receive everything needed in a single app.installed webhook:
| What You Get | Where It Comes From | What It's For |
|---|---|---|
| API Key | credentials.apiKey |
Authenticate API calls to WorkSkedge |
| Session Signing Secret | credentials.sessionSigningSecret |
Self-verify user JWT tokens in embedded apps |
| App Secret | Shown once at app creation | Verify HMAC-SHA256 signatures on ALL webhooks |
Webhook Flow
Here's how the installation process works:
┌─────────────────────────────────────────────────────────────────────┐
│ APP INSTALLATION FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. User clicks "Install App" in WorkSkedge │
│ ↓ │
│ 2. WorkSkedge creates: │
│ - company_app_installs record │
│ - API Key for this installation │
│ - Session Signing Secret for JWT verification │
│ ↓ │
│ 3. WorkSkedge sends POST to {developer_website}/webhooks/app │
│ with app.installed event payload (includes credentials) │
│ ↓ │
│ 4. Your app receives and processes the webhook: │
│ - Verify signature using App Secret │
│ - Store API Key for making API calls │
│ - Store Session Signing Secret for verifying user tokens │
│ ↓ │
│ 5. Your app returns HTTP 200 OK │
│ │
└─────────────────────────────────────────────────────────────────────┘
Webhook Events
WorkSkedge sends these lifecycle events to your webhook endpoint:
| Event | Description |
|---|---|
app.installed |
Sent when a company installs your app (includes credentials) |
app.uninstalled |
Sent when a company uninstalls your app |
app.updated |
Sent when app settings are modified |
app.installed Payload
This is the most important webhook you'll receive. It contains everything needed to operate your app:
{
"event": "app.installed",
"timestamp": "2024-01-15T10:30:00.000Z",
"data": {
"installId": "550e8400-e29b-41d4-a716-446655440000",
"appId": "660e8400-e29b-41d4-a716-446655440001",
"appName": "Time Tracker Pro",
"company": {
"id": "770e8400-e29b-41d4-a716-446655440002",
"name": "Acme Construction",
"industry": "construction",
"employeeCount": 50,
"timezone": "America/New_York",
"modulesEnabled": {
"core": true,
"timesheets": true,
"purchaseOrders": true
}
},
"credentials": {
"apiKey": "ws_key_abc123...",
"sessionSigningSecret": "ws_sess_def456..."
},
"grantedScopes": [
"employees:read",
"timesheets:read",
"timesheets:write"
],
"subscribedEvents": [
"timesheet.created",
"timesheet.submitted",
"employee.created"
],
"allowedRoles": ["administrator", "manager"],
"installedBy": {
"userId": "880e8400-e29b-41d4-a716-446655440003",
"email": "admin@acme.com",
"name": "John Admin"
},
"installedAt": "2024-01-15T10:30:00.000Z"
}
}
app.uninstalled Payload
{
"event": "app.uninstalled",
"timestamp": "2024-02-15T14:00:00.000Z",
"data": {
"installId": "550e8400-e29b-41d4-a716-446655440000",
"appId": "660e8400-e29b-41d4-a716-446655440001",
"appName": "Time Tracker Pro",
"company": {
"id": "770e8400-e29b-41d4-a716-446655440002",
"name": "Acme Construction"
}
}
}
Webhook Headers
WorkSkedge includes these headers with all webhooks:
| Header | Description |
|---|---|
X-WorkSkedge-Signature |
HMAC-SHA256 signature for verification |
X-WorkSkedge-Event |
Event type (e.g., app.installed) |
X-WorkSkedge-Delivery-Id |
Unique delivery attempt ID |
X-WorkSkedge-Install-Id |
Installation ID |
Signature Verification
Verify webhook authenticity using your App Secret (provided when you created your app):
Node.js Example
const crypto = require('crypto');
function verifyWebhook(req, appSecret) {
const signature = req.headers['x-workskedge-signature'];
const rawBody = req.rawBody; // Must be raw string, not parsed JSON
const expectedSignature = crypto
.createHmac('sha256', appSecret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Express.js example
app.post('/webhooks/app', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(req, process.env.APP_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
switch (event.event) {
case 'app.installed':
handleInstallation(event.data);
break;
case 'app.uninstalled':
handleUninstallation(event.data);
break;
case 'app.updated':
handleUpdate(event.data);
break;
}
res.status(200).send('OK');
});
async function handleInstallation(data) {
// Store credentials for this installation
await db.installations.create({
installId: data.installId,
companyId: data.company.id,
companyName: data.company.name,
apiKey: data.credentials.apiKey,
sessionSigningSecret: data.credentials.sessionSigningSecret,
});
console.log(`App installed by ${data.company.name}`);
}
Python Example
import hmac
import hashlib
from flask import Flask, request
app = Flask(__name__)
def verify_webhook(request, app_secret):
signature = request.headers.get('X-WorkSkedge-Signature')
raw_body = request.data
expected = hmac.new(
app_secret.encode(),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@app.route('/webhooks/app', methods=['POST'])
def handle_app_webhook():
if not verify_webhook(request, os.environ['APP_SECRET']):
return 'Invalid signature', 401
event = request.json
if event['event'] == 'app.installed':
handle_installation(event['data'])
elif event['event'] == 'app.uninstalled':
handle_uninstallation(event['data'])
return 'OK', 200
def handle_installation(data):
# Store credentials for this installation
Installation.create(
install_id=data['installId'],
company_id=data['company']['id'],
company_name=data['company']['name'],
api_key=data['credentials']['apiKey'],
session_signing_secret=data['credentials']['sessionSigningSecret']
)
Using the Credentials
Making API Calls
Use the apiKey from the installation to authenticate API calls:
const response = await fetch('https://bktpmukmqrxpuiskvvaq.supabase.co/functions/v1/api-employees', {
headers: {
'X-API-Key': installation.apiKey,
'Content-Type': 'application/json'
}
});
Verifying Session Tokens
Use the sessionSigningSecret to verify JWT tokens in embedded apps. See the Authentication tab for details.
Business Webhooks
In addition to lifecycle webhooks (app.installed, app.uninstalled, app.updated), your app can subscribe to business event webhooks. All webhooks use the same App Secret for signature verification.
Available Business Events
| Event | Description |
|---|---|
work_order.created |
A work order was created |
work_order.updated |
A work order was modified |
work_order.status_changed |
A work order status changed |
timesheet.submitted |
A timesheet was submitted for approval |
timesheet.locked |
A timesheet was locked |
timesheet.processed |
A timesheet was marked as processed |
employee.created |
An employee was added |
employee.updated |
An employee was modified |
Business Webhook Payload Example
{
"event": "work_order.created",
"timestamp": "2024-01-15T10:30:00.000Z",
"data": {
"workOrderId": "wo-123",
"workOrderNumber": "WO-2024-001",
"projectId": "proj-456",
"projectName": "Office Renovation",
"status": "draft",
"createdBy": "user-789"
}
}
Verifying Business Webhooks
Use the same App Secret and verification logic for all webhooks:
// Same verification for ALL webhooks (lifecycle and business)
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWebhook(req, process.env.APP_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Handle both lifecycle and business events
switch (event.event) {
case 'app.installed':
handleInstallation(event.data);
break;
case 'work_order.created':
handleWorkOrderCreated(event.data);
break;
case 'timesheet.submitted':
handleTimesheetSubmitted(event.data);
break;
// ... other events
}
res.status(200).send('OK');
});
Best Practices
Always Verify Signatures
Never trust unverified webhook payloads. Always verify the HMAC signature.
Store Credentials Securely
API Key and Session Signing Secret are sensitive. Store them encrypted in your database.
Handle Idempotently
Webhooks may be retried; use deliveryId for deduplication.
Return 200 Quickly
Process webhooks asynchronously to avoid timeouts. Return 200 immediately.
Log Delivery IDs
Store delivery IDs for debugging and support inquiries.
Use Raw Request Body
Verify signatures against the raw body, not parsed JSON.
Troubleshooting
Webhook Not Received
- Verify
developer_websiteis configured correctly in your app settings - Ensure your
/webhooks/appendpoint is publicly accessible - Check for firewall rules blocking WorkSkedge IPs
- Review webhook delivery logs in WorkSkedge admin
Invalid Signature
- Ensure you're using the raw request body (not parsed JSON)
- Verify you're using the correct App Secret (provided when you created the app)
- Check for whitespace or encoding issues in the payload
- If you regenerated your secret, make sure your app is using the new one
Missing Credentials
credentialsis only included inapp.installedevents- If you missed the initial webhook, you may need to reinstall the app
Related Documentation
App Credentials Reference
This is the single source of truth for all credentials used in the WorkSkedge App Platform. Understanding when, where, and how to use each credential type is essential for building secure integrations.
Overview
The WorkSkedge App Platform uses three types of credentials:
| Credential | Scope | When Received | Purpose |
|---|---|---|---|
| App Secret | Per App | At app creation | Verify webhook signatures |
| API Key | Per Installation | app.installed webhook |
Authenticate API calls |
| Session Signing Secret | Per Installation | app.installed webhook |
Verify user JWT tokens |
1. App Secret
What It Is
A cryptographic secret used to verify the authenticity of webhooks sent by WorkSkedge.
When You Get It
- Shown once when you create your app in WorkSkedge
- Copy and store it immediately - it cannot be retrieved later
- Can be regenerated from app settings (invalidates old secret)
What It's For
- Verify HMAC-SHA256 signatures on all webhooks from WorkSkedge
- Includes lifecycle webhooks (
app.installed,app.uninstalled,app.updated) - Includes business webhooks (
work_order.created,timesheet.submitted, etc.)
How to Use It
const crypto = require('crypto');
function verifyWebhook(rawBody, signature, appSecret) {
const expected = crypto
.createHmac('sha256', appSecret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// In your webhook handler
app.post('/webhooks', (req, res) => {
const signature = req.headers['x-workskedge-signature'];
if (!verifyWebhook(req.rawBody, signature, process.env.APP_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Process webhook...
});
Security Notes
- Store in environment variables, never in code
- Same secret for all companies that install your app
- Rotate periodically for enhanced security
2. API Key
What It Is
A unique key that authenticates your app when making API calls to WorkSkedge on behalf of a specific company.
When You Get It
- Received in the
app.installedwebhook payload - Located at:
data.credentials.apiKey - Format:
ws_key_...
What It's For
- Authenticate REST API calls to WorkSkedge
- Access company data within the granted scopes
- Each installation gets its own unique API key
How to Use It
// Store the API key when you receive the app.installed webhook
async function handleInstallation(data) {
await db.installations.create({
installId: data.installId,
companyId: data.company.id,
apiKey: data.credentials.apiKey,
// ... other fields
});
}
// Use the API key for subsequent API calls
async function getEmployees(installId) {
const installation = await db.installations.findByInstallId(installId);
const response = await fetch(
'https://bktpmukmqrxpuiskvvaq.supabase.co/functions/v1/api-employees',
{
headers: {
'X-API-Key': installation.apiKey,
'Content-Type': 'application/json'
}
}
);
return response.json();
}
Security Notes
- Store per-installation (each company gets a unique key)
- Never expose to client-side code
- Scoped to the permissions granted during installation
3. Session Signing Secret
What It Is
A cryptographic secret used to verify JWT session tokens for users accessing your embedded app.
When You Get It
- Received in the
app.installedwebhook payload - Located at:
data.credentials.sessionSigningSecret - Format:
ws_sess_...
What It's For
- Self-verify JWT tokens passed to your embedded app
- Authenticate users without making API calls
- Faster and more reliable than API-based verification
How to Use It
const crypto = require('crypto');
function verifySessionToken(token, sessionSigningSecret) {
const [headerB64, payloadB64, signatureB64] = token.split('.');
// Verify signature
const data = `${headerB64}.${payloadB64}`;
const expected = crypto
.createHmac('sha256', sessionSigningSecret)
.update(data)
.digest('base64url');
if (signatureB64 !== expected) {
return { valid: false, error: 'Invalid signature' };
}
// Decode and check expiration
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
if (payload.exp * 1000 < Date.now()) {
return { valid: false, error: 'Token expired' };
}
return { valid: true, payload };
}
// In your embedded app's backend
app.post('/api/verify-user', (req, res) => {
const { token, installId } = req.body;
const installation = await db.installations.findByInstallId(installId);
const result = verifySessionToken(token, installation.sessionSigningSecret);
if (!result.valid) {
return res.status(401).json({ error: result.error });
}
// User is authenticated
res.json({ user: result.payload });
});
Security Notes
- Store per-installation (each company gets a unique secret)
- Never expose to client-side code
- Tokens expire after 1 hour; WorkSkedge auto-refreshes them
Credential Lifecycle
On App Creation
┌─────────────────────────────────────┐
│ APP CREATION │
├─────────────────────────────────────┤
│ │
│ 1. Developer creates app │
│ ↓ │
│ 2. WorkSkedge generates App Secret │
│ ↓ │
│ 3. App Secret shown ONCE │
│ ↓ │
│ 4. Developer stores App Secret │
│ │
└─────────────────────────────────────┘
On App Installation
┌─────────────────────────────────────┐
│ APP INSTALLATION │
├─────────────────────────────────────┤
│ │
│ 1. Company clicks "Install" │
│ ↓ │
│ 2. WorkSkedge generates: │
│ - API Key (per installation) │
│ - Session Signing Secret │
│ ↓ │
│ 3. app.installed webhook sent │
│ - Signed with App Secret │
│ - Contains credentials object │
│ ↓ │
│ 4. Developer stores credentials │
│ │
└─────────────────────────────────────┘
Quick Reference Table
| Question | App Secret | API Key | Session Signing Secret |
|---|---|---|---|
| When do I get it? | App creation | app.installed webhook |
app.installed webhook |
| Where in payload? | N/A (shown in UI) | credentials.apiKey |
credentials.sessionSigningSecret |
| One per...? | App | Installation | Installation |
| Can I regenerate? | Yes (in settings) | No (reinstall app) | No (reinstall app) |
| Use for what? | Verify webhooks | Authenticate API calls | Verify user tokens |
| In which header? | Verify X-WorkSkedge-Signature |
Send X-API-Key |
N/A (JWT signature) |
Troubleshooting
"Invalid signature" on webhooks
- Ensure you're using the App Secret (not API Key or Session Signing Secret)
- Use the raw request body, not parsed JSON
- Check if you regenerated the secret and need to update your app
"Unauthorized" on API calls
- Ensure you're using the API Key in the
X-API-Keyheader - Verify the key is for the correct installation
- Check that the app is still installed and active
"Invalid token" on session verification
- Ensure you're using the Session Signing Secret for that installation
- Check token expiration (tokens last 1 hour)
- Verify base64url encoding in your verification code
Credential Rotation
Rotating the App Secret
When you regenerate your App Secret in app settings:
- Immediate Effect: Old secret becomes invalid immediately
- Impact: All webhook signature verification will fail until you update your app
- No Impact On: API Keys and Session Signing Secrets remain valid
Graceful Rotation Procedure
// Support both old and new secrets during transition
function verifyWebhookWithRotation(rawBody, signature, secrets) {
for (const secret of secrets) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return true;
}
}
return false;
}
// Use: verifyWebhookWithRotation(body, sig, [NEW_SECRET, OLD_SECRET])
Recommended Steps
- Deploy code that accepts both old and new secrets
- Regenerate the App Secret in WorkSkedge
- Update your environment with the new secret
- Remove old secret from your code after confirming it works
Rotating API Keys and Session Signing Secrets
These credentials cannot be regenerated - they are tied to the installation. To get new credentials:
- Have the company uninstall your app
- Have them reinstall it
- You'll receive new credentials in the
app.installedwebhook