Skip to main content
Script Execution lets you create a workflow that runs custom JavaScript whenever a Journey starts for a subscription. Your script receives the subscription context as input and must return one or more Firmhouse actions for the Journey to execute.
Script Execution is currently a private beta feature. During the beta, Firmhouse staff needs to enable it for your project.

What it is

Script Execution is useful when you need custom Journey logic that is not covered by the standard workflow presets. Instead of directly changing a subscription from the script, your script decides which supported action Firmhouse should execute next. The script:
  • must contain valid JavaScript
  • receives the subscription context as input
  • must return an array of action objects
  • cannot perform direct side effects in Firmhouse by itself

Add a Script Execution workflow

  1. Ask the Firmhouse support team to enable the Script Execution beta for your project.
  2. Open your project in the Firmhouse portal.
  3. In the sidebar, go to Workflows.
  4. Click Create new workflow.
  5. Choose Script Execution.
  6. Enter a name for the workflow and optionally add a description.
  7. Paste your JavaScript into the script editor.
  8. Optionally, add Input Parameters (see below).
  9. Click Save workflow.

Input Parameters

Input Parameters let you define key/value pairs that become available in your script. This lets you write a single reusable script and configure it differently per workflow — without editing JavaScript. Your script receives the parameters in two forms:
  • input.params — the raw array of parameter objects exactly as configured, e.g. [{"key": "order_1", "type": "product", "value": "gid://..."}, ...]. Use this when you need access to the full metadata (type, individual entries).
  • input.params_normalized — a convenience hash that groups values by key, e.g. {"order_1": "gid://...", "order_2": ["gid://...", "gid://..."]}. Duplicate keys are automatically collected into arrays. Use this for simple key-based lookups.
To add parameters, expand the Input Parameters section on the workflow form, click + Add parameter, enter a key and value, and save.

Parameter types

Each parameter has a Type selector:
  • Text (default) — Enter any plain text value. The value is always passed to your script as a string. If you need array-like behaviour, use a comma-separated or pipe-separated value and split it in your script (e.g. "a,b,c".split(",")).
  • Product — Select a product from your catalog using a search autocomplete field. This makes it easy to deterministically reference a specific product in your input parameters without needing to look up its ID. The selected product’s Global ID is stored and passed to your script as a string (e.g. "gid://firmhouse/Product/123").
You can use the same key multiple times. For example, two rows with key order_2 and type Product will appear as two separate entries in input.params. In input.params_normalized, they are grouped into an array: input.params_normalized.order_2["gid://firmhouse/Product/101", "gid://firmhouse/Product/102"].

The script protocol

Your script must return an array. Each item in the array must be an object with:
  • function
  • arguments
For example:
return [
  {
    function: "no_action",
    arguments: {
      reason: `No follow-up action is needed for subscription signed up at ${input.signed_up_at}`
    }
  }
]

Rules

  • The script must use valid JavaScript syntax.
  • The script must return [...], not { actions: [...] }.
  • The returned value must be an array.
  • Each action must be an object.
  • function must be one of the supported function names listed below.
  • arguments must be an object.
  • Required arguments must be present.
  • Extra arguments are rejected.
  • Argument types must match the expected types.

Copy-paste examples

No-op / smoke test

Use this script to verify that the workflow executes successfully without changing the subscription.
return [
  {
    function: "no_action",
    arguments: {
      reason: `signed up ${input.signed_up_at}`
    }
  }
]

Product swap example

Use this script as a starting point for a workflow that supports multiple source-to-target product mappings and returns one swap action for each matching current product.
const productMappings = [
  {
    source_product_id: "gid://firmhouse/Product/101",
    target_product_id: "gid://firmhouse/Product/201"
  },
  {
    source_product_id: "gid://firmhouse/Product/102",
    target_product_id: "gid://firmhouse/Product/202"
  }
]

const actions = productMappings.flatMap((mapping) => {
  const matchingProduct = input.current_products.find((product) => {
    return product.id === mapping.source_product_id
  })

  if (!matchingProduct) return []

  return [
    {
      function: "swap_product_by_id",
      arguments: {
        current_product_id: matchingProduct.id,
        new_product_id: mapping.target_product_id,
        reason: `Swap ${matchingProduct.title} based on configured product mapping`
      }
    }
  ]
})

