Stripe | Financial Infrastructure to Grow Your Revenue

Stripe | Financial Infrastructure to Grow Your Revenue

4466 articles

Build a custom action with a script


Private preview

Build a custom action with a script Private preview

Create a workflow action using TypeScript on Stripe's managed runtime.

This guide describes how to build a custom action for Stripe Workflows using a script. Scripts run TypeScript on Stripe’s managed runtime. Stripe handles secret storage and egress authentication for external API calls.

The example in this guide builds a “Send email” action that calls an external email service when a workflow triggers. The action uses dynamic forms to let users select an audience, template, and segment at configuration time.

Get early access to extensions

Enter your email to request access.

Before you begin

Before you begin, read how custom actions work to understand the methods, schemas, and runtime behavior that apply to all custom actions regardless of implementation type.

Also, make sure that you have:

PrerequisiteSetup
Stripe account with access to the extensions private previewIf you don’t have access, sign up for early access.
Stripe CLI v1.12.4 or later, logged into your accountstripe version to check. Install or upgrade: brew upgrade the relevant part of the product
A sandbox (recommended for first-time setup)Create one in the Dashboard if you don’t have one.
Node.js v22 or laternode --version
pnpm v10 (v11 is not supported)pnpm --version
Stripe Apps CLI plugin v1.19.0 or laterstripe plugin install apps then confirm with stripe apps -v
Generate plugin v0.11.5 or laterstripe plugin install generate then confirm with stripe generate --version

npm packages used by extensions

Script runtime behavior

Most TypeScript features work on Stripe’s runtime, but the following patterns aren’t available. The build and upload steps catch these automatically:

  • Code evaluation such as eval() or new Function()
  • Timer functions such as setTimeout() , setInterval() , and setImmediate()
  • Global scope access via global or globalThis
  • Process APIs such as process. exit() or process. env
  • console. log() . Use Workbench to view script run logs.
  • Embedded API keys or secrets. Use the Stripe secret store to manage sensitive values.
  • Network access APIs such as fetch() . Use endpointFetch() to invoke endpoints from a script .

Stripe doesn’t currently support third-party libraries as dependencies.

Create an app

Extensions are packaged within Stripe Apps. If you don’t already have an app, create one to contain your extension:

stripe generate app helloworld
cd helloworld

This creates the app with the workspace layout needed for extension development. Follow the prompts by entering the following information:

  • ID : Accept the auto-generated app ID or create a custom one. Stripe identifies your app using this ID. Your app ID must be globally unique. You can’t change this after you first upload your app.
  • Display name : Enter a display name. This is the name the Dashboard displays for your app. You can change the name later.

App directory file structure

Migrate an existing app

If you have an existing app created with stripe apps create, migrate it first:

cd my-existing-app
stripe apps migrate

After migration, you’ll see both stripe-app.json and stripe-app.yaml. The YAML file is the manifest file and is now the source of truth.

Generate the extension

Generate a custom action extension from your app directory. The command takes the following arguments:

  • Extension point ID : Specifies the type of extension point to generate ( extend. workflows. custom _ action ).
  • Extension identifier : An identifier you provide ( send-email ).
  • Implementation type : Specifies whether the extension is a script or a remote function ( script ).
  • Display name : The label that appears for the extension ( --name "Send email" ).
stripe generate extension extend.workflows.custom_action send-email script --name "Send email"

This generates a complete workspace with TypeScript configuration, linting, testing, and a starter script implementation:

your_app_directory/
├── extensions/
│ └── send-email/
│ ├── src/
│ │ ├── index.ts # Script implementation
│ │ ├── index.test.ts # Tests
│ │ └── custom_input.schema.json # Custom input JSON Schema
│ ├── generated/
│ │ ├── config.schema.json # Generated config schema
│ │ └── config.ui.json # Generated config UI schema
│ ├── eslint.config.mts
│ ├── package.json
│ ├── tsconfig.json
│ └── tsconfig.build.json
├── custom-objects/ # Custom data types (optional)
├── ui/ # UI extensions (optional)
├── tools/
│ └── test.mts # Cross-workspace test runner
├── stripe-app.yaml
├── package.json
├── eslint.config.mts
├── vitest.config.mts
└── pnpm-workspace.yaml

You implement your extension by editing files in the extension’s src/. Run pnpm build, pnpm lint, and pnpm test from the app root directory to compile, typecheck, and run unit tests across all workspaces.

Files in generated/ are auto-generated from your extension’s Config TypeScript interface when you run pnpm build. They control the Dashboard configuration UI for the extension installer. Don’t edit these files directly, modify your Config interface and rebuild instead.

The generate command also creates a custom-objects/ workspace for defining custom data types. See Custom objects for details. You can leave this workspace empty if you don’t use custom objects.

The ui/ workspace is for building UI extensions. You can leave it empty if your app only uses script extensions.

The root pnpm test command runs tools/test.mts, which discovers and runs tests across all workspaces, using vitest for extensions and jest for UI views.

