Six runnable snippets, one pattern each. Copy, paste into the action editor, adapt the constants to your tenant, and you're done.
How to use this page
Section titled “How to use this page”Each example is a complete action — the entire function body is shown, nothing is left as an exercise. The header above each snippet tells you which Flow to attach it to and which Stage to drop the Run Custom Action block into. Read the "How it changes the flow" note to understand the observable effect, then check "Variations" for the next step you'll probably want to take. If you're not sure how to create and attach an action yet, start with Your first custom action and come back here.
Log the signed-in user
Section titled “Log the signed-in user”What it does. Writes the user's email address and sign-in country to the action's execution log, then stores both values in the step state so a downstream Decision block can read them.
Attach to. Login flow, Post-Auth stage.
async function logSignedInUser(event, api) { const email = event.user.email; const country = event.request.geo.country;
api.log('info', `Login: ${email} from ${country}`); api.state.set('email', email); api.state.set('country', country);}How it changes the flow. The audit log for every login gains an info entry with the user's email and originating country. The values are also available to downstream blocks as step.<your-action-slug>.email and step.<your-action-slug>.country.
Variations. Add event.request.asn.org to the log line to capture the network name alongside the country. Swap api.log for api.state.set only if you want the data downstream without producing a log entry.
Block disposable-email signups
Section titled “Block disposable-email signups”What it does. Checks the domain of the user's email address against a list of known disposable-email providers and terminates the flow before the account is created.
Attach to. Registration flow, Pre-Create stage.
async function blockDisposableEmail(event, api) { const disposableDomains = [ 'mailinator.com', 'tempmail.com', 'guerrillamail.com', 'throwam.com', 'sharklasers.com', ];
const email = event.user.email ?? ''; const domain = email.split('@')[1]?.toLowerCase() ?? '';
if (disposableDomains.includes(domain)) { api.log('warn', `Disposable email blocked: ${domain}`); api.deny('disposable-email-blocked'); return; }}How it changes the flow. Registration halts before the identity is written to the platform. The audit log records disposable-email-blocked as the reason. The user sees your tenant's standard denial message — the reason string is never surfaced to them.
Variations. Extend the array with any domains your support queue is already seeing. To keep the list centrally managed, replace the inline array with an api.fetch call that downloads the list from a URL on your tenant's fetch allowlist.
Tag corporate vs self-serve at signup
Section titled “Tag corporate vs self-serve at signup”What it does. Inspects the user's email domain at the moment the account is created and writes a tier and signup_segment value into app_metadata — the admin-controlled metadata bag the user cannot see or change.
Attach to. Registration flow, Post-Create stage.
async function tagSignupSegment(event, api) { const corporateDomains = ['cymmetri.com', 'acme-corp.com'];
const email = event.user.email ?? ''; const domain = email.split('@')[1]?.toLowerCase() ?? '';
const isCorporate = corporateDomains.includes(domain);
api.user.setAppMetadata({ tier: isCorporate ? 'corporate' : 'free', signup_segment: isCorporate ? 'b2b' : 'self_serve', tagged_at: new Date().toISOString(), });
api.log('info', `Signup tagged: ${isCorporate ? 'corporate' : 'self_serve'} (${domain})`);}How it changes the flow. Every new user account has app_metadata.tier and app_metadata.signup_segment set immediately after creation. Those values persist across sessions — you can read them back as event.user.app_metadata.tier in any later flow, including Login.
Variations. This tier value is the building block for injecting it into the access token on every login. See Example 5: Inject a tier claim into the access token directly below for the natural follow-on.
Step up to MFA on risky logins
Section titled “Step up to MFA on risky logins”What it does. Reads the aggregate risk score and Tor/VPN flags from the current login, and signals a required step-up when either the risk score crosses a threshold or the connection looks anomalous.
Attach to. Login flow, Post-Auth stage (after the Risk Evaluate block, before any Decision block that routes to MFA).
async function stepUpOnRisk(event, api) { const riskScore = event.authentication.risk_score; const isTor = event.request.asn.is_tor; const isVpn = event.request.asn.is_vpn;
const requireStepUp = riskScore > 70 || isTor;
api.state.set('require_step_up', requireStepUp); api.log( 'info', `Risk check — score: ${riskScore}, tor: ${isTor}, vpn: ${isVpn}, step_up: ${requireStepUp}` );}How it changes the flow. The action writes step.<your-action-slug>.require_step_up into the shared state. A Decision block placed immediately after reads that value and routes true to an MFA block, letting false continue to Issue Session. No step-up happens unless you wire the Decision block — the action itself never denies.
Variations. Add a geo-mismatch check by comparing event.request.geo.country to event.user.app_metadata.last_country. Combine with the corporate-tagging action (Example 3) to apply stricter thresholds to tier === 'corporate' accounts.
Inject a tier claim into the access token
Section titled “Inject a tier claim into the access token”What it does. Reads the tier value stored in the user's app_metadata — placed there by the signup-tagging action above — and embeds it as a custom claim in every access token the session issues.
Attach to. Login flow, Post-Login stage (after Issue Session), or Token Refresh flow, Pre-Issue stage.
async function injectTierClaim(event, api) { const tier = event.user.app_metadata.tier;
api.session.setCustomClaim('tier', tier ?? 'free'); api.log('info', `Claim injected — tier: ${tier ?? 'free'}`);}How it changes the flow. Every access token issued from this point forward carries a tier claim the application can read directly from the JWT payload — no back-channel call needed to look up the user's plan.
Variations. Set multiple claims in the same action to keep the call count low: api.session.setCustomClaim('region', event.request.geo.country) and api.session.setCustomClaim('segment', event.user.app_metadata.signup_segment) can follow immediately after the tier claim. Custom claims are visible to the receiving application but are not displayed in the admin console — decode the JWT to verify.
Sync a new user to a CRM via webhook
Section titled “Sync a new user to a CRM via webhook”What it does. POSTs a JSON payload about the new user to a webhook endpoint immediately after the account is created. The action logs the outcome and continues regardless of the response — a failed delivery does not block the user's registration.
Attach to. Registration flow, Post-Create stage.
async function syncUserToCrm(event, api) { // Replace the webhook.site URL with your production endpoint // before going live. Add the destination to your fetch allowlist // in Tenant Settings > Custom Actions > Fetch Allowlist. const webhookUrl = 'https://webhook.site/your-unique-id';
const payload = { email: event.user.email, signup_country: event.request.geo.country, tier: event.user.app_metadata.tier ?? 'free', created_at: event.user.created_at, };
try { const res = await api.fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); api.log('info', `CRM sync sent — status: ${res.status}`); } catch (err) { api.log('warn', `CRM sync failed — ${err.message}. Continuing registration.`); }}Never POST real user data to a public webhook inspector such as webhook.site in production. Anyone who holds the inspector URL can read every payload your action sends. Use a fake email and a test-tenant user while developing, then swap in your real endpoint — and remove the public inspector URL — before the flow goes live.
How it changes the flow. A JSON payload arrives at your CRM or internal webhook receiver immediately after every new account is created. Registration continues even if the request fails; the failure is logged but never surfaced to the user.
Variations. Add an HMAC signature to let your receiver verify the request came from your tenant. Compute it with a shared secret stored in your tenant's action secrets (Phase 2), or pass it as a static header in your block config for now.
Guard against a missing user context
Section titled “Guard against a missing user context”What it does. Checks that event.user is present and has an email address before running any further logic. Use this as a defensive wrapper at the top of any action that relies on user identity.
Attach to. Any flow, any stage — but most useful in Registration or Login flows that run early, before identification is guaranteed.
async function guardUserContext(event, api) { if (!event.user || !event.user.email) { api.log('warn', 'Action skipped — event.user not populated at this stage'); // Return without calling api.deny so the flow continues. // If your action REQUIRES a user, call api.deny here instead. return; }
// Your action logic follows here. api.log('info', `User context confirmed: ${event.user.email}`);}How it changes the flow. If event.user is unpopulated — which can happen when an action is placed in an early stage before identity lookup runs — the action logs a warning and exits cleanly instead of throwing an unhandled error. An unhandled error causes the runtime to treat the action as a denial, which would block the user unexpectedly.
Variations. Promote the guard into a reusable wrapper by pasting it at the top of every action that reads event.user. If your action must have a user to do meaningful work, replace the silent return with api.deny('user-context-missing') so the failure is explicit in the audit log.
Patterns not covered here
Section titled “Patterns not covered here”These six examples handle the most common single-action needs. More complex work — chaining a webhook call into a branching decision, coordinating two or three actions across a multi-stage flow, or building a full fraud-signal pipeline that combines risk score, geo-mismatch, and device fingerprint into one routing decision — is better told as a complete flow walkthrough. Recipes covers those end-to-end configurations with annotated flow diagrams and all the block wiring shown.