if (actions.length === 0) {
  return [
    {
      function: "no_action",
      arguments: { reason: "No configured source products were found on this subscription" }
    }
  ]
}

return actions

Promotion by confirmed orders count

Use this script when you want to apply different promotions after specific order milestones. In this example, one promotion is applied after 3 confirmed orders and another after 6 confirmed orders.
const promotionByConfirmedOrders = {
  3: "gid://firmhouse/Promotion/301",
  6: "gid://firmhouse/Promotion/302"
}

const promotionId = promotionByConfirmedOrders[input.confirmed_orders_count]

if (!promotionId) {
  return [
    {
      function: "no_action",
      arguments: {
        reason: `No promotion configured for ${input.confirmed_orders_count} confirmed orders`
      }
    }
  ]
}

return [
  {
    function: "apply_promotion",
    arguments: {
      promotion_id: promotionId,
      reason: `Apply the configured promotion after ${input.confirmed_orders_count} confirmed orders`
    }
  }
]

Product sequence with revolving cycle

Some subscription businesses ship different products depending on where the subscriber is in their journey. For example, a health supplements brand might send a starter kit on the first order, then alternate between two different refill packs from order 2 onward. After the first checkout order and possible follow-up orders, you can configure a revolving cycle that spans multiple orders. This script uses Input Parameters to configure which products belong to each order in the sequence. You do not need to edit the JavaScript — just set the parameters in the workflow form.

Real-world example

Imagine a supplements brand that works like this:
  • The customer checks out with a Starter Kit (order 1).
  • On order 2, the subscription switches to two refill products: Refill Pack A and Refill Pack B.
  • On order 3, the subscription has only Refill Pack A.
  • From order 4 onward, orders 2 and 3 keep repeating: the customer alternates between receiving both refill packs and just one.
To set this up, you configure the Input Parameters like this:
KeyTypeValue
order_1ProductStarter Kit
order_2ProductRefill Pack A
order_2ProductRefill Pack B
order_3ProductRefill Pack A
restart_from_order_2Texttrue
The resulting order sequence:
  • Order 1 → Starter Kit (checkout order — already on subscription at signup)
  • Order 2 → Refill Pack A + Refill Pack B (removes Starter Kit, adds both refills)
  • Order 3 → Refill Pack A (removes Refill Pack B, keeps Refill Pack A)
  • Order 4 → Refill Pack A + Refill Pack B (cycles back to order 2)
  • Order 5 → Refill Pack A (cycles back to order 3)
  • …and so on

How the Input Parameters work

Each key follows the order_X format, where X is the order number. Set the type to Product and search for the product you want. To add multiple products to the same order, add multiple rows with the same key (e.g. two rows both named order_2, each with a different product). The script collects all products for that order automatically. order_1 represents the checkout order — the products the customer receives at signup. These are already on the subscription, so the script does not add them. It uses order_1 to know which products to remove when moving to order_2. Add a key named restart_from_order_X (e.g. restart_from_order_2) with any value (e.g. true) to set where the revolving cycle begins. Once all configured orders have been fulfilled, the sequence loops back to this order number and repeats indefinitely.

The script

Paste this script into the Script Execution workflow. It reads the Input Parameters and handles add/remove automatically.
const params = input.params_normalized
const orderCount = input.confirmed_orders_count
const currentProducts = input.current_products || []

// Collect order_X keys and parse step numbers
const stepNumbers = Object.keys(params)
  .filter(k => k.startsWith("order_"))
  .map(k => parseInt(k.split("_")[1]))
  .filter(n => !isNaN(n))
  .sort((a, b) => a - b)
// Deduplicate step numbers (same key can appear in multiple rows)
const uniqueSteps = [...new Set(stepNumbers)]

if (uniqueSteps.length === 0) {
  return [{ function: "no_action", arguments: { reason: "No order_X steps configured in input params" } }]
}

const maxStep = uniqueSteps[uniqueSteps.length - 1]

// Find restart_from_order_X key to determine revolving cycle start
let alternateFrom = uniqueSteps[0]
for (const key of Object.keys(params)) {
  const match = key.match(/^restart_from_order_(\d+)$/)
  if (match) {
    alternateFrom = parseInt(match[1])
    break
  }
}

