Custom Code Rules

Write TypeScript authorization logic that compiles to WebAssembly and runs in Lithic's Rules Engine with deterministic execution for backtesting.

📘

Custom Code Rules is a premium feature included in the Lithic Fraud Command suite. To enable Fraud Command and access this feature, contact your Customer Success Manager.

Overview

Custom Code Rules (TYPESCRIPT_CODE rule type) let you write custom logic in TypeScript that runs directly inside Lithic's Rules Engine. Unlike CONDITIONAL_ACTION rules, which evaluate a fixed set of attribute conditions, Custom Code Rules give you the full expressiveness of a programming language: arithmetic on multiple features, string manipulation, pattern matching, multi-factor scoring, and any other logic you can express in TypeScript.

Your TypeScript code is executed in a sandboxed environment with deterministic inputs and the execution is fully reproducible. This means Custom Code Rules benefit from the same shadow mode testing, backtesting, and performance reporting available to all other rule types.

Custom Code Rules are available on the following event streams:

Event StreamDescription
AUTHORIZATIONCard transaction authorization requests
THREE_DS_AUTHENTICATION3DS authentication requests
TOKENIZATIONDigital wallet and merchant tokenization requests
ACH_CREDIT_RECEIPTIncoming ACH credit transactions
ACH_DEBIT_RECEIPTIncoming ACH debit transactions
CARD_TRANSACTION_UPDATEPost-authorization transaction monitoring events (see Transaction Monitoring Rules)

When to Use Custom Code Rules

Use CONDITIONAL_ACTION rules when your logic can be expressed as a set of attribute checks (e.g., block transactions from specific countries or merchant categories). Use VELOCITY_LIMIT rules for straightforward spend or count limits over time periods.

Use Custom Code Rules when your logic requires:

  • Arithmetic across multiple signals: scoring models that combine weighted risk factors
  • String manipulation: fuzzy matching on merchant descriptors or cardholder names
  • Conditional thresholds: different limits based on card metadata, account attributes, or computed values
  • Complex branching: logic that cannot be reduced to AND/OR conditions on a flat set of attributes
  • External libraries: timezone conversions, pattern matching, or other utility functions

How Custom Code Rules Work

At a high level, the lifecycle of a custom code rule is:

  1. Write: You define a rule() function in TypeScript that accepts typed feature inputs and returns an array of actions
  2. Compile: When you create or update a draft, Lithic validates your TypeScript code and wraps it in a WebAssembly module. This process may take up to 10 seconds. The draft is in a PENDING state during compilation.
  3. Shadow: Once compilation succeeds, the draft moves to SHADOWING state and begins evaluating against live events without affecting outcomes
  4. Promote: After validating shadow results, you promote the draft to make it active
flowchart LR
    A[Write TypeScript] --> B[Create Draft]
    B --> C[PENDING]
    C --> D[SHADOWING]
    D --> E[Promote]
    E --> F[ACTIVE]
    C -->|Compilation<br/>Error| G[ERROR]
    G -->|Fix & Redraft| B

If compilation fails, the draft enters an ERROR state with a diagnostic message describing the issue. You can fix the code and create a new draft to try again.

The easiest way to create and manage Custom Code Rules is through the built-in code editor in the Lithic Dashboard, which provides syntax highlighting, inline type errors, and one-click promotion. Custom Code Rules are also available through the Auth Rules API: pass your TypeScript source in the code parameter and declare inputs in features.

For details on the full rule management lifecycle (including shadow mode, promotion, and rollback), see Authorization Rules.

Writing a Custom Code Rule

The Rule Function

Every custom code rule must define a rule() function. This function receives features as positional arguments and returns an array of actions. If the function returns an empty array, the rule takes no action on the event.

import { Authorization, AuthorizationAction as Action } from './types';

function rule(authorization: Authorization): Action[] {
  if (authorization.amount > 100_00 && authorization.merchant.country !== 'USA') {
    return [Action.Decline("SUSPECTED_FRAUD", "High-value foreign transaction")];
  }
  return [];
}

The './types' module is auto-generated by Lithic and contains TypeScript type definitions for all available features and actions. You do not need to create this file. It is bundled automatically during compilation.

Features (Inputs)

Features are the data inputs available to your rule at evaluation time. You declare which features your rule needs when creating it, and they are passed as positional arguments to the rule() function in the order you declare them.

Each feature has a name (which becomes the parameter name in your function signature) and a type (which determines what data it contains). The available feature types depend on the event stream:

