Actions
Your own code at points of the approval pipeline: alerts, step-ups and refusals by country, local time, role or amount, passkeys for admins. Start from a no-code template or a few lines of JavaScript (whatever your server language), replay it against your real recent decisions, then deploy, watch and roll back.
| Trigger | When | Decisions |
|---|---|---|
login.request | Before the push is sent (POST /login/request), after the risk engine and your rules | deny, stepUp, requirePasskey · skipNumberMatch*, overrideBlock* |
risk.check | Silent sign-in decision (POST /risk/check) | prompt · allowSilent* |
agent.approval | Before an AI agent's request reaches the phone (POST /agents/approvals): event.agent, event.request.authorizationDetails, amounts, destinations | deny, stepUp, requirePasskey (only tightening) |
approval.decided | Approved or rejected (phone, passkey, offline QR, recovery code) | — |
shield.decision | Vishing Shield changed an approval made during a call; re-confirmed or taken back | — |
fraud.reported | Your server reported a request as fraud | — |
device.linked · device.keys_lost · user.unlinked | Phone linked; its keys were destroyed; 2FA turned off from the phone | — |
passkey.added · passkey.removed · recovery.updated | Passkeys; Never Locked Out recovery steps | — |
identity.pre_user_registration | Touchque Login: before a customer signs up (address and name checked, nothing created yet) | deny |
identity.post_login | Touchque Login: after a customer's first factor, before the second step and the app's code | deny, requireMfa · idToken.setCustomClaim, accessToken.setCustomClaim, user.setAppMetadata, user.setUserMetadata |
identity.post_user_registration | Touchque Login: a customer signed up | — |
Every Action can also notify (api.notify.slack | teams | webhook | email), call its allowed hosts (api.fetch) and freeze the user (api.freezeUser). * needs a capability.
pending_approval and the previous version keeps running.| Capability | What it allows |
|---|---|
skip_number_match | Drop the number the workspace itself asked for: the always-challenge setting or an access rule step-up. Never the risk engine's asks, critical actions, push fatigue, a flagged phone or an engine outage. |
override_block | Turn a risk-engine refusal into a number-matched push (never a plain one; never a country block, an access-rule refusal or a frozen account). |
allow_silent | Let a known, device-bound browser approved on the phone linked now skip the phone when only a new network or country, or an older approval (at most 30 days, never past your maxAgeSeconds), made it ask. Never for critical actions, a Vishing Shield signal, a workspace gate, an engine outage or a verdict other than ALLOW. |
api.fetch to the version's allowedHosts. 50 ms CPU, 16 MB, 300 ms for all Actions of a pre trigger. Failures never block a sign-in unless onFailure is step_up or deny. Secrets are encrypted, write-only and reach only versions that declare them.| Endpoint | Does |
|---|---|
POST /actions/test | One run on a recorded event, your own event or a sample: decisions, notifications (not sent), console output, and what the decision would have been. |
POST /actions/dry-run | Replays a draft against the last (up to 1000) real decisions: how many would change and how (push→number_match, push→deny…), notifications it would send, errors, p95. |
PUT /actions/{slug} | Deploy (create or new version, idempotent). POST …/rollback is one call. |
GET /actions/{slug}/logs · /metrics | Every live run, runs / errors / p95 / decisions per window. |
An Action's reason is part of the decision's explanation: { code: "action", text, action } in risk.check reasons and step-ups, and a refusal answers 403 { code: "blocked", reason: "action", action, error }. Pass what only your server knows as context on the login request or the guard (never shown on the phone).
| id | Trigger | Does |
|---|---|---|
alert | any | Slack, Teams, webhook or e-mail (Resend, Postmark, SendGrid) when the event matches |
block_or_step_up | login.request | deny, stepUp or requirePasskey by country, local time, role, amount, action or risk |
require_passkey_for_role | login.request | Only a passkey approves for these roles (context.roles) |
always_ask_when | risk.check | Never silent when the sign-in matches |
when: countries, notCountries, hours { from, to, timeZone }, roles, actions, criticalOnly, riskAbove, amount { path, over }, status. Server SDKs 5.2.0 (action types moved to tq.actionTypes in 4.0).
// A template: Slack for approvals from Turkey at night
await tq.actions.secrets.set('SLACK_WEBHOOK_URL', ({}).SLACK_WEBHOOK_URL);
await tq.actions.deploy('night-alert', {
trigger: 'approval.decided',
template: { id: 'alert', params: { channel: 'slack',
when: { countries: ['TR'], hours: { from: 0, to: 6, timeZone: 'Europe/Istanbul' }, status: 'approved' } } },
});
// Your own code, typed; replay it against the last 500 real decisions first
const handler = async (event, api) => {
if (event.context?.roles?.includes('admin')) api.requirePasskey('Admins approve with a passkey');
};
const report = await tq.actions.dryRun({ trigger: 'login.request', handler });
// report.transitions → { 'push→passkey': 12 }
await tq.actions.deploy('admins-use-passkeys', { trigger: 'login.request', handler });
// Watch, roll back
await tq.actions.logs('admins-use-passkeys');
await tq.actions.rollback('admins-use-passkeys');tq.actions.deploy('night-alert', trigger='approval.decided', template={
'id': 'alert', 'params': {'channel': 'slack', 'when': {'countries': ['TR'], 'hours': {'from': 0, 'to': 6}}}})
draft = 'exports.onExecute = async (event, api) => { if (event.context?.amount > 10000) api.stepUp("Over 10,000"); };'
report = tq.actions.dry_run(trigger='login.request', code=draft) # report['transitions']
tq.actions.deploy('big-transfers', trigger='login.request', code=draft)$tq->actions->deploy('night-alert', ['trigger' => 'approval.decided', 'template' => [
'id' => 'alert', 'params' => ['channel' => 'slack', 'when' => ['countries' => ['TR'], 'hours' => ['from' => 0, 'to' => 6]]]]]);
$draft = 'exports.onExecute = async (event, api) => { if (event.context?.amount > 10000) api.stepUp("Over 10,000"); };';
$report = $tq->actions->dryRun(['trigger' => 'login.request', 'code' => $draft]);
$tq->actions->deploy('big-transfers', ['trigger' => 'login.request', 'code' => $draft]);draft := `exports.onExecute = async (event, api) => { if (event.context?.amount > 10000) api.stepUp("Over 10,000"); };`
report, _ := client.Actions.DryRun(touchque.DryRunActionOptions{
ActionDraft: touchque.ActionDraft{Trigger: "login.request", Code: draft}})
_ = report.Transitions // map["push→number_match"]3
client.Actions.Deploy("big-transfers", touchque.DeployActionOptions{Trigger: "login.request", Code: draft})await tq.Actions.DeployAsync("night-alert", new DeployActionOptions
{
Trigger = "approval.decided",
Template = ActionTemplateRef.Of("alert", new { channel = "slack", when = new { countries = new[] { "TR" }, hours = new { from = 0, to = 6 } } }),
});
var draft = "exports.onExecute = async (event, api) => { if (event.context?.amount > 10000) api.stepUp('Over 10,000'); };";
var report = await tq.Actions.DryRunAsync(new DryRunActionOptions { Trigger = "login.request", Code = draft }); // report.Transitions
await tq.Actions.DeployAsync("big-transfers", new DeployActionOptions { Trigger = "login.request", Code = draft });tq.actions().deploy("night-alert", new Actions.DeployOptions().trigger("approval.decided")
.template(Actions.TemplateRef.of("alert", Map.of("channel", "slack",
"when", Map.of("countries", List.of("TR"), "hours", Map.of("from", 0, "to", 6))))));
String draft = "exports.onExecute = async (event, api) => { if (event.context?.amount > 10000) api.stepUp('Over 10,000'); };";
JsonNode report = tq.actions().dryRun(new Actions.Draft().trigger("login.request").code(draft)); // report.get("transitions")
tq.actions().deploy("big-transfers", new Actions.DeployOptions().trigger("login.request").code(draft));tq.actions.deploy("night-alert", trigger: "approval.decided",
template: { id: "alert", params: { channel: "slack", when: { countries: ["TR"], hours: { from: 0, to: 6 } } } })
draft = 'exports.onExecute = async (event, api) => { if (event.context?.amount > 10000) api.stepUp("Over 10,000"); };'
report = tq.actions.dry_run(trigger: "login.request", code: draft) # report["transitions"]
tq.actions.deploy("big-transfers", trigger: "login.request", code: draft)# Signed request (HMAC), see Authentication
PUT /actions/tr-step-up
{ "trigger": "login.request",
"template": { "id": "block_or_step_up", "params": { "effect": "stepUp", "when": { "countries": ["TR"] } } } }
POST /actions/dry-run
{ "trigger": "login.request", "code": "exports.onExecute = (e, api) => api.requirePasskey('all');", "limit": 500 }// login.request
exports.onExecute = async (event, api) => {
const t = api.time.local(event.client.timeZone || 'Europe/Istanbul');
if (event.client.country === 'TR' && t.hour < 6) {
api.stepUp('Night sign-in from Turkey');
api.notify.slack(`${event.user.id} is signing in from ${event.client.location}`);
}
};{
"trigger": "login.request",
"evaluated": 500,
"matched": 41,
"changed": 38,
"transitions": { "push→number_match": 35, "number_match→deny": 3 },
"notifications": { "slack": 41 },
"errors": 0,
"timeouts": 0,
"p50Ms": 2,
"p95Ms": 6
}