Grant the required permission

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.

Generate the extension first

Run the permission grant after generating the extension. The stripe generate extension command resets the permissions in stripe-app.yaml.

Grant the permission using the CLI:

stripe apps grant permission workflow_custom_action_run_write \
 "Runs custom actions in workflows and accesses data from earlier workflow steps"

This adds the permission under declarations.stripe_api_access in your app manifest stripe-app.yaml:

declarations:
 stripe_api_access:
 permissions:
 - permission: workflow_custom_action_run_write
 purpose: Runs custom actions in workflows and accesses data from earlier workflow steps

The declarations.stripe_api_access section controls which Stripe API permissions your app requests at install time. Account administrators who install your app see these permissions and must accept them.

Define the manifest

Open stripe-app.yaml and configure your extension. The manifest declares the extension, its methods, endpoints for external API calls, and schema file locations.

The generate command creates a minimal manifest without an endpoints section. If your script calls external APIs via endpointFetch(), add the endpoints section manually as shown below:

The manifest has two distinct schema sections under each extension:

  • methods. execute. custom _ input , the fields that appear in the workflow builder when a user configures this action step. You define these in custom _ input. schema. json .
  • configuration , app-level config set by the installer. This is auto-generated from your extension’s Config TypeScript interface via gen-schemas during pnpm build . Don’t edit the generated files directly.

Endpoints and egress

The endpoints section declares the external services your script calls. Each endpoint specifies:

  • id : A unique identifier you reference from your code.
  • type : custom _ http for script egress endpoints.
  • url : The base URL of the external service.
  • purpose : A human-readable description shown to users during app installation.
  • auth : How Stripe injects credentials into outgoing requests.

For the auth configuration, Stripe retrieves the secret from the Secret Store and injects it into each request automatically. In this example, Stripe adds the X-Api-Token header with the value stored under the email_api_token secret name.

You call these endpoints from your script using the global endpointFetch function, not fetch(), which isn’t available in the script runtime. endpointFetch is a global function injected by the Stripe runtime, you don’t import it.

const res = await endpointFetch({
 endpoint: "email_api", // matches endpoint id in manifest
 path: "/v1/templates", // appended to the endpoint URL
 method: "GET",
});
const data = JSON.parse(res.body!);

endpointFetch is available at runtime only (not in tests or local builds). It requires a matching endpoints entry in stripe-app.yaml, and Stripe automatically injects authentication credentials from the Secret Store.

On non-2xx responses, endpointFetch throws an error. To test code that uses endpointFetch, extract your business logic into pure functions and test those separately. You can’t call endpointFetch in unit tests.

See Invoke endpoints from a script for the full API reference, parameter table, error codes, and auth configuration options.

Create the schemas

Define the input schema, UI schema, and optionally an output schema for your action. The input schema controls what users configure in the workflow builder. The output schema defines what your action returns to downstream steps.

See action parameters for the full schema reference and supported types, and output values for output schema details.

Input schema ( custom_input.schema.json):

UI schema ( custom_input.ui.schema.json):

The generate command creates custom_input.schema.json (input schema) but not the UI schema. Create custom_input.ui.schema.json manually in the same directory, then reference it in your manifest under methods.execute.custom_input.ui_schema.

{
 "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" }
 }
 ]
}

Output schema ( custom_output.schema.json):

If your action produces values that downstream workflow steps should reference, define an output schema. Create custom_output.schema.json in the same src/ directory and reference it in your manifest under methods.execute.custom_output.output_schema.

Only fields declared in the output schema are visible to downstream steps. See output values for behavior details.

Implement get_form_state

The getFormState method powers dynamic form behavior in the workflow builder. It’s called on initial form load and whenever the user changes a field value.

See dynamic forms with get_form_state for the full request and response format.

Start with a minimal implementation that returns static options. This compiles, passes tests, and gives you a working baseline:

Once that’s working, replace the hardcoded options with dynamic data from your external service using endpointFetch(). The following example shows the full pattern with cascading dropdowns, dependent field clearing, and stale value handling:

The helper functions below ( fetchAudiences, fetchSegments, fetchTemplates, fetchTemplate, formatFieldName) use endpointFetch() to call your external email service through the endpoint declared in the manifest. This code won’t compile until you implement them. See invoke endpoints from a script for how to make these calls.

Handle errors

When your getFormState implementation encounters a problem that prevents the entire form from loading (for example, the app requires setup that hasn’t been completed), throw an Error with a code property that matches an error declared in your manifest.

async getFormState(request, _config, _context) {
 const isSetupComplete = await checkSetup();
 if (!isSetupComplete) {
 const err: Error & { code?: string } = new Error('Setup check failed');
 err.code = 'setup_required';
 throw err;
 }
 // ... normal form state logic
}

Implement execute

The execute method runs when the workflow fires. It receives the values the user configured in the form. If your manifest declares an output schema, return a custom_output object with the values downstream steps can reference.