Feature TypeTypeScript TypeAvailable On
AUTHORIZATIONAuthorizationAUTHORIZATION
AUTHENTICATIONAuthenticationTHREE_DS_AUTHENTICATION
TOKENIZATIONTokenizationTOKENIZATION
ACH_RECEIPTACHReceiptACH_CREDIT_RECEIPT, ACH_DEBIT_RECEIPT
CARD_TRANSACTIONCardTransactionCARD_TRANSACTION_UPDATE
CARDCardAUTHORIZATION, THREE_DS_AUTHENTICATION, CARD_TRANSACTION_UPDATE
ACCOUNT_HOLDERAccountHolderAUTHORIZATION, THREE_DS_AUTHENTICATION, CARD_TRANSACTION_UPDATE
IP_METADATAIpMetadataTHREE_DS_AUTHENTICATION
SPEND_VELOCITYSpendVelocityAUTHORIZATION, CARD_TRANSACTION_UPDATE

A rule can declare multiple features. For example, a rule that needs both the authorization event and the card data would declare two features:

import { Authorization, Card, AuthorizationAction as Action } from './types';

function rule(authorization: Authorization, card: Card): Action[] {
  // Access both authorization event data and card attributes
  const isHighRisk = authorization.amount > 50_00 && card.state === 'OPEN';
  // ...
  return [];
}
📘

The SPEND_VELOCITY feature type provides access to spend aggregations over configurable time periods. When declaring a SPEND_VELOCITY feature, you must also specify the scope, period, and optional filters parameters. See the API specification for the full configuration options.

Actions (Outputs)

The rule() function returns an array of action objects. The available actions depend on the event stream:

Event StreamAvailable ActionsDescription
AUTHORIZATIONAction.Decline(code, explanation?)Decline the transaction with a result code
Action.Challenge(explanation?)Trigger an SMS step-up challenge. See Authorization Challenges
Action.PartialApprove(approved_amount)Approve a partial amount
THREE_DS_AUTHENTICATIONAction.Decline(code, explanation?)Decline the authentication
Action.Challenge(explanation?)Trigger a 3DS challenge
TOKENIZATIONAction.Approve(explanation?)Approve the tokenization
Action.RequireTfa(reason?, explanation?)Require two-factor authentication
Action.Decline(reason?, explanation?)Decline the tokenization
ACH_CREDIT_RECEIPT / ACH_DEBIT_RECEIPTAction.Approve(explanation?)Approve the ACH transaction
Action.Return(code, explanation?)Return the ACH transaction with a NACHA return code
CARD_TRANSACTION_UPDATEAction.Tag(key, value, explanation?)Attach a key/value tag to the transaction
Action.CreateCase(scope, queue_token, explanation?)Open an investigation case that routes to a queue for review. See Transaction Monitoring Rules

The code parameter on Decline and Return actions is a typed enum (e.g., AuthorizationDeclineCode, ACHReturnCode), not a free-form string. Your code must use a valid enum value or it will fail type checking during compilation. See the API specification for the full list of valid codes per event stream.

The optional explanation string on each action is recorded alongside rule results for auditing purposes. Use it to describe the reason for the action.

import { Authorization, AuthorizationAction as Action } from './types';

function rule(authorization: Authorization): Action[] {
  if (authorization.merchant.mcc === '7995') {
    return [Action.Decline("BLOCKED_MCC", "Gambling merchant blocked")];
  }
  return [];
}

Importing Libraries

Custom Code Rules support URL imports from esm.sh CDN. This lets you use any open-source TypeScript or JavaScript library without a local build step. Lithic handles bundling and compilation behind the scenes. All URL imports are resolved and bundled into a single module during compilation. Type definitions are fetched automatically from CDN type headers, so full type checking is available for imported libraries.

import { getHours, isWeekend } from 'https://esm.sh/[email protected]';
import { Authorization, AuthorizationAction as Action } from './types';

function rule(authorization: Authorization): Action[] {
  // Rules run in UTC, so these checks use UTC time
  const created = new Date(authorization.created);
  const isOvernight = getHours(created) < 6;

  // Block large weekend or overnight transactions
  if ((isWeekend(created) || isOvernight) && authorization.amount > 1_000_00) {
    return [Action.Decline("AUTH_RULE", "Large transaction outside business hours")];
  }
  return [];
}

Some JavaScript APIs are not available in the rule runtime, and libraries that depend on them fail when the rule runs. See Constraints and Limitations.

Code Examples

See the following examples to get started:

  1. Risk Scoring (Authorization) - assign risk points based on multiple signals and decline when the cumulative score exceeds a threshold
  2. Fuzzy Matching (3DS Authentication) - use an external fuzzy matching library to compare cardholder names during 3DS authentication
  3. Spend Tiers (Authorization) - use a SPEND_VELOCITY feature (configured with CARD scope and DAY period) to enforce different daily limits based on card metadata
import { Authorization, Card, AuthorizationAction as Action } from './types';