// The script runs after each order is confirmed.
// We prepare the subscription for the NEXT order.
// order_1 is the checkout order — its products are already on the subscription at signup.
// After order 1 is confirmed (orderCount=1), we swap to order_2 products, etc.
if (orderCount < 1) {
  return [{ function: "no_action", arguments: { reason: "No confirmed orders yet" } }]
}

const nextOrder = orderCount + 1

// Determine which step to prepare for
let effectiveStep
if (nextOrder <= maxStep) {
  effectiveStep = nextOrder
} else {
  // Revolving cycle: loop from alternateFrom to maxStep
  const cycleLength = maxStep - alternateFrom + 1
  const offset = (nextOrder - alternateFrom) % cycleLength
  effectiveStep = alternateFrom + offset
}

// Get the target product IDs for this step
// Supports both single values and arrays (from duplicate keys)
const stepKey = "order_" + effectiveStep
const rawTarget = params[stepKey]
const targetProductIds = Array.isArray(rawTarget) ? rawTarget : (rawTarget ? [rawTarget] : [])

if (targetProductIds.length === 0) {
  return [{ function: "no_action", arguments: { reason: "No products defined for step " + effectiveStep } }]
}

const currentProductIds = currentProducts.map(p => p.id)
const actions = []

// Remove products that should no longer be on the subscription
for (const currentId of currentProductIds) {
  if (!targetProductIds.includes(currentId)) {
    actions.push({
      function: "remove_product_from_subscription",
      arguments: {
        product_id: currentId,
        reason: "Removing product not in order_" + effectiveStep + " of sequence"
      }
    })
  }
}

// Add products that should be on the subscription but are not yet
for (const targetId of targetProductIds) {
  if (!currentProductIds.includes(targetId)) {
    actions.push({
      function: "add_product_to_subscription",
      arguments: {
        product_id: targetId,
        reason: "Adding product for order_" + effectiveStep + " of sequence"
      }
    })
  }
}

if (actions.length === 0) {
  return [{ function: "no_action", arguments: { reason: "Products already match order_" + effectiveStep } }]
}

return actions

Supported functions

no_action

Use this when the Journey should continue without making any subscription change right now. Required arguments:
  • reason (string)
Example:
return [
  {
    function: "no_action",
    arguments: {
      reason: "The subscription already matches the expected configuration."
    }
  }
]

swap_product_by_id

Use this to replace one product on the subscription with another product. Required arguments:
  • current_product_id (string)
  • new_product_id (string)
  • reason (string)
Both product IDs must be Firmhouse product Global IDs. Use the firmhouse namespace in these Global IDs, for example gid://firmhouse/Product/123. Do not use gid://gomonthly/..., because those IDs will not work in Script Execution workflows. Example:
const currentProduct = input.current_products[0]

if (!currentProduct) {
  return [
    {
      function: "no_action",
      arguments: { reason: "No current product found." }
    }
  ]
}

return [
  {
    function: "swap_product_by_id",
    arguments: {
      current_product_id: currentProduct.id,
      new_product_id: "gid://firmhouse/Product/123",
      reason: "Move the subscriber to the recurring refill product."
    }
  }
]

update_next_billing_date

Use this to move the subscription’s next billing date. Required arguments:
  • next_billing_date (string, ISO date format YYYY-MM-DD)
  • reason (string)
Example:
return [
  {
    function: "update_next_billing_date",
    arguments: {
      next_billing_date: "2026-05-15",
      reason: "Align billing with the customer’s requested charge date."
    }
  }
]

apply_promotion

Use this to apply a promotion to the subscription. Required arguments:
  • promotion_id (string)
  • reason (string)
Example:
return [
  {
    function: "apply_promotion",
    arguments: {
      promotion_id: "123",
      reason: "Apply the onboarding discount after activation."
    }
  }
]

add_product_to_subscription

Use this to add a product to the subscription. Required arguments:
  • product_id (string) — Firmhouse product Global ID
  • reason (string)
Optional arguments:
  • quantity (string) — defaults to "1" if not provided
Example:
return [
  {
    function: "add_product_to_subscription",
    arguments: {
      product_id: "gid://firmhouse/Product/123",
      reason: "Add the refill product for this cycle."
    }
  }
]