async execute(
 request: Extend.Workflows.CustomAction.ExecuteCustomActionRequest,
 _config: Config,
 _context: Context
) {
 const input = request.customInput ?? {};

 // Use endpointFetch to call your external API
 const res = await endpointFetch({
 endpoint: "email_api",
 path: "/v1/send",
 method: "POST",
 body: JSON.stringify({
 audienceId: input.audience_id,
 templateId: input.template_id,
 variables: input.template_variables,
 }),
 });

 const result = JSON.parse(res.body!);

 return {
 custom_output: {
 messages_sent: result.messages_sent,
 campaign_id: result.campaign_id,
 delivery_successful: result.success,
 },
 };
}

Stripe automatically injects the API token from the Secret Store into the request based on the auth configuration in your manifest’s endpoints section.

Timeouts and retries

Each call to your execute method has a 30-second timeout. If your script doesn’t return within 30 seconds, Stripe treats the call as failed and retries it.

Stripe retries failed actions automatically:

  • Timeouts (no response within 30 seconds) are retried.
  • 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.

Because retries happen automatically, your execute implementation should be idempotent where possible. The same action can run more than once for the same workflow execution.

Your action doesn’t need its own async job processing. If your work fits within 30 seconds, do it synchronously and return the result. 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.

Set up secret storage

Your script needs access to third-party API tokens. Because each user installing your app has their own account with the external service, you need to:

  1. Build a settings UI where users enter their API token after installing your app.
  2. Store the token using the Stripe Apps Secret Store API .

The egress system automatically injects the stored secret into your API requests based on the auth configuration in your manifest’s endpoints section.

For implementation details, see:

Test your extension

Add or extend tests in index.test.ts. The tests below work with the minimal compilable example from the get_form_state section:

Run tests from the app root directory:

pnpm test

Run pnpm run dev from the extension directory for watch mode during development. Run pnpm build, pnpm test, and pnpm lint from the app root directory to build and validate the full project.

Build and upload

Build, lint, and test your app before uploading. If the build fails, see Script runtime behavior.

pnpm build
pnpm lint
pnpm test

Verify that you’re logged into the intended account from the Stripe CLI and the Dashboard. We recommend using a sandbox:

stripe login

This opens the Stripe Dashboard for authentication.

Upload your app’s source code to Stripe:

stripe apps upload
You are about to upload your app to Testing

Name: Acme Billing App
ID: com.example.acme-billing-app
Version: 0.0.1

✔ Built files
✔ Packaged files for upload
✔ Uploaded
🌐 Stripe needs to process your files before this version can be installed.

To see your upload, click Enter. (You can also go to Apps > Created apps in the Dashboard and click your app’s name and open the Versions tab.) When the review status is Ready to install, click Install and select where to install the app.

The installation location depends on where you uploaded the app:

  • If you uploaded the app to live mode, you can install it in any sandbox.
  • If you uploaded the app to a sandbox, you can install it in the same sandbox and in live mode.
  • To install the app in another sandbox: switch to the other sandbox by using stripe login and upload and install the app there.

Update the app and extension version numbers as needed. To update an extension version, you must also update the app version. Stripe recommends semantic versioning. App uploads to live accounts might require additional review by Stripe. To iterate faster, use a sandbox.

Upload status

After you upload your app, the Status field on the details page for Created apps displays values such as the following:

  • Processing : Stripe is processing your app before this version can be installed.
  • In review : Stripe is reviewing your app. Apps might remain in this state longer if you’re uploading to a live account or if your app makes HTTP requests to external endpoints .
  • Changes requested : Stripe has requested changes to your app. Review the issues and resolve them in your local build, then upload your app again.

Install and add to a workflow

After you install the app:

  1. Go to Workflows in the Dashboard.
  2. Open an existing workflow or create a new one.
  3. Click Add action , then find your custom action under Apps in the action menu.
  4. Configure the action’s parameters using the dynamic form.
  5. Publish the workflow.

Your custom action runs as part of the workflow like any built-in Stripe action.

Test in a sandbox

We recommend testing your custom action in a sandbox before using it in live mode.

  1. Install the app on a sandbox account.
  2. In the sandbox Dashboard, go to Workflows and create a test workflow using your custom action.
  3. Configure the action: verify that dynamic dropdowns populate correctly and field states update as expected.
  4. Trigger the workflow and confirm your action executes successfully.
  5. Use Workbench to inspect the script run details, including input and output arguments and any errors.

Once your action works in the sandbox, you can install the app on a live account and repeat the same steps to verify.

Handle runtime errors

In most cases, catch errors and provide fallback behavior. Throwing an exception halts the entire code execution associated with the script, so only throw when no other option exists.

Observe script runs

Use Workbench to see the details of script runs, such as run ID, input and output arguments, and whether any errors occurred. For more information, see View extension run details.

To receive notifications when an extension run fails, subscribe to the v2.extend.extension_run.failed event. Set up an event destination that subscribes to this event. You can also trigger a Workflow from this event.

See also

Last verified 2026-09-24

Is this helpful?