function rule(authorization: Authorization, card: Card): Action[] {
  let riskScore = 0;

  // Foreign transaction
  if (authorization.merchant.country !== 'USA') {
    riskScore += 2;
  }

  // High amount relative to typical spend
  if (authorization.amount > 500_00) {
    riskScore += 3;
  }

  // Card-not-present transaction
  if (authorization.pos?.entry_mode.card === 'NOT_PRESENT') {
    riskScore += 2;
  }

  // No liability shift from 3DS
  if (authorization.cardholder_authentication?.liability_shift !== '3DS_AUTHENTICATED') {
    riskScore += 3;
  }

  if (riskScore >= 8) {
    return [Action.Decline("SUSPECTED_FRAUD", `Risk score ${riskScore} exceeds threshold`)];
  }

  return [];
}
import * as fuzz from 'https://esm.sh/fuzzball@2';
import {
  Authentication,
  AccountHolder,
  AuthenticationAction as Action,
} from './types';

function rule(
  authentication: Authentication,
  account_holder: AccountHolder
): Action[] {
  const cardholderName = authentication.cardholder.name;
  const nameOnFile =
    `${account_holder.first_name} ${account_holder.last_name}`.trim();

  // Challenge if cardholder name is missing
  if (!cardholderName) {
    return [Action.Challenge("Cardholder name missing from 3DS request")];
  }

  // Challenge if name does not match using fuzzy comparison
  const score = fuzz.token_sort_ratio(
    cardholderName.toLowerCase(),
    nameOnFile.toLowerCase()
  );
  if (score < 80) {
    return [
      Action.Challenge(
        `Name mismatch: '${cardholderName}' vs '${nameOnFile}' (score: ${score})`
      ),
    ];
  }

  return [];
}
import {
  Authorization,
  Card,
  SpendVelocity,
  AuthorizationAction as Action,
} from './types';

function rule(
  authorization: Authorization,
  card: Card,
  velocity: SpendVelocity
): Action[] {
  // Determine daily limit from the card's program family
  const tier = card.card_program_family;
  const dailyLimitCents: Record<string, number> = {
    COMMERCIAL: 10_000_00,
    CONSUMER: 1_000_00,
  };

  const limit = dailyLimitCents[tier] ?? dailyLimitCents['CONSUMER'];
  const projectedSpend = velocity.total_amount + authorization.amount;

  if (projectedSpend > limit) {
    return [
      Action.Decline(
        "CARD_VELOCITY",
        `Projected daily spend ${projectedSpend} exceeds ${tier} limit ${limit}`
      ),
    ];
  }

  return [];
}

Constraints and Limitations

  • Execution time: Each rule must complete within 10 milliseconds. If a rule exceeds the time limit or throws an uncaught exception, it is treated as an evaluation error. For decisioning event streams (those that approve or decline), the transaction is declined. The issue will be visible in the dashboard and also in the rule evaluation results.
  • Bundle size: The total bundled size of your code and its dependencies must not exceed 1 MB. Rules that exceed this limit will fail to compile.
  • No external calls: Rules cannot make network requests, access the filesystem, or reach any resources outside their declared features.
  • Synchronous only: The rule() function must be synchronous; async/await is not supported.
  • URL imports only: Only URL imports from esm.sh are supported; npm install-style dependency management is not available.
  • Limited JavaScript APIs: Rules run in a lightweight JavaScript engine, not a browser or Node.js, so some standard APIs are not available. These include Intl, URL, atob and btoa, crypto, timers such as setTimeout, and Node.js globals such as Buffer and process. Dates and times are in UTC. Code that uses a missing API, directly or through an imported library, can compile successfully and then fail when the rule runs. See Troubleshooting Evaluation Errors.
  • Deterministic execution: All rule execution is fully deterministic. If your code reads the current time, it receives the event timestamp, not wall clock time. Random number generation is seeded from the event. This is what enables backtesting: the same rule always produces the same result when replayed against the same event.

Troubleshooting Evaluation Errors

Compilation checks your types and bundles your imports, but it does not run your code. A rule that compiles can still fail at evaluation time, for example when it or one of its libraries calls an API the runtime does not provide.

  • Check before promoting: Keep new drafts in shadow mode, or run a backtest, and review the rule evaluation results for errors before you promote.
  • Errors on every event: If a rule errors on every event regardless of its inputs, the cause is usually an unavailable API used when a library loads.
  • Narrow it down: Remove imports one at a time, or replace a library call with plain code, until the errors stop. Before adding a library, check its documentation or source for the APIs listed above.
  • Prefer self-contained code: Libraries that only do computation on their inputs, such as string matching or date arithmetic in UTC, work well. For local time, apply the UTC offset yourself.

Next Steps


Did this page help you?