Private preview
How custom actions work Private preview
Understand the methods, schemas, and runtime behavior for custom workflow actions.
A custom action is an extension that plugs into the extend.workflows.custom_action extension point. You package it as part of a Stripe App, 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 or build with a remote function.
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:
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:
- 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.
- If your extension implements get _ form _ state , the builder calls it to populate dynamic dropdowns, show or hide fields, and set initial field states.
- 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.
- The user fills in the form and saves the workflow. Stripe stores the configured values.
At runtime, when the workflow triggers:
- The workflow engine calls execute with the saved configuration values.
- Your action runs according to the logic you defined (send an email, create a record, call an external API).
- 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 )
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.
{
"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
interface GetFormStateRequest {
values: { [fieldName: string]: any }; // Current form field values
}
Response format
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:
- Initial load : Return all audience options, disable segment dropdown (no audience selected yet).
- User selects audience : Return segment options for that audience, enable segment dropdown.
- 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 )
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. | 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. | 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. |
| 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_statecall. The UI replaces config with what you return, so include all fields in every response. Always return the full config object. - Keep
get_form_stateresponses fast. This method runs during form interaction. Target less than 500ms response times. - Use
disabledandhiddenfor 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
executeidempotent. Use the request id to deduplicate. - Use 4xx for permanent failures, 5xx for transient failures. This controls retry behavior.