remove_product_from_subscription

Use this to remove a product from the subscription. Required arguments:
  • product_id (string) — Firmhouse product Global ID
  • reason (string)
Example:
return [
  {
    function: "remove_product_from_subscription",
    arguments: {
      product_id: "gid://firmhouse/Product/123",
      reason: "Remove the starter product after the bootstrap phase."
    }
  }
]

stop_journey

Use this when the Journey has reached its end and should not continue for this subscription. Required arguments:
  • stop_journey (boolean)
  • reason (string)
Example:
return [
  {
    function: "stop_journey",
    arguments: {
      stop_journey: true,
      reason: "The workflow has completed all follow-up actions."
    }
  }
]

What input contains

Firmhouse passes the current subscription context into your script as input. At the moment this payload follows a fixed structure. The current input payload contains:
  • signed_up_at
  • confirmed_orders_count
  • confirmed_or_fulfilled_orders
  • current_products
  • current_promotions
  • active_plan
  • params — the raw array of Input Parameter objects you configured on the workflow (empty array [] when none are set). Each entry has key, type, and value.
  • params_normalized — a convenience hash that groups params by key. Duplicate keys become arrays. Empty object {} when no parameters are set.
Example:
{
  "signed_up_at": "2026-04-01T10:15:00Z",
  "confirmed_orders_count": 1,
  "confirmed_or_fulfilled_orders": [
    {
      "id": "gid://firmhouse/Order/1",
      "order_lines": [
        {
          "id": "gid://firmhouse/OrderLine/1",
          "product_id": 123,
          "product_sku": "STARTER-001",
          "quantity": 1
        }
      ]
    }
  ],
  "current_products": [
    {
      "id": "gid://firmhouse/Product/123",
      "product_id": 123,
      "title": "Starter Product",
      "sku": "STARTER-001",
      "quantity": 1,
      "instalment_original_product_id": 123,
      "instalment_current_number": 1
    }
  ],
  "current_promotions": [
    {
      "created_at": "2026-04-01T10:15:00Z",
      "id": "gid://firmhouse/AppliedPromotion/1",
      "promotion_id": "gid://firmhouse/Promotion/1",
      "title": "Welcome Discount",
      "active": "true"
    }
  ],
  "active_plan": {
    "subscribed_plan": {
      "id": "gid://firmhouse/SubscribedPlan/1",
      "next_billing_date": "2026-04-15",
      "billing_cycle_interval_period": 1,
      "billing_cycle_interval_unit": "months",
      "delivery_cycle_interval_period": 1,
      "delivery_cycle_interval_unit": "months",
      "cancellation_strategy": "immediate"
    },
    "plan": {
      "id": "gid://firmhouse/Plan/1",
      "name": "Starter Plan",
      "slug": "starter-plan",
      "instalments": 3,
      "billing_cycle_interval_period": 1,
      "billing_cycle_interval_unit": "months",
      "delivery_cycle_interval_period": 1,
      "delivery_cycle_interval_unit": "months"
    }
  }
}

Field details

  • signed_up_at The subscription sign-up timestamp.
  • confirmed_orders_count The number of confirmed or fulfilled orders on the subscription.
  • confirmed_or_fulfilled_orders The confirmed or fulfilled orders, including their order lines.
  • current_products The currently active products on the subscription, including instalment-related fields: product_id, instalment_original_product_id, and instalment_current_number.
  • current_promotions The promotions currently applied to the subscription.
  • active_plan Selected plan details for the subscription, including both subscribed_plan attributes and full plan attributes.
You can inspect the input object in your script and use the fields you need in your logic. input.params contains the raw array of parameter objects you defined in the Input Parameters section of the workflow form. input.params_normalized provides the same data grouped by key for convenient lookups. See Input Parameters for details on how to configure them.

Troubleshooting

If the script does not save or does not run correctly, check the following:
  • the script contains valid JavaScript
  • the script returns an array
  • every action includes function and arguments
  • all required arguments are present
  • there are no extra unsupported arguments
  • the argument types match the required types
If the workflow still fails, contact Firmhouse support and include:
  • your project ID
  • the name of the workflow
  • the script you saved
  • the action you expected the script to return