# 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. If your extension declares an output schema, the action’s output fields are available as data that downstream steps can reference.
2. If your extension implements `get_form_state`, the builder calls it to populate dynamic dropdowns, show or hide fields, and set initial field states.
3. As the user changes field values, the builder calls `get_form_state` again with the current form values, and your implementation returns updated options, schemas, and field states.
4. 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.
2. Your action runs according to the logic you defined (send an email, create a record, call an external API).
3. Your action returns success or failure. If you declared an output schema, the returned `custom_output` values become available to downstream steps. The workflow engine handles retries for transient failures.

## Action parameters 

Each custom action defines an **input schema** (what data the user configures) and, optionally, a **UI schema** (how the configuration form looks in the workflow builder). If you don’t provide a UI schema, the form renders each field as a standard control based on its JSON Schema type.

### 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**: Return updated configuration based on the current values.

Your implementation should inspect the `values` object to determine what state the form is in and return the appropriate configuration.

### Request format

```ts
interface GetFormStateRequest {
  values: { [fieldName: string]: any }; // Current form field values
}
```

### 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).
2. **User selects audience**: Return segment options for that audience, enable segment dropdown.
3. **User changes audience**: Clear segment value, return new segment options.

## Output values 

Your `execute` method can return output values that downstream workflow steps can reference. This lets your action produce data that other actions or conditions in the workflow can use.

### Output schema

Define an output schema as a JSON Schema (draft-07) file that declares the fields your action returns. Only fields declared in the output schema are passed to downstream steps — extra fields are dropped.

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": {
    "messages_sent": {
      "type": "integer",
      "description": "Total number of messages delivered"
    },
    "campaign_id": {
      "type": "string",
      "description": "Unique identifier for the campaign that was sent"
    },
    "delivery_successful": {
      "type": "boolean",
      "description": "Whether all messages were successfully delivered"
    }
  }
}
```

### Reference output in downstream steps

After your action runs, downstream workflow steps can reference its output fields. For example, a condition step could check `delivery_successful`, or a subsequent action could use `campaign_id` as an input.

## 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. Always return the full config object.
- **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.
- **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 when possible.** 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.
- **Use manifest errors for blocking problems.** When the entire form can’t load (for example, the app isn’t authenticated), declare errors in the manifest and throw or return a coded error. Provide actionable messages that tell the merchant how to fix the problem. The error message is displayed in the action config drawer. Undeclared error codes show a generic “Couldn’t connect to app” message instead.
- **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)
