# How custom actions work

Understand the methods, schemas, and runtime behavior for custom workflow actions.

A custom action is an [extension](https://docs.stripe.com/extensions/how-extensions-work.md) that plugs into the `extend.workflows.custom_action` [extension point](https://docs.stripe.com/extensions/extension-points.md). You package it as part of a [Stripe App](https://docs.stripe.com/stripe-apps.md), and it appears in the workflow builder for anyone who installs the app.

You can implement custom actions using scripts (TypeScript on Stripe’s managed runtime) or remote functions (HTTP endpoints on your own infrastructure). This page covers the concepts that apply to both. For step-by-step build guides, see [build with a script](https://docs.stripe.com/extensions/custom-actions/build-with-script.md) or [build with a remote function](https://docs.stripe.com/extensions/custom-actions/build-with-remote-function.md).

## Permissions 

Custom actions require the `workflow_custom_action_run_write` permission. This grants your extension access to workflow run data, including values from earlier steps that you can pass into your action’s `execute` method. Account administrators who install your app must accept this permission before using it.

Add the permission to your extension in `stripe-app.yaml`:

```yaml
extensions:
  - id: "send_email"
    permissions:
      - permission: workflow_custom_action_run_write
        purpose: "Runs custom actions in workflows and accesses data from earlier workflow steps"
```

## Methods 

The `extend.workflows.custom_action` extension point defines two methods:

| **Method**       | **When it runs**                                                       | **Required?** | **Purpose**                                                       |
| ---------------- | ---------------------------------------------------------------------- | ------------- | ----------------------------------------------------------------- |
| `execute`        | At runtime, when the workflow runs                                     | Yes           | Perform your action (send an email, call an API, create a record) |
| `get_form_state` | At configuration time, when a user sets up the action in the Dashboard | No            | Populate dynamic dropdowns, show/hide fields, validate input      |

If you don’t implement `get_form_state`, the form renders statically from your input schema and UI schema. Static forms work for actions with fixed fields. Implement `get_form_state` when you need dynamic behavior like cascading dropdowns or conditionally visible fields.

## How the flow works 

At configuration time, when a user adds your action to a workflow in the Dashboard:

1. The workflow builder renders a form based on your input schema and UI schema.
1. If your extension implements `get_form_state`, the builder calls it to populate dynamic dropdowns, show or hide fields, and set initial field states.
1. As the user changes field values, the builder calls `get_form_state` again with the changed field name, and your implementation returns updated options, schemas, and field states.
1. The user fills in the form and saves the workflow. Stripe stores the configured values.

At runtime, when the workflow triggers:

1. The workflow engine calls `execute` with the saved configuration values.
1. Your action runs according to the logic you defined (send an email, create a record, call an external API).
1. Your action returns success or failure. The workflow engine handles retries for transient failures.

## Action parameters 

Each custom action defines an **input schema** (what data the user configures) and a **UI schema** (how the configuration form looks in the workflow builder).

### Input schema

A JSON Schema (draft-07) file that defines the data contract. Stripe saves this data when the user configures the action, and passes it to the `execute` method at runtime.

Supported types:

- `string`
- `boolean`
- `integer`
- `object`
- `array` (array items must be `string`, `boolean`, or `integer`)

```json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "audience_id": {
      "type": "string",
      "title": "Audience",
      "description": "Select a mailing list"
    },
    "segment_id": {
      "type": "string",
      "title": "Segment",
      "description": "Target a specific segment within the audience (optional)"
    },
    "template_id": {
      "type": "string",
      "title": "Email Template",
      "description": "Select an email template to use"
    },
    "template_variables": {
      "type": "object",
      "title": "Template Variables",
      "description": "Fill in the merge fields for your selected template"
    },
    "message": {
      "type": "string",
      "title": "Message",
      "description": "Custom message body to include in the email"
    }
  },
  "required": ["audience_id", "template_id"],
  "additionalProperties": false
}
```

Keep the JSON Schema definition simple for fields powered by dynamic behavior (dropdowns, dynamic schemas). The actual options and schemas come from `get_form_state` at configuration time.

### UI schema

A JSONForms file that controls layout and dynamic behavior.

```json
{
  "type": "VerticalLayout",
  "elements": [
    {
      "type": "Control",
      "scope": "#/properties/audience_id",
      "options": { "format": "dynamic_select" }
    },
    {
      "type": "Control",
      "scope": "#/properties/segment_id",
      "options": { "format": "dynamic_select" }
    },
    {
      "type": "Control",
      "scope": "#/properties/template_id",
      "options": { "format": "dynamic_select" }
    },
    {
      "type": "Control",
      "scope": "#/properties/template_variables",
      "options": { "format": "dynamic_schema" }
    },
    {
      "type": "Control",
      "scope": "#/properties/message",
      "options": { "multi": true, "template": true }
    }
  ]
}
```

The `message` field uses `multi` for a multiline text area and `template` to enable template string interpolation, where users can reference workflow data using template syntax.

**UI schema format options:**

| **Option**                   | **Description**                                                                                     |
| ---------------------------- | --------------------------------------------------------------------------------------------------- |
| `"format": "dynamic_select"` | Dropdown whose options are populated by `get_form_state`                                            |
| `"format": "dynamic_schema"` | Object whose schema is returned dynamically by `get_form_state`                                     |
| `"multi": true`              | Renders the field as a multiline text area                                                          |
| `"template": true`           | Enables template string interpolation, allowing users to reference workflow data in the field value |
| *(none)*                     | Standard control rendered from the JSON Schema type                                                 |

## Dynamic forms with `get_form_state` 

The `get_form_state` method powers dynamic form behavior in the workflow builder (dropdown options, dynamic schemas, field states, and value updates). This method is called:

- **On initial form load**: Return the initial configuration for all dynamic fields.
- **When a user changes a field value**: `changedField` contains the field name. Return updated configuration based on the new value.

For scripts, the SDK delivers `changedField` as `undefined` on initial load (camelCase, converted automatically from the wire format). For remote functions, the wire format sends `changed_field` as `null` on initial load (snake_case).

### Request format

```ts
interface GetFormStateRequest {
  values: { [fieldName: string]: any }; // Current form field values
  changedField: string | undefined;     // Which field changed (undefined on initial load)
}
```

### Response format

```ts
interface FieldConfig {
  options: Array<{ value: string; label: string }>; // For dynamic_select fields (required, use [] if N/A)
  schema: Record<string, unknown>;                   // For dynamic_schema fields (required, use {} if N/A)
  disabled?: boolean;         // Grey out the field
  hidden?: boolean;           // Hide the field entirely
  warning?: string;           // Shows warning message, workflow can still be saved
  error?: string;             // Shows error message, workflow can't be saved
}

interface GetFormStateResponse {
  values: { [fieldName: string]: any };          // Updated field values (required)
  config: { [fieldName: string]: FieldConfig };  // Configuration for each dynamic field
}
```

`options` and `schema` are **required** on every field config entry. Use `options: []` for fields that are not dropdowns and `schema: {}` for fields that do not have a dynamic schema.

### Example pattern: cascading dropdowns

A user selects an email audience, and the segment dropdown populates with segments from that audience:

1. **Initial load**: Return all audience options, disable segment dropdown (no audience selected yet).
1. **User selects audience**: Return segment options for that audience, enable segment dropdown.
1. **User changes audience**: Clear segment value, return new segment options.

## Runtime behavior 

### Timeouts

Each call to `execute` has a 20-second timeout. If your action doesn’t complete within 20 seconds, Stripe treats the call as failed and retries it. Design your action to complete within this window.

### Retry behavior

Stripe retries failed actions automatically. For remote functions, retry behavior is based on the HTTP status code your endpoint returns:

| **Status** | **Meaning**                                   | **Retried?** |
| ---------- | --------------------------------------------- | ------------ |
| 200        | Success                                       | No           |
| 4xx        | Permanent failure (bad input, invalid config) | No           |
| 5xx        | Transient failure (service down, timeout)     | Yes          |

For scripts, unhandled errors thrown from your script are retried. If your script catches an error and returns a result, Stripe treats that as a success and does not retry.

Return 4xx for errors that won’t resolve on retry (invalid template ID, malformed input). Return 5xx only for transient issues (external service temporarily unavailable).

### Idempotency

Because retries happen automatically, your `execute` implementation must be idempotent. The same action might be called multiple times for the same workflow execution. Every request from Stripe includes an `id` field that stays the same across retries — use it as an idempotency key. For example, check whether you’ve already sent an email for that request ID before sending another.

### You don’t need async processing

Your action doesn’t need its own async job processing. If your work fits within the 20-second timeout, do it synchronously and return success or failure. Stripe handles the orchestration, scheduling, and retries around your action.

If you return success immediately and kick off background work, you lose the ability to report errors back to the workflow. From the workflow’s perspective, your action succeeded. For example, if your action sends an email through an external service and that service is down, the workflow still records the action as successful because your endpoint returned 200. The workflow won’t retry, and you won’t see an error in run details.

## Choosing an implementation type 

| Consideration                 | Script                                                                                                                                                                                                       | Remote function                                                                                                                                              |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Where it runs                 | Stripe’s managed runtime                                                                                                                                                                                     | Your infrastructure                                                                                                                                          |
| Language                      | TypeScript                                                                                                                                                                                                   | Any language (HTTP endpoint)                                                                                                                                 |
| External API calls            | Yes, via `endpointFetch()`. Stripe handles auth injection through the [Secret Store](https://docs.stripe.com/stripe-apps/store-secrets.md).                                                                  | Yes, you make calls directly from your servers                                                                                                               |
| Auth for external services    | You build a settings UI to collect credentials. Stripe auto-injects them into outgoing requests via the manifest `auth` config and the [Secret Store](https://docs.stripe.com/stripe-apps/store-secrets.md). | You build a settings UI to collect credentials. You retrieve them from the Secret Store on your backend and include them in your outgoing requests yourself. |
| Auth from Stripe to your code | N/A, your code runs on Stripe                                                                                                                                                                                | Stripe signs each request. You [verify the webhook signature](https://docs.stripe.com/webhooks.md#verify-official-libraries).                                |
| Best for                      | Logic that doesn’t need your own infrastructure                                                                                                                                                              | Logic that needs your own systems, transforms, or non-TypeScript languages                                                                                   |
| Limitations                   | No third-party dependencies, no raw `fetch()`, TypeScript only                                                                                                                                               | You manage hosting, availability, and deployment                                                                                                             |

## Best practices 

- **Return config for all dynamic fields on every `get_form_state` call.** The UI replaces config with what you return, so include all fields in every response. You can use `changedField` to scope how you compute the config — for example, skip re-fetching data for fields unrelated to the change — but always return the full config object. On initial load, fetch everything.
- **Keep `get_form_state` responses fast.** This method runs during form interaction. Target less than 500ms response times.
- **Use `disabled` and `hidden` for dependent fields.** Don’t error when a parent field hasn’t been selected yet, return `{ disabled: true }` instead.
- **Clear dependent values when parents change.** Use the `values` return to set dependent fields to `undefined` (for example, clear segment when audience changes).
- **Handle stale saved values.** If a saved value no longer exists in the options, return a `warning` instead of auto-clearing. This lets users see what happened.
- **Handle errors per-field.** If a fetch fails for one field (for example, the template service is down), return an `error` on that specific field rather than failing the entire `get_form_state` request. This gives users a better experience — they can still configure the other fields.
- **Make `execute` idempotent.** Use the request `id` to deduplicate.
- **Use 4xx for permanent failures, 5xx for transient failures.** This controls retry behavior.

## See also

- [Build a custom action using a script](https://docs.stripe.com/extensions/custom-actions/build-with-script.md)
- [Build a custom action using a remote function](https://docs.stripe.com/extensions/custom-actions/build-with-remote-function.md)
- [Extension points](https://docs.stripe.com/extensions/extension-points.md)
- [How extensions work](https://docs.stripe.com/extensions/how-extensions-work.md)
