The api object is the only sanctioned channel through which a custom action can affect the authentication flow. Reading uses event; writing uses api. Six methods cover everything an action can do.
If your action throws an uncaught error, the runtime treats it as an implicit api.deny. The flow halts, and the user sees the tenant's denial message. Design your actions to fail loudly rather than continue silently past an error.
api.deny
Section titled “api.deny”api.deny(reason: string): voidTerminates the current flow immediately. No subsequent blocks run. The reason string is written to the audit log so you can trace why a login was rejected — it is not shown to the end-user. The user sees the tenant's configured denial message instead.
After calling api.deny, add a return statement so the rest of your action code does not execute.
When to reach for it: blocking signups from disposable-email domains, rejecting logins from flagged IP ranges, enforcing a corporate domain allowlist, halting high-risk flows before the session is issued.
const email = event.user?.email ?? '';
if (email.endsWith('@disposable.example')) { api.deny('disposable-email-blocked'); return;}api.state.set
Section titled “api.state.set”api.state.set(key: string, value: unknown): voidWrites a key-value pair into the flow's step-state store under the current action's slug. Downstream blocks — Decision expressions, webhook templates, other custom actions that run later — read it at step.<your-action-slug>.<key>.
Values must be JSON-serialisable. Functions, Date objects, BigInt, and circular references throw at call time. Store dates as ISO 8601 strings: new Date().toISOString().
State written here is scoped to this single flow run. It does not persist across runs — use api.user.setAppMetadata when you need state that survives to future logins.
When to reach for it: passing a derived value (tier, risk classification, feature flag) to a downstream Decision block or webhook without round-tripping to an external service.
// Cymmetri tags enterprise accounts by email domain.const domain = event.user?.email?.split('@')[1] ?? '';const tier = domain === 'cymmetri.io' ? 'enterprise' : 'self-serve';
api.state.set('tier', tier);
// A downstream Decision block reads:// step.tag-account-tier.tier == "enterprise"// and routes to the corporate login path.api.log
Section titled “api.log”api.log(level: 'debug' | 'info' | 'warn' | 'error', message: string): voidAppends a structured log entry to the action's execution trace. Log entries are visible in the Test pane and are written to the tenant audit stream under the event type custom_action.test_run. Messages are silently truncated at 4096 bytes.
console.log, console.debug, console.info, console.warn, and console.error are all aliases. They call api.log at the corresponding level. Use whichever style feels natural — the output is identical.
console.log → api.log('info', ...)console.debug → api.log('debug', ...)console.info → api.log('info', ...)console.warn → api.log('warn', ...)console.error → api.log('error', ...)Multi-argument console.* calls are joined with a space.
api.log('info', `Risk score: ${event.authentication.risk_score}`);
// Or via console alias — same result:console.warn(`Unrecognised country: ${event.request.geo.country}`);Do not log full event objects or individual fields that contain personally identifiable information (email, phone, IP). Log derived outcomes — scores, tier names, boolean flags — not the raw inputs that drove them. Anyone with access to your tenant's audit log can read what your action logs.
api.user.setAppMetadata
Section titled “api.user.setAppMetadata”api.user.setAppMetadata(patch: Record<string, unknown>): voidShallow-merges patch into the user's app_metadata field. The change is durable — the merged object is persisted to the user's identity record and is available in every subsequent flow run as event.user.app_metadata.
Shallow merge means the top-level keys in patch overwrite their counterparts in the existing app_metadata. Keys in app_metadata that are not in patch are left untouched. If any value is itself an object, the whole nested object is replaced — not recursively merged. To preserve existing sub-keys inside a nested object, read the current value first and spread it:
const existing = event.user?.app_metadata?.address ?? {};api.user.setAppMetadata({ address: { ...existing, country: event.request.geo.country },});app_metadata is admin-controlled. Users cannot read or modify it. Contrast this with user_metadata, which users can write from their profile.
When to reach for it: storing risk decisions, consent timestamps, account tier, signup country, or any per-user property that your application or future flow actions need to read.
// Cymmetri writes tier and signup country at registration.api.user.setAppMetadata({ tier: 'enterprise', signup_country: event.request.geo.country, flagged_at: new Date().toISOString(),});api.session.setCustomClaim
Section titled “api.session.setCustomClaim”api.session.setCustomClaim(name: string, value: unknown): voidInjects a custom claim into the access token JWT issued at the end of this flow. The claim appears in the token payload under name. Values must be JSON-serialisable.
The claim is visible to the application that receives the token when it decodes the JWT. It is not shown in the admin console — inspect a decoded token to verify the claim landed. The end-user does not see custom claims.
api.session.setCustomClaim has an effect only when it is called from an action attached to a Login flow or an Issue Tokens stage. Calling it earlier in the flow — in Pre-Login or Registration — writes to the claim buffer but the buffer is discarded before the token is issued. Place actions that set claims in Post-Auth or Post-Login.
When to reach for it: embedding a risk tier, department, region, or feature flag so your application can branch without a back-channel lookup. The downstream application reads the claim from the JWT — no extra API call needed.
// Cymmetri embeds the account tier in every access token.const tier = event.user?.app_metadata?.tier ?? 'free';
api.session.setCustomClaim('tier', tier);api.session.setCustomClaim('geo_country', event.request.geo.country);api.fetch
Section titled “api.fetch”api.fetch( url: string, init?: RequestInit,): Promise<{ status: number, headers: Record<string, string>, body: string,}>Makes an outbound HTTP request from inside the action. The response body is always a string — parse JSON explicitly with JSON.parse(res.body) when the upstream returns JSON.
Only hostnames on your tenant's fetch allowlist are reachable. A request to a hostname that is not on the allowlist is rejected immediately with error_kind: "http_fetch_denied" — no network packet leaves the runtime. Configure the allowlist in your tenant's Custom Actions settings.
The default timeout is 5 seconds. Actions that exceed the timeout are treated as a deny — the flow halts. Design outbound calls to fail fast: set appropriate Content-Type and Accept headers, and handle non-2xx responses explicitly.
// Cymmetri checks a fraud-scoring API before issuing the session.const res = await api.fetch('https://fraud-api.cymmetri.io/v1/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: event.user?.email, ip: event.request.ip, visitor: event.request.visitor_id, }),});
const payload = JSON.parse(res.body);
if (res.status !== 200 || payload.risk_score > 80) { api.deny('fraud-api-rejected'); return;}
api.state.set('fraud_score', payload.risk_score);Do not send real user data to public inspection services like webhook.site or beeceptor. Anyone with the inspector URL can read every request your action posts. When developing against a public test endpoint, use a tenant test user with a fake email and no real personal data.
Combining methods — common patterns
Section titled “Combining methods — common patterns”Tag then claim
Section titled “Tag then claim”Write a derived property to app_metadata so it persists across sessions, then embed it as a claim so this session's application can read it immediately without a separate lookup.
const tier = event.user?.app_metadata?.tier ?? 'free';
// Persist any tier change from this run.api.user.setAppMetadata({ tier });
// Embed in the current token.api.session.setCustomClaim('tier', tier);Fetch then deny
Section titled “Fetch then deny”Call an external risk API, log the result for audit visibility, then deny if the score exceeds a threshold. Using api.log before api.deny means the reason is visible in the audit trail even when the action terminates the flow.
const res = await api.fetch('https://risk.cymmetri.io/v1/score', { method: 'POST', body: JSON.stringify({ ip: event.request.ip }), headers: { 'Content-Type': 'application/json' },});const score = JSON.parse(res.body).score ?? 0;
api.log('info', `Risk score from external API: ${score}`);
if (score > 90) { api.deny('external-risk-score-too-high'); return;}
api.state.set('external_risk_score', score);Log then state
Section titled “Log then state”Use api.log to leave a human-readable trail for your operations team, and api.state.set to pass the same conclusion to downstream Decision blocks programmatically.
const country = event.request.geo.country;const blocked = ['XX', 'YY'].includes(country);
api.log( blocked ? 'warn' : 'info', `Login from ${country} — blocked: ${blocked}`,);
api.state.set('country_blocked', blocked);
// Downstream Decision block:// step.check-country.country_blocked == true → Deny block// step.check-country.country_blocked == false → continue