Stripe | Financial Infrastructure to Grow Your Revenue

Stripe | Financial Infrastructure to Grow Your Revenue

4466 articles

How custom actions work


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:

MethodWhen it runsRequired?Purpose
executeAt runtime, when the workflow runsYesPerform your action (send an email, call an API, create a record)
get_form_stateAt configuration time, when a user sets up the action in the DashboardNoPopulate 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 )

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:

OptionDescription
"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": trueRenders the field as a multiline text area
"template": trueEnables 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:

  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 )

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:

StatusMeaningRetried?
200SuccessNo
4xxPermanent failure (bad input, invalid config)No
5xxTransient 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

ConsiderationScriptRemote function
Where it runsStripe’s managed runtimeYour infrastructure
LanguageTypeScriptAny language (HTTP endpoint)
External API callsYes, via endpointFetch(). Stripe handles auth injection through the Secret Store.Yes, you make calls directly from your servers
Auth for external servicesYou 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 codeN/A, your code runs on StripeStripe signs each request. You verify the webhook signature.
Best forLogic that doesn’t need your own infrastructureLogic that needs your own systems, transforms, or non-TypeScript languages
LimitationsNo third-party dependencies, no raw fetch(), TypeScript onlyYou 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

Last verified 2026-09-24

Is this helpful?