Build a custom action with a remote functionPrivate preview
Create a workflow action using HTTP endpoints on your own infrastructure.
This guide describes how to build a custom action for Stripe Workflows using a remote function. Remote functions are HTTP endpoints that you host on your own servers. Stripe calls your endpoints when the action runs, signing each request so you can verify it came from Stripe.
Use remote functions when you need to run logic in your own environment, perform transforms on your servers, or use a language other than TypeScript.
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 start, 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 a server or hosting environment where you can deploy HTTP endpoints and:
| Prerequisite | Setup |
|---|---|
| Stripe account with access to the extensions private preview | If you don’t have access, sign up for early access. |
| Stripe CLI v1.12.4 or later, logged into your account | stripe version to check. Install or upgrade: brew upgrade stripe/stripe-cli/stripe |
| A sandbox (recommended for first-time setup) | Create one in the Dashboard if you don’t have one. |
| Node.js v22 or later | node --version |
| pnpm v10 (v11 is not supported) | pnpm --version |
| Stripe Apps CLI plugin v1.19.0 or later | stripe plugin install apps then confirm with stripe apps -v |
| Generate plugin v0.11.5 or later | stripe plugin install generate then confirm with stripe generate --version |
npm packages used by extensions
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. and stripe-app.. 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 extension point ID, an extension identifier, the implementation type, and a display name:
stripe generate extension extend.workflows.custom_action send-email remote-function --name "Send email"
This updates the stripe-app. manifest with your extension configuration, including placeholder endpoint URLs that you’ll replace with your actual server URLs.
Unlike script extensions, remote function extensions don’t generate TypeScript source files in src/. Your implementation lives on your own server. The generate command does create:
custom_for defining the action’s input fieldsinput. schema. json generated/directory with config schemas (auto-generated from yourConfiginterface duringpnpm build)
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 remote function extensions.
Grant the required permission
Custom actions require the workflow_ permission. This grants your extension access to workflow run data, including values from earlier steps that your execute endpoint receives. 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..
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. in your manifest:
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. 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. and configure your extension. For remote functions, each method maps to an endpoint URL that Stripe calls.
id: "com.example.send-email-app" name: "Send email" version: "0.0.1" declarations: distribution_type: private sandbox_install_compatible: true extensions: - id: "send_email" name: "Send email" interface_id: "extend.workflows.custom_action" version: "0.0.1" endpoints: - id: "send_email_execute" type: remote_function live: url: "https://api.example.com/execute" test: url: "https://api-test.example.com/execute" managed_sandbox: url: "https://api-sandbox.example.com/execute" - id: "send_email_get_form_state" type: remote_function live: url: "https://api.example.com/get_form_state" test: url: "https://api-test.example.com/get_form_state" managed_sandbox: url: "https://api-sandbox.example.com/get_form_state" methods: execute: implementation_type: "remote_function" endpoint_id: send_email_execute custom_input: input_schema: type: "json_schema" content: "extensions/send_email/src/custom_input.schema.json" ui_schema: type: "jsonforms" content: "extensions/send_email/src/custom_input.ui.schema.json" custom_output: output_schema: type: "json_schema" content: "extensions/send_email/src/custom_output.schema.json" get_form_state: implementation_type: "remote_function" endpoint_id: send_email_get_form_state errors: - code: setup_required message: "This app requires additional setup. Visit the app settings page to complete configuration."
Endpoint environments
Each endpoint can define URLs for different environments. Stripe calls the appropriate URL based on the mode of the account where the app is installed.
| Key | Environment | Description |
|---|---|---|
live | Live mode | Called when the app is installed on a live account. |
test | Test mode | Called when the app is installed on an account using legacy test mode. |
managed_ | Sandbox | Called when the app is installed on a sandbox account. |
You can define any combination of these depending on which environments your app supports. For early development, you might only define managed_ endpoints. For production apps, define live and one or both of test and managed_.
If any endpoint defines a managed_ URL, you must set sandbox_ in the declarations section. Without this, uploading fails.
Create the schemas
Define the input schema, UI schema, and optionally an output schema for your action. These are the same regardless of implementation type.
See action parameters for the full schema reference and supported types, and output values for output schema details.
Input schema (custom_):
{ "$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" } }, "required": ["audience_id", "template_id"], "additionalProperties": false }
UI schema (custom_):
The generate command creates custom_ (input schema) but not the UI schema. Create custom_ manually in the same directory, then reference it in your manifest under methods..
{ "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_):
If your action produces values that downstream workflow steps should reference, define an output schema. Create custom_ in the same directory and reference it in your manifest under methods..
{ "$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" } } }
Only fields declared in the output schema are visible to downstream steps. See output values for behavior details.
Implement get_form_state
For remote functions, Stripe sends an HTTP POST to your get_ endpoint. The request body contains the current form values.
See dynamic forms with get_form_state for the full request and response format.
Request from Stripe
interface GetFormStateRemoteRequest { id: string; // Unique call identifier type: "get_form_state"; context: string; // Account ID data: { values: { [fieldName: string]: any }; }; }
Verify the signature
Stripe signs every request to your endpoint. Verify the signature before processing:
const stripe = require("stripe")("sk_..."); const endpointSecret = "whsec_..."; app.post( "/get_form_state", express.raw({ type: "application/json" }), (request, response) => { const sig = request.headers["stripe-signature"]; try { stripe.webhooks.signature.verifyHeader(request.body, sig, endpointSecret); } catch (err) { return response.status(400).json({ code: "unable_to_verify_signature", message: err.message, }); } const requestBody = JSON.parse(request.body); const { values } = requestBody.data; // Build form state based on current values const result = buildFormState(values); response.json(result); }, );
Example implementation
function buildFormState(values) { // Fetch audience options (always populated) const audienceOptions = fetchAudiences(); // Fetch segments if an audience is selected let segmentOptions = []; let segmentDisabled = true; if (values.audience_id) { segmentDisabled = false; segmentOptions = fetchSegments(values.audience_id); } // Fetch templates and build dynamic schema const templateOptions = fetchTemplates(); let templateSchema = {}; let templateHidden = true; if (values.template_id) { templateHidden = false; const tmpl = fetchTemplate(values.template_id); if (tmpl) { const properties = {}; for (const tag of tmpl.mergeVars) { properties[tag] = { type: "string", title: formatFieldName(tag) }; } templateSchema = { type: "object", properties }; } } // Check for stale saved values const audienceValid = !values.audience_id || audienceOptions.some((a) => a.value === values.audience_id); let newValues = { ...values }; if (values.segment_id && !segmentOptions.some((s) => s.value === values.segment_id)) { newValues.segment_id = undefined; } // options and schema are required on every field config entry return { values: newValues, config: { audience_id: { options: audienceOptions, schema: {}, warning: audienceValid ? undefined : "Audience no longer exists.", }, segment_id: { options: segmentOptions, schema: {}, disabled: segmentDisabled, }, template_id: { options: templateOptions, schema: {}, }, template_variables: { options: [], schema: templateSchema, hidden: templateHidden, }, }, }; }
Handle errors
When your get_ implementation encounters a problem that prevents the entire form from loading (for example, the app requires setup that hasn’t been completed), return an error object with a code that matches an error declared in your manifest.
if (!isSetupComplete) { return response.json({ error: { code: "setup_required", message: "Setup check failed" } }); }
Implement execute
The execute method runs when the workflow triggers your action. Stripe sends an HTTP POST to your execute endpoint with the values the user configured. If your manifest declares an output schema, include a custom_ object in your response with the values downstream steps can reference.
Request from Stripe
interface ExecuteRemoteRequest { id: string; // Unique call identifier (use as idempotency key) type: "execute"; context: string; // Account ID data: { custom_input: { [fieldName: string]: any }; }; }
Implementation with idempotency
Because Stripe retries failed actions automatically, your execute endpoint must be idempotent. Use the request id to deduplicate.
// Use a database or Redis in production. This is for illustration only. const processedRequests = new Set(); app.post( "/execute", express.raw({ type: "application/json" }), (request, response) => { const sig = request.headers["stripe-signature"]; try { stripe.webhooks.signature.verifyHeader(request.body, sig, endpointSecret); } catch (err) { return response.status(400).json({ code: "unable_to_verify_signature", message: err.message, }); } const requestBody = JSON.parse(request.body); const { id, data } = requestBody; const customInput = data.custom_input; // Idempotency check. Skip if already handled. if (processedRequests.has(id)) { return response.json({}); } // Perform the action const result = sendEmail(customInput); // Record the request as processed processedRequests.add(id); response.json({ custom_output: { messages_sent: result.messageCount, campaign_id: result.campaignId, delivery_successful: result.success, }, }); }, );
Timeouts and retries
Each call to your execute endpoint has a 20-second timeout. If your endpoint doesn’t respond within 20 seconds, Stripe treats the call as failed and retries it.
Stripe retries failed actions automatically based on the HTTP status code your endpoint returns:
- Timeouts (no response within 20 seconds) are retried.
- 5xx errors (server errors) are retried.
- 4xx errors (client errors) are not retried. Use these for permanent failures.
Return the appropriate status code to signal whether a failure is transient or permanent:
| Status | Meaning | Retried? |
|---|---|---|
| 200 | Success | No |
| 4xx | Permanent failure (bad input, invalid config) | No |
| 5xx | Transient failure (service down, timeout) | Yes |
Because retries happen automatically, your execute implementation must be idempotent. Every request from Stripe includes an id field that stays the same across retries that you can use as an idempotency key (see the execute implementation above).
Your endpoint doesn’t need its own async job processing. If your work fits within 20 seconds, do it synchronously and return a success or error response. Stripe handles the orchestration, scheduling, and retries around your action. If you return 200 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.
Retrieve your signing secret
After you upload your app and Stripe creates the event destinations for your endpoints, retrieve the signing secrets from the Dashboard. Use the signing secret to verify the stripe-signature header on incoming requests.
For details on signature verification, see verify webhook signatures.
Deploy your endpoints
Deploy your execute and get_ endpoints to your hosting environment. Make sure:
- Both endpoints are reachable at the URLs specified in your manifest.
- Each endpoint verifies the Stripe signature before processing requests.
- The
executeendpoint responds within 20 seconds. - The
get_endpoint responds within 500ms for a good configuration experience.form_ state
Build and upload
Build, lint, and test your app before uploading:
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 loginand upload and install the app there.
- To install the app in another sandbox: switch to the other sandbox by using
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.
Install and add to a workflow
After you install the app:
- Go to Workflows in the Dashboard.
- Open an existing workflow or create a new one.
- Click Add action, then find your custom action under Apps in the action menu.
- Configure the action’s parameters using the dynamic form.
- 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.
- Make sure your
get_andform_ state executeendpoints are running and reachable at the URLs in your manifest. - Install the app on a sandbox account.
- In the sandbox Dashboard, go to Workflows and create a test workflow using your custom action.
- Configure the action: verify that dynamic dropdowns populate correctly and field states update as expected.
- Trigger the workflow and confirm your action executes successfully.
- Use Workbench to inspect the calls to your endpoints, including request and response payloads and any errors. Select the Webhooks tab to see the call history.
Once your action works in the sandbox, you can install the app on a live account and repeat the same steps to verify.
Debug your endpoints
Use Workbench in the Stripe Dashboard to view all calls to your remote function endpoints:
- All calls to your
get_andform_ state executeendpoints - Request and response payloads
- Error rates and response times
- Failed calls with detailed payloads for debugging
Select the Webhooks tab in Workbench to see the call history.