Skip to main content

Recipe — Approval Flow with Two Roles

This recipe shows how to build a flow where a requester submits a request and an approver reviews and decides.

The Process

[Fill Request] ──→ (✉ Request under review) ──→ [Review Request] ──→ <approved?>
userTask intermediateThrowEvent userTask exclusiveGateway
candidateGroups: candidateGroups:
[requester] [approver]
│ │
│ ┌── Yes ────┤
│ │ └── No ──→ [end: rejected]
│ ▼
│ (✉ Request approved) ──→ [end: approved]

Backend: Create Sessions

Requester session:

const { session_token } = await fetch('https://api.stateway.io/v1/sessions', {
method: 'POST',
headers: { 'X-API-Key': process.env.STATEWAY_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: user.id,
user_display: user.name,
ttl: 3600,
scope: {
definitions: [{
key: 'purchase-request',
roles: ['requester'],
candidate_as_user: false,
can_start: true,
max_concurrent: 5
}]
}
})
}).then(r => r.json());

Approver session:

const { session_token } = await fetch('https://api.stateway.io/v1/sessions', {
method: 'POST',
headers: { 'X-API-Key': process.env.STATEWAY_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: user.id,
user_display: user.name,
ttl: 3600,
scope: {
definitions: [{
key: 'purchase-request',
roles: ['approver'],
candidate_as_user: false,
can_start: false,
max_concurrent: null
}]
}
})
}).then(r => r.json());

Frontend: Requester

import { StatewayClient } from '@stateway/frontend-sdk';

const sw = new StatewayClient({ endpoint: 'wss://ws.stateway.io', sessionToken });

sw.on('work.empty', ({ definitions }) => {
const [def] = definitions.filter(d => d.canStart);
if (def) {
showButton('New Request', () =>
sw.startProcess(def.key, { amount, justification })
);
}
});

sw.on('task.completed', () => {
showMessage('Request sent for review. Please wait...');
});

sw.on('notification', ({ message, variables }) => {
showResult(message, variables);
});

Frontend: Approver

import { StatewayClient } from '@stateway/frontend-sdk';

const sw = new StatewayClient({ endpoint: 'wss://ws.stateway.io', sessionToken });

sw.on('work.snapshot', (items) => {
renderApprovalQueue(items);
});

sw.on('task.assigned', (task) => {
appendToQueue(task);
});

sw.on('task.claimed_by_other', ({ taskId }) => {
removeFromQueue(taskId);
});

async function handleDecision(taskId: string, approved: boolean, comment: string) {
try {
await sw.claimTask(taskId);
await sw.completeTask(taskId, { approved, comment });
} catch (err) {
if (err.code === 'CLAIM_CONFLICT') showToast('Another approver got there first');
}
}

Key Points

  • The requester has can_start: true — they can initiate new instances
  • The approver has can_start: false — they only process tasks in their inbox
  • Both roles use candidateGroups, so all users with that role see the pool tasks
  • claimTask is called before completeTask because these are pool tasks
  • The notification event delivers the final result (approved/rejected) to the requester