URL: https://help.jsworkflows.com/md/index.txt
JsWorkflows Documentation
Reference docs for building and operating JsWorkflows workflows on Shopify.
Choose your starting point
Core reference
---
URL: https://help.jsworkflows.com/md/ai-assistants/overview.txt
Create Workflows With AI Assistants
Connect ChatGPT, Claude, Codex CLI, or Claude Code to JsWorkflows with MCP so an AI assistant can write, validate, and create Shopify workflows.
JsWorkflows can connect to AI assistants through MCP, the Model Context Protocol. After connecting, an assistant such as ChatGPT, Claude, Codex CLI, or Claude Code can use JsWorkflows tools to help create and maintain workflows.
This is useful when you want to describe an automation in plain language and have the assistant write the workflow code, validate it, and create or update the workflow in JsWorkflows for review.
The AI assistant connects to the JsWorkflows app. It does not connect directly to your Shopify store through the JsWorkflows MCP connector.
Watch the walkthrough
This video shows how to connect ChatGPT to the JsWorkflows MCP server with OAuth, then use ChatGPT to create a demo workflow in JsWorkflows.
What the assistant can do
With the JsWorkflows MCP connector, an AI assistant can:
- search JsWorkflows documentation and API guidance
- list supported workflow trigger topics
- inspect sample webhook payloads
- write workflow code from a merchant or developer request
- validate workflow code before saving
- create new inactive workflows
- update existing workflows for review
- preserve existing trigger resources when updating an existing workflow
- list, read, and delete workflows when you ask it to
The assistant should still leave activation under your control. Review the workflow, required setup values, and any extra Shopify access scopes before turning it on.
Supported AI assistant options
You can connect JsWorkflows to:
- ChatGPT
- Claude Desktop or Claude.ai
- Codex CLI
- Claude Code
- other MCP-capable clients that support Streamable HTTP with OAuth
The in-app setup pages are available from:
- Create workflow → AI assistants
- Settings → AI integrations
Connect with OAuth
Use this OAuth MCP URL when adding JsWorkflows to an AI assistant:
https://mcp.jsworkflows.com/oauth/mcp
OAuth is the recommended connection method. Use the OAuth MCP URL exactly as shown. When the assistant or MCP client supports OAuth, leave bearer token and custom header fields empty.
1. Open the JsWorkflows setup page for the assistant you want to use.
2. Add JsWorkflows as a custom MCP connector using the OAuth MCP URL.
3. When the browser authorization page opens, generate a fresh one-time code in JsWorkflows.
4. Paste the one-time code into the authorization page.
5. Finish the authorization flow, then start a new AI conversation or restart the CLI session so the tools are loaded.
Note: Connection codes are one-time codes. Generate the code when the authorization page is open. If the code expires or you need to authorize again, generate a new code.
Codex CLI setup
Add JsWorkflows as a remote MCP server:
codex mcp add jsworkflows --url https://mcp.jsworkflows.com/oauth/mcp
codex mcp login jsworkflows --scopes workflows.manage
If Codex opens the browser authorization page during the add command, generate the JsWorkflows connection code at that time. If you authorize again later, generate a new code.
Claude Code setup
Add JsWorkflows as an HTTP MCP server:
claude mcp add --transport http jsworkflows https://mcp.jsworkflows.com/oauth/mcp
Claude Code will open the browser authorization page. Paste a freshly generated JsWorkflows connection code when prompted.
Other MCP clients
Use Streamable HTTP transport and the OAuth MCP URL:
https://mcp.jsworkflows.com/oauth/mcp
If the client supports OAuth, leave bearer token and custom authentication headers empty.
Add Shopify API tooling when available
Many JsWorkflows workflows call Shopify Admin APIs or need exact store resource IDs such as collection IDs, product IDs, publication IDs, location IDs, market IDs, or inventory item IDs.
For those workflows, the best result comes from using a Shopify connector or Shopify API tooling in the same AI assistant session, when available.
Official Shopify setup links:
- Connecting AI tools to Shopify (https://help.shopify.com/en/manual/apps/connecting-ai-tools) explains Shopify's AI tool options, including the Shopify apps for ChatGPT and Claude and the Shopify CLI connector for local AI tools.
- Shopify AI Toolkit Dev MCP server (https://shopify.dev/docs/apps/build/ai-toolkit#install-with-the-dev-mcp-server) explains how to add Shopify's Dev MCP server for Shopify API documentation and schema-aware development help.
Use Shopify tooling to:
- inspect the current Shopify Admin GraphQL schema
- avoid deprecated Shopify API fields
- validate final Shopify GraphQL queries and mutations
- resolve live store resource IDs from merchant-provided names, handles, SKUs, tags, or order numbers
- determine the explicit Shopify OAuth scopes required by the workflow
If Shopify tooling is not available, the assistant should not invent Shopify GIDs. Use one of these fallbacks instead:
- provide the exact Shopify GIDs yourself
- let the assistant create an inactive workflow with visible setup values in the workflow configuration
- keep placeholders in config.js with clear setup notes
Caution: Shopify schema validation and live store lookup are separate from the JsWorkflows MCP connector. The JsWorkflows MCP connector manages workflows in JsWorkflows. Shopify API validation and store lookup require Shopify tooling in the AI assistant, if your assistant supports it.
Recommended workflow process
Use AI assistants as a generate-and-review workflow, not as an automatic activation system.
1. Describe the automation you want.
2. Ask the assistant to use JsWorkflows tools and Shopify tooling, when available.
3. Review the generated workflow code, setup fields, notes, and required scopes in JsWorkflows.
4. Add any required Shopify scopes shown by JsWorkflows.
5. Run a test when the trigger supports testing.
6. Turn on the workflow only after review.
For existing workflows, ask the assistant to update the existing workflow. It should not create new trigger resources such as new webhook subscriptions, new HTTP endpoints, or new email trigger addresses when applying an update to an existing workflow.
Good prompts
Start with a precise prompt that tells the assistant what should happen, when it should run, and what data it should change or report.
Examples:
Create a JsWorkflows scheduled workflow that emails a daily summary of paid orders over $500.
Create a JsWorkflows Shopify webhook workflow that tags a product when all variants are out of stock. Use Shopify schema validation before creating it.
Review this existing JsWorkflows workflow for deprecated Shopify GraphQL fields, fix the code, validate it, and update the workflow.
Create a JsWorkflows email-trigger workflow that reads an attached CSV file and updates inventory in batches.
Create a JsWorkflows scheduled workflow that imports inventory from a public CSV URL and updates inventory in batches.
Use the Shopify connector to find the collection named Sale, then create a JsWorkflows workflow that adds matching products to that collection. Do not invent IDs if the connector is unavailable.
What to review before activation
Before turning on an AI-created workflow, check:
- the trigger type and trigger topic
- whether the workflow is inactive until you approve it
- any configuration values exposed in the setup form
- any config.js placeholders that still need real values
- additional Shopify scopes shown by JsWorkflows
- Shopify GraphQL queries and mutations, especially mutations that change store data
- retry and batching behavior for imports or large updates
- logs from a test run, when testing is available
For Shopify webhook workflows, duplicate webhook deliveries can happen. Use api.dedupe() when duplicate processing would cause duplicate writes, duplicate emails, or repeated side effects.
For HTTP-triggered workflows, dedupe is optional. Use it only when the incoming payload has a stable ID or when duplicate HTTP requests would be harmful.
Troubleshooting
The assistant cannot see JsWorkflows tools
Reconnect the MCP connector, complete OAuth again, then start a new conversation or restart the CLI session. Some assistants load tools only when a new session starts.
The authorization code expired
Generate a new one-time code in JsWorkflows and paste it into the authorization page again.
The assistant guesses Shopify GraphQL fields
Tell it to use Shopify schema validation or Shopify API tooling before saving. If Shopify tooling is unavailable, ask it to leave placeholders or setup notes instead of guessing.
The workflow needs store-specific IDs
Use an authorized Shopify connector to look up the IDs, or provide exact Shopify GIDs yourself. If neither is available, create an inactive workflow with clear setup fields or placeholders.
The conversation becomes too large
Start a fresh focused conversation for the workflow. For code-heavy work, Codex CLI or Claude Code can be useful because they can work from your local project files and keep the prompt focused on the workflow task.
Related docs
- Your first workflow (/getting-started/first-workflow/)
- Workflow steps (/getting-started/steps/)
- Workflow API overview (/workflow-api/overview/)
- Testing triggers (/triggers/test/)
- Templates (/templates/)
---
URL: https://help.jsworkflows.com/md/billing.txt
Billing & Credits
How JsWorkflows billing, credits, and plan usage work.
Credits are the billing unit
JsWorkflows uses credits as its usage unit.
Credits are consumed when workflows run. In general, workflows that run more often, use more steps, process more data, or fan out into more branches will consume more credits than simple workflows.
Note: Test runs triggered from the editor also consume credits and count toward your plan.
For the full plan and pricing breakdown, see Credits & Plans (/getting-started/credits/).
AI Assistant beta and billing usage
The AI Assistant is a beta feature available on the Growth, Business, and Enterprise plans.
AI assistant usage also consumes credits and counts toward the same billing usage as workflow execution.
For documentation and knowledge-base questions, use the AI assistant on this documentation website instead of the in-app AI assistant. The documentation-site assistant does not consume your in-app billing credits.
Workflow generation and workflow review are the most credit-intensive assistant actions. A simple workflow-generation request typically uses around 20,000 credits. Complex or incomplete requests can use much more if the assistant has to go through multiple validation or repair rounds.
Caution: For complex workflow setup, monitor AI assistant usage carefully. If the workflow is complex or high-stakes, contact support instead of relying on repeated generation retries.
How billing works
- Your base plan is billed through Shopify.
- Paid plans include a monthly credit allowance.
- On paid plans, included credits reset at the end of each billing cycle.
- Unused paid-plan credits do not roll over to the next cycle.
Free plan behavior
The Free plan works differently from paid plans:
- it includes 10,000 credits
- it has no overage
- its credits do not reset monthly
- once the credit limit is reached, new workflow triggers are blocked until you upgrade
Overage on paid plans
If you exceed the included credits on a paid plan:
- additional usage is billed as overage
- the overage rate depends on your plan
- overage is capped per billing cycle
- workflows continue running while overage billing is available
See Credits & Plans (/getting-started/credits/) for current included credits, overage rates, and caps.
View usage and manage your subscription
Go to Plans in the app to view:
- your current plan
- billing-cycle dates
- credits used this cycle
- credits remaining in the current plan allowance
- estimated overage and bill for paid plans
- subscription status
Cancelling or downgrading
You manage plan changes from Plans in the app.
If you cancel a paid subscription:
- the current paid plan remains active until the end of the billing period
- the app is then downgraded to the Free plan
- future usage follows the Free plan limits unless you subscribe again
Need more detail?
Use these pages together:
- Credits & Plans (/getting-started/credits/) for plan sizing, pricing, and credit behavior
- Templates (/templates/) to understand which templates are heavier operationally and may consume more credits
Tip: If you are unsure which plan fits your store, start with a lower plan and monitor real usage in Plans. Actual workflow behavior is a better guide than estimating from trigger count alone.
---
URL: https://help.jsworkflows.com/md/faq.txt
FAQ
Common questions about JsWorkflows.
What is JsWorkflows?
JsWorkflows is a Shopify-first automation platform for store operations. You can start from production-ready templates or build custom workflows in JavaScript for orders, inventory, fulfillment, customers, reporting, imports, syncs, and connected external services.
Do I need to be a developer?
Not always, but some workflows are easier if you are comfortable reading or editing JavaScript.
If your use case is close to an existing template, you may only need to fill in setup values. If your logic is highly custom, developer-level comfort will help.
Is there a free trial?
The app starts on the Free plan by default. You can install it, explore the workflow builder, test templates, and upgrade later from the Plans page.
See Billing & Credits (/billing/) and Credits & Plans (/getting-started/credits/) for plan details.
How many workflows can I have?
The Free plan supports up to 10 active workflows.
Paid plans do not have a workflow-count limit. Usage is controlled by credits, not by how many workflows exist.
How are credits consumed?
Credits are consumed when workflow steps run.
Usage usually increases when a workflow:
- runs more often
- uses more steps
- fans out into multiple branches
- processes larger datasets
- performs more follow-up work over time
Test runs from the editor also consume credits.
For higher plans, the in-app AI Assistant also consumes credits. For documentation questions, use the AI assistant on this documentation site instead because it does not consume in-app billing credits.
Should I start from a template or build from scratch?
Start from a template when the workflow is already close to what you need.
Build from scratch when:
- your business logic is highly specific
- you need unusual API calls
- you need a custom external integration
- the template would require major rewriting anyway
See Templates (/templates/) for more.
Can I edit a template after installing it?
Yes. Installing a template creates your own workflow copy. You can inspect the code, edit it, test it, and activate it like any other workflow.
Template catalog updates do not automatically change workflows you already installed.
What trigger types are supported?
JsWorkflows supports:
- Shopify webhooks
- Shopify Flow triggers
- scheduled triggers
- HTTP triggers
- email triggers
- manual test runs from the editor
See the Triggers (/triggers/shopify-webhook/) section for the full breakdown.
Do inactive workflows still run?
No. Inactive workflows do not process incoming events.
For example, inactive Shopify webhook workflows ignore webhook deliveries while they are turned off.
Can a workflow call an external API?
Yes. Use the global fetch() inside your workflow code.
For Shopify Admin API calls to your store, authentication is handled automatically. For other services, use either:
- api.getOAuthToken(handle) for connected OAuth services
- env.* secrets for non-OAuth credentials
See fetch() (/workflow-api/fetch/), OAuth (/oauth/overview/), and Secrets (/workflow-api/secrets/).
Can one workflow call another workflow?
Not directly as an internal function call, but you can call an HTTP-trigger workflow from another workflow by sending a request to its HTTP trigger URL with fetch().
What happens if a step throws an error?
If a step throws an uncaught error, the run is marked failed and the error appears in the Runs view.
If your workflow catches the error itself, schedules a retry, or continues with fallback logic, the run may continue instead of failing immediately.
Can I connect the same OAuth service more than once?
Yes. Each connection has its own handle, so you can connect multiple accounts or workspaces for the same service.
Your workflow code then chooses which connection to use with api.getOAuthToken('your-handle').
When should I use the in-app AI Assistant?
Use the in-app AI Assistant for:
- workflow generation
- workflow review
- store-specific workflow questions
Use the documentation-site AI assistant for:
- documentation lookup
- API usage questions
- knowledge-base questions
This matters because the in-app AI Assistant consumes billing credits, while the documentation-site assistant does not.
For complex or business-critical workflow setup, contact support instead of relying on repeated AI generation retries.
How do I get help?
For product or workflow support, email support@jsworkflows.com (mailto:support@jsworkflows.com).
For documentation questions, use the AI assistant on this documentation website first.
---
URL: https://help.jsworkflows.com/md/getting-started/credits.txt
Credits & Plans
How JsWorkflows credits and plans work.
What is a credit?
A credit is the usage unit for JsWorkflows.
Credits are consumed when workflows run. In general, workflows that run more often, use more steps, process more data, or fan out into many branches will consume more credits than simple single-step workflows.
Credits are meant to reflect real platform usage, not arbitrary feature counters.
Note: Test runs triggered from the editor also consume credits and count toward your plan.
What affects credit usage?
Credit usage usually increases when a workflow:
- triggers frequently
- uses multiple steps
- fans out into many parallel branches
- processes larger datasets
- performs more follow-up work over time
This means two workflows triggered the same number of times can still use very different amounts of credits depending on how much work they actually do.
AI Assistant beta usage
The AI Assistant is a beta feature available on the Growth, Business, and Enterprise plans.
AI assistant usage also consumes credits and counts toward the same billing usage as workflow runs.
For documentation and knowledge-base questions, use the AI assistant on this documentation website instead of the in-app AI assistant. The documentation-site assistant does not consume your in-app billing credits.
The heaviest assistant actions are usually:
- workflow generation
- workflow review
- repeated repair or validation rounds after an incomplete or ambiguous request
A simple workflow-generation request typically uses around 20,000 credits. Complex workflow requests can use significantly more, especially when the assistant has to retry validation or repair the result across multiple rounds.
Caution: For complex workflow setup, use the AI assistant carefully and monitor credit usage in the app's Plans page. If the workflow is complex or business-critical, contact support instead of relying on multiple generation retries.
Which plan is right for me?
The right plan depends more on the kind of workflows you run than on trigger count alone.
Free
Best for exploring the platform, testing workflows, and learning how templates and custom workflows work in your store.
Starter
Good for light operational automation such as simple webhook-driven actions, notifications, or a small number of low-volume workflows.
Growth
A better fit when your store runs more frequent workflows, more multi-step workflows, or workflows that connect to external systems and do more work per run.
Business
Designed for stores with heavier operational automation, higher workflow volume, and more demanding multi-step or branching workflows.
Enterprise
For large-scale operations where workflows regularly process large datasets, fan out heavily, or support significant operational workloads across the store.
Tip: If you are unsure, start lower and monitor real usage in the app's Plans page. Actual usage is a better guide than guessing up front.
Plans
| Plan | Price | Workflows | Credits / cycle | Overage | Overage cap |
| Free | $0 | 10 | 10,000 | Not available | — |
| Starter | $25/mo | Unlimited | 500,000 / month | $0.015 per 1,000 credits | $50/mo |
| Growth | $49/mo | Unlimited | 2,500,000 / month | $0.01 per 1,000 credits | $150/mo |
| Business | $99/mo | Unlimited | 8,000,000 / month | $0.01 per 1,000 credits | $500/mo |
| Enterprise | $199/mo | Unlimited | 20,000,000 / month | $0.01 per 1,000 credits | $2,000/mo |
- Workflows means the maximum number of active workflows at one time. Paid plans have no workflow limit.
- Credits / cycle means the included usage allowance for the plan.
- Overage means additional usage billed after the included credits are exceeded.
- Overage cap means the maximum additional charge in a single billing cycle.
- AI Assistant beta is available on Growth, Business, and Enterprise.
- Free workflow setup is included with all paid plans.
Caution: The Free plan has no overage. Once the 10,000-credit limit is reached, new workflow triggers are blocked until you upgrade. Free plan credits do not reset monthly.
How billing works
Your base plan is charged through Shopify.
On paid plans, if you exceed the included credits, overage is billed at your plan's overage rate up to the plan's cap. Workflows continue running while overage billing is available.
Unused paid-plan credits do not roll over to the next billing cycle.
---
URL: https://help.jsworkflows.com/md/getting-started/first-workflow.txt
Your First Workflow
Build a simple real workflow and learn the step pattern used throughout JsWorkflows.
This guide builds a simple workflow from scratch so you can learn the basic JsWorkflows pattern before moving on to more advanced templates and operational workflows.
If you prefer, you can also start from a template instead of writing your first workflow yourself. This page focuses on the from-scratch path so you can understand how workflow steps are structured.
Watch the walkthrough
This video follows the same example workflow used in this guide: creating an orders/paid workflow, scheduling a follow-up step, testing the workflow, and checking the run output.
What we're building
A workflow that:
1. receives an orders/paid webhook
2. schedules a follow-up step for 5 minutes later
3. sends a Slack notification with the order details
This is a simple example, but it teaches a pattern you will use in more advanced workflows too, including approvals, operational checks, delayed follow-up, and multi-step automations.
Why split work into steps
For Shopify webhook workflows, JsWorkflows acknowledges the incoming webhook before your workflow code runs. Your start step is not responsible for sending the HTTP response back to Shopify.
Even so, it is usually best to keep start focused on orchestration:
- validate the incoming data
- prepare the payload for later steps
- hand the real work off to another step
This matters because:
- start is the reserved entry point for a run
- start cannot be scheduled again with api.scheduleNextStep()
- delayed or retryable work should happen in later named steps
- scheduled steps run later as separate workflow executions
That makes workflows easier to recover, retry, extend, and reason about when something goes wrong downstream.
The workflow code
Create a new workflow in the editor with the orders/paid trigger, then replace the code with:
export class Workflow {
/*
Entry step.
Keep this focused on validation, payload preparation, and scheduling.
/
async start(data, headers, api) {
const orderId = data.id;
const orderName = data.name;
const total = data.totalprice;
const currency = data.currency;
await api.scheduleNextStep({
delay: '5 min',
action: 'notifySlack',
payload: { orderId, orderName, total, currency },
});
console.log(Queued Slack notification for ${orderName});
}
/*
Follow-up step.
This is where delayed or retryable work should happen.
/
async notifySlack({ orderId, orderName, total, currency }, headers, api) {
const { token, error } = await api.getOAuthToken('my-slack');
if (error | | !token) {
console.log('Could not get Slack token:', error | | 'Missing Slack token');
return;
}
const res = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: Bearer ${token},
},
body: JSON.stringify({
channel: 'C0123456789',
text: New paid order ${orderName} — ${total} ${currency},
}),
});
if (!res.ok) {
console.log(Slack HTTP ${res.status}:, await res.text());
return;
}
const json = await res.json();
if (!json.ok) {
console.log('Slack API error:', json.error | | 'unknownerror');
return;
}
console.log(Notified Slack for order ${orderName});
}
}
In every step, the third argument api gives your workflow access to JsWorkflows platform helpers such as scheduling, OAuth tokens, logging, and state.
In this example:
- api.scheduleNextStep() queues the later Slack step
- api.getOAuthToken('my-slack') loads the access token for a saved Slack OAuth connection
The string 'my-slack' is the OAuth handle. You create it when you connect Slack in JsWorkflows, then reuse that handle in your workflow code.
To create that Slack connection:
- go to Settings → OAuth2 Tokens, or
- in the workflow editor open More actions → Manage OAuth2 tokens
Then connect Slack and give the connection a handle such as my-slack. Your workflow code uses that handle later with api.getOAuthToken(...).
How it works
The start step:
1. reads the fields needed from the webhook payload
2. schedules notifySlack for 5 minutes later
3. exits after the follow-up work has been queued
The notifySlack step:
1. gets the Slack OAuth token by handle
2. posts the message to Slack using the channel ID
3. logs the result
The scheduled step runs later as a separate execution. It is not the original start step waiting in memory for 5 minutes. The payload passed to api.scheduleNextStep() is what the later step receives when it starts.
This same pattern scales into more advanced workflows:
- delayed follow-up checks
- approval and review flows
- fan-out processing
- retryable external API operations
For Shopify webhook workflows, JsWorkflows already deduplicates duplicate webhook deliveries before your workflow code runs. You would typically use api.dedupe() yourself only when you need business-level deduplication for your own workflow logic.
Test it step by step
1. In the workflow editor, open More actions → Test data. This shows the Request body and Request headers, both of which are editable.
For Shopify webhook triggers, the request body is pre-filled with the topic's sample payload. You can also inspect the sample at any time through More actions → Sample payload.
2. For a faster first test, temporarily change delay: '5 min' in the code to delay: 10. That is the minimum allowed delay and lets you verify the full flow quickly before switching it back to 5 minutes.
3. Leave the sample payload as-is, or adjust a few fields such as the order name, total, or currency if you want to make the test output easier to recognize.
4. Click More actions → Run test.
The workflow starts from start exactly as a live run would. The notifySlack step is then scheduled and will run after the configured delay.
5. Watch the Live Output panel below the editor. It shows step executions in real time, including status, duration, step name, retries, and payload size.
Caution: Test runs behave like live runs. Credits are consumed, and fetch() calls hit real endpoints. Use a test Slack channel while learning and testing.
Enable it
When you are ready, toggle the workflow Active.
From that point on, every orders/paid event that matches the workflow trigger can start a run.
What to learn from this page
Your first workflow does not need to be complicated. The important thing is to learn the basic structure:
- start is the reserved entry step
- start is best used for validation, payload preparation, and scheduling
- later steps do the delayed, retryable, or external work
Once that pattern is clear, the rest of the platform becomes much easier to understand.
---
URL: https://help.jsworkflows.com/md/getting-started/installation.txt
Installation
How to install JsWorkflows on your Shopify store.
Install from the Shopify App Store
1. Open the JsWorkflows Shopify App Store listing: https://apps.shopify.com/jsworkflows (https://apps.shopify.com/jsworkflows).
2. Review the Shopify permissions requested by the app. These permissions allow JsWorkflows to run supported Shopify workflows and respond to the events and resources your store uses.
3. Approve the installation. Shopify opens JsWorkflows automatically after the install completes.
4. Start on the Free plan by default. After installation, you can explore the app, test workflows, and upgrade later from the app's Plans page if needed.
What to do after installation
After the app is installed, the next step is usually one of these:
- start from a template
- create a workflow from scratch
Depending on the workflow you build, you may also need to:
- add workflow variables or secrets
- connect an OAuth service such as Google or Slack
- grant additional Shopify scopes required by a specific template or workflow
- run a manual test before turning the workflow on
For most stores, the simplest path is:
1. install the app
2. open a template
3. fill in the setup form
4. run a test
5. activate the workflow
Permissions and scopes
JsWorkflows uses Shopify permissions in two layers:
| Type | What it means |
| Base app permissions | Permissions granted when you install the app so core workflow features can run |
| Additional workflow scopes | Extra Shopify scopes requested only when a specific template or workflow needs them |
This is important because not every workflow needs the same level of Shopify access.
Examples:
- a simple webhook or notification workflow may only need the app's base permissions
- an inventory, fulfillment, or publishing workflow may require extra Shopify scopes
- template setup will tell you when additional scopes are required
Why this matters
JsWorkflows is designed for a wide range of Shopify operational workflows. That means the app should not request every possible permission up front if a store does not need it.
Instead:
- you install the app with its base permissions
- you grant additional workflow-specific scopes only when needed
Uninstalling
Uninstalling the app from Shopify immediately stops all workflows and triggers.
If you reinstall later, follow the normal installation flow again.
---
URL: https://help.jsworkflows.com/md/getting-started/overview.txt
Overview
What JsWorkflows is and how it works.
JsWorkflows is a Shopify-first automation platform for merchants and teams that need more control over store operations.
It is built for workflows that solve real operational problems across catalog, inventory, orders, fulfillment, customers, reporting, exception handling, and external system coordination.
JsWorkflows is not a visual drag-and-drop rule builder. It combines production-ready templates with editable JavaScript workflows, so you can start with a working solution and still adapt the logic to your store as requirements grow.
When JsWorkflows is a good fit
JsWorkflows is a strong fit when your store needs workflows that are:
- focused on store operations and custom business logic, not just generic notification or marketing flows
- specific to your business rules
- too complex for simple rule builders
- connected to Shopify data and external systems
- dependent on review, approval, or other human decision points
- easier to maintain as code than as a large visual flow
Common examples include:
- reacting to Shopify events with store-specific business logic
- scheduled reporting, audits, and operational checks
- catalog and merchandising workflows
- inventory, order, and fulfillment exception handling
- customer, returns, and risk-related workflows
- imports, syncs, and reconciliation jobs
- sending data or actions to connected external services
If you only want simple no-code recipes with a visual canvas, JsWorkflows may not be the best fit. It is built for workflows where flexibility, control, and operational depth matter more than drag-and-drop simplicity.
Templates and custom workflows
JsWorkflows supports both templates and fully custom workflows across a wide range of Shopify operational use cases.
Templates are production-ready starting points for common operational tasks. They can include:
- configuration fields
- setup instructions
- starter workflow code
- tested patterns for a specific use case
After you save a template workflow, you can still inspect and edit the generated code.
This is one of the main strengths of the platform:
- start from a template when you want speed
- edit the workflow when your store needs something more specific
The template library is intended to cover practical store operations workflows, not just simple demos or narrow connector examples.
You can also create a workflow entirely from scratch if you already know the logic you want to build.
How workflows run
Under the hood, workflows are written as code. Each workflow is a JavaScript class. You define one or more step methods on the class, while JsWorkflows handles triggering, scheduling, execution, run tracking, and connections to external services.
export class Workflow {
async start(data, headers, api) {
// your code runs here
}
async nextStep(data, headers, api) {
// scheduled steps run here
}
}
Every step method receives three arguments:
| Argument | Description |
| data | The trigger payload, such as an order, customer, HTTP body, or scheduled run metadata |
| headers | The HTTP headers from the trigger request |
| api | The JsWorkflows API for fetch, scheduling, state, OAuth, secrets, logging, and platform helpers |
Core concepts
| Concept | Description |
| Workflow | A JavaScript class that defines your automation logic |
| Step | A method on the workflow class |
| Trigger | What starts a run, such as a Shopify webhook, HTTP request, inbound email, schedule, or manual test |
| Run | One full execution of a workflow from trigger to completion |
| Template | A workflow starter with configuration and prebuilt code |
| Variable / Secret | Per-workflow settings and secrets used by your code |
| OAuth handle | The name of a connected external service used with api.getOAuthToken() |
| Credit | The billing unit, with one credit used per step execution |
Execution model
- The start method is always the entry point.
- To continue the workflow later, call api.scheduleNextStep().
- Multiple scheduleNextStep() calls in one step create parallel branches.
- A run is only marked complete when all scheduled branches have finished.
- Steps that use api.waitForEvent() pause the run until an external signal resumes it.
This execution model is especially useful for:
- scheduled operations
- review and approval workflows
- delayed follow-up actions
- fan-out workflows that process many records safely
It is especially useful for operational workflows that need retries, batching, delayed follow-up, fan-out processing, or coordination across multiple steps.
It also supports human-in-the-loop workflows, where a run pauses until someone reviews a case, approves a change, or sends the next signal that lets the workflow continue.
Workflow lifecycle hooks
Two optional methods can be defined on the Workflow class:
export class Workflow {
async start(data, headers, api) { ... }
async onWorkflowComplete(api) {
// Called when the entire run finishes successfully
}
async onWorkflowError(err, api) {
// Called when a step throws an uncaught error
}
}
Both hooks receive a restricted api: getOAuthToken, google, log, dedupe, and runStore are available. scheduleNextStep, waitForEvent, and csv are not.
---
URL: https://help.jsworkflows.com/md/getting-started/steps.txt
Steps & Workflow Design
What steps are, when to use them, fan-out patterns, and best practices for reliable workflows.
Steps are how JsWorkflows breaks a workflow into reliable, independent pieces of work.
Designing your workflow around clear steps is one of the main differences between a workflow that is easy to maintain and recover, and one that becomes hard to retry, extend, or reason about.
What a step is
A step is a single method on your Workflow class. Each time a step runs, it is a fresh, independent execution with:
- its own execution context
- no shared in-memory state between steps
- full access to the platform API (api.scheduleNextStep, api.runStore, and more)
- its own data, headers, and api arguments
When api.scheduleNextStep() is called, the current step exits normally. After the specified delay, the next step is dispatched as a separate execution, not a continuation of the same process.
Why steps exist
Real workflows often need to:
- Wait — send a follow-up message later, check a status after a delay, or resume after an external event
- Spread load — process many records without forcing everything into one execution
- Run in parallel — let independent work happen in separate branches
- Separate orchestration from work — validate and queue in start, then do the delayed, retryable, or external work in later steps
All of these are solved by splitting the work across steps.
How start should be used
start is the reserved entry point for every workflow run.
In many workflows, start is best used for:
- validating the incoming payload
- preparing the payload for later steps
- scheduling the next step
- deciding which branch or path should run next
That is usually the right design because start cannot be scheduled again with api.scheduleNextStep(). If work may need to be delayed, retried, or broken into smaller units, it should usually live in later named steps.
Trigger-specific behavior
start behaves a little differently depending on the trigger type:
- Shopify webhook triggers: JsWorkflows acknowledges the webhook before your code runs. Your start step is not responsible for sending the HTTP response back to Shopify.
- HTTP triggers: start can return the HTTP response. If it returns a Response, that response is sent directly. If it returns a plain object or value, JsWorkflows returns it as JSON. If it returns nothing, the platform returns { "success": true }.
- Email triggers: start receives the parsed email as data (sender, subject, body, and attachment descriptors). Attachments are not passed directly; use api.getAttachment() in a later retryable step to read the file content.
- Scheduled triggers: start is simply the entry point for the scheduled run.
A good start step
async start(data, headers, api) {
if (!data.id) return;
await api.scheduleNextStep({
delay: '30 sec',
action: 'processOrder',
payload: { orderId: data.id },
});
}
Linear chains
The simplest multi-step pattern is a linear chain: start → stepA → stepB → done.
export class Workflow {
async start(data, headers, api) {
await api.scheduleNextStep({
delay: '1 day',
action: 'sendFollowUp',
payload: { orderId: data.id, email: data.email },
});
}
async sendFollowUp({ orderId, email }, headers, api) {
await api.scheduleNextStep({
delay: '7 days',
action: 'sendSecondFollowUp',
payload: { orderId, email },
});
}
async sendSecondFollowUp({ orderId, email }, headers, api) {
// Runs later as a separate execution
}
}
Each step only needs to know about the next one. Steps do not share memory, so pass everything the next step needs in payload.
Fan-out (parallel branches)
Call api.scheduleNextStep() multiple times in one step to launch parallel branches. Each call starts an independent execution of the target method. The run is not marked complete until all branches finish.
export class Workflow {
async start(data, headers, api) {
for (const item of data.lineitems) {
await api.scheduleNextStep({
delay: '30 sec',
action: 'checkLineItem',
payload: { itemId: item.id, sku: item.sku, quantity: item.quantity },
});
}
}
async checkLineItem({ itemId, sku, quantity }, headers, api) {
console.log(Processing item ${sku} ×${quantity});
}
}
Fan-out is useful when each item can be processed independently.
Collecting results from fan-out branches
Use api.runStore.push() to accumulate results from parallel branches, then read them later in a consolidation step or lifecycle hook:
export class Workflow {
async start(data, headers, api) {
for (const item of data.lineitems) {
await api.scheduleNextStep({
delay: '30 sec',
action: 'checkLineItemStock',
payload: { itemId: item.id, sku: item.sku },
});
}
}
async checkLineItemStock({ itemId, sku }, headers, api) {
const res = await fetch(https://your-inventory-api.com/stock/${sku});
const { quantity } = await res.json();
await api.runStore.push('stockResults', { sku, quantity, inStock: quantity > 0 });
}
}
The run state (api.runStore) is scoped to the current run and shared across all branches.
A later step or lifecycle hook can read stockResults and use it to build a summary, trigger a follow-up action, or write a report.
Using steps to control large workloads
If your workflow processes large lists, doing all the work in one step can create throttling, retries, or hard-to-debug failures. Steps let you spread that work across time or across parallel branches.
Spread work across staggered steps
async start(data, headers, api) {
const items = data.lineitems;
for (let i = 0; i < items.length; i++) {
await api.scheduleNextStep({
delay: ${30 + i} sec,
action: 'updateItem',
payload: { itemId: items[i].id },
});
}
}
Page through large result sets across steps
This is a more advanced example. Focus on the step pattern: fetch one page, process it, then schedule the next page only if more data remains.
async start(data, headers, api) {
await api.scheduleNextStep({
delay: '30 sec',
action: 'fetchPage',
payload: { cursor: null, pageNum: 1 },
});
}
async fetchPage({ cursor, pageNum }, headers, api) {
const res = await fetch(
https://${env.SHOPIFYSTORE}/admin/api/${env.SHOPIFYAPIVERSION}/graphql.json,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query GetProducts($cursor: String) {
products(first: 50, after: $cursor) {
nodes { id title }
pageInfo { hasNextPage endCursor }
}
},
variables: { cursor },
}),
}
);
const { data: gqlData } = await res.json();
const { nodes, pageInfo } = gqlData.products;
// process this page
if (pageInfo.hasNextPage) {
await api.scheduleNextStep({
delay: '30 sec',
action: 'fetchPage',
payload: { cursor: pageInfo.endCursor, pageNum: pageNum + 1 },
});
}
}
Idempotency
Design every step so it is safe to retry. Running the same step twice with the same input should not create a worse outcome than running it once.
Practical strategies:
- Use api.dedupe() when your workflow needs business-level deduplication — for example, to prevent sending the same reminder twice for the same order or to stop an HTTP-triggered workflow from re-processing the same external event
- Check before writing — before tagging an order or sending a message, check whether it already happened
- Use external idempotency keys — many APIs support this; use a stable event or business ID
For Shopify webhook triggers, duplicate delivery protection is already handled by the platform before your workflow code runs. api.dedupe() is still useful when you want your own workflow-specific deduplication rules.
Payload design
The payload you pass to api.scheduleNextStep() is persisted until the later step runs. Keep it lean:
- Pass only what the next step needs
- Avoid large blobs when possible
- Do not depend on in-memory state
If a later step needs something, pass it in payload or store it in api.runStore.
When not to use a step
Not every workflow needs multiple steps. A single start step can be enough when:
- the work completes very quickly
- you are using an HTTP trigger and want to return the response directly from start
- you are in a scheduled trigger and the job is short and straightforward
The overhead of scheduling another step is unnecessary for work that can safely run in start.
---
URL: https://help.jsworkflows.com/md/oauth/custom.txt
Custom OAuth 2.0
Connect any OAuth 2.0 Authorization Code provider by supplying your own credentials.
The Custom OAuth 2.0 option lets you connect any service that supports the standard Authorization Code grant flow (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1). You supply the Authorization URL, Token URL, Client ID, Client Secret, and scopes. JsWorkflows handles the redirect, token exchange, and storage.
What is supported
- Standard OAuth 2.0 Authorization Code flow
- Token exchange via application/x-www-form-urlencoded POST to your Token URL
- Token storage encrypted at rest
- Automatic token refresh at expiry, only if the provider returns a refreshtoken and expiresin in the token response
What is not supported
- PKCE (code challenge/verifier)
- Token endpoints that require JSON body instead of form-encoded body
- Non-standard grant types (client credentials, device code, etc.)
- Providers that do not follow the standard accesstoken response field name
- Providers that require a different redirect URI
Setup
1. Create an OAuth app at the provider
In the provider's developer console, create an OAuth 2.0 application. Note:
- Grant type must be Authorization Code
- Add https://oauth.jsworkflows.com/oauth2/callback as an authorized redirect URI (exact match required)
- Copy the Client ID, Client Secret, Authorization URL, and Token URL
2. Add the connection in JsWorkflows
Go to Settings → OAuth2 Tokens and click Connect to Service. Select Custom (OAuth 2.0) from the service list.
Fill in the fields:
| Field | Description |
| OAuth Redirect URL | Pre-filled. Copy and add it to your provider's redirect URI list |
| Client ID | From your OAuth app |
| Client Secret | From your OAuth app |
| Authorization URL | The provider's OAuth authorization endpoint |
| Token URL | The provider's token exchange endpoint |
| OAuth Name | A friendly label visible only to you |
| Handle | The unique identifier used in api.getOAuthToken('your-handle'). Use lowercase letters, numbers, and hyphens only. It cannot be changed after creation |
| Scopes | Space-separated scopes to request (e.g. read:data write:data) |
Click Generate and Authorize. A popup window opens the provider's authorization page. After you grant access, the popup closes and the connection appears in your list.
Usage in workflows
export class Workflow {
async start(data, headers, api) {
const { token, error } = await api.getOAuthToken('my-custom-handle');
if (error | | !token) throw new Error(error | | 'Missing OAuth token');
const res = await fetch('https://api.example.com/resource', {
headers: { Authorization: Bearer ${token} },
});
if (!res.ok) {
throw new Error(API ${res.status}: ${await res.text()});
}
const json = await res.json();
console.log(Response: ${JSON.stringify(json)});
}
}
Many OAuth providers use Authorization: Bearer for API calls, but some APIs require a different header format. Always follow the provider's API documentation for resource requests.
Token refresh
JsWorkflows automatically refreshes the access token when it expires, using the refreshtoken returned by the provider. The refresh POST uses the same Token URL you provided during setup, with standard refreshtoken grant parameters.
If the provider does not return a refreshtoken in the initial token response, automatic refresh is not possible. In that case, the token will stop working after it expires and you will need to reconnect the integration manually.
Editing a connection
When you edit a Custom OAuth connection, the Authorization URL and Token URL are pre-filled from the stored values. The Client Secret is shown as a placeholder — leave it unchanged to keep the existing secret, or enter a new one to replace it.
Re-saving triggers a new OAuth authorization popup so the provider can issue a fresh token with the updated credentials or scopes.
Limitations
- No PKCE support: providers that require PKCE (common for public clients and mobile apps) will not work.
- No JSON token body: the token request always uses application/x-www-form-urlencoded. Providers that require a JSON body are not compatible.
- Refresh requires refreshtoken: if the provider only issues short-lived access tokens without a refresh token, the token will expire and need manual reconnection.
- Standard accesstoken field required: the token response must contain an access_token field at the top level of the JSON response.
- One redirect URI: the fixed redirect URI is https://oauth.jsworkflows.com/oauth2/callback. Providers that do not allow this exact URI cannot be used.
---
URL: https://help.jsworkflows.com/md/oauth/dropbox.txt
Dropbox OAuth
Connect Dropbox to read and write files from your workflows.
JsWorkflows provides a platform-managed Dropbox OAuth app. You do not need to create a Dropbox app or provide credentials.
Available scopes
| Resource | Operation | Scope |
| Files | Read & write files | files.content.read, files.content.write |
Connecting
1. Go to OAuth Connections → Add Connection → Dropbox.
2. Select the resources and operations your workflow needs.
3. Sign in with your Dropbox account and grant the requested permissions.
4. Give the connection a handle (e.g., my-dropbox).
Use lowercase letters, numbers, and hyphens only for the handle.
Access tokens are refreshed automatically when they expire.
Example: upload a file
export class Workflow {
async start(data, headers, api) {
await api.scheduleNextStep({
delay: 10,
action: 'uploadReport',
payload: { orderId: data.id, total: data.totalprice },
});
}
async uploadReport({ orderId, total }, headers, api) {
const { token, error } = await api.getOAuthToken('my-dropbox');
if (error | | !token) throw new Error(error | | 'Missing Dropbox token');
const content = Order ID,Total\n${orderId},${total};
const bytes = new TextEncoder().encode(content);
const response = await fetch('https://content.dropboxapi.com/2/files/upload', {
method: 'POST',
headers: {
Authorization: Bearer ${token},
'Content-Type': 'application/octet-stream',
'Dropbox-API-Arg': JSON.stringify({
path: /reports/order-${orderId}.csv,
mode: 'overwrite',
}),
},
body: bytes,
});
if (!response.ok) {
throw new Error(Dropbox API ${response.status}: ${await response.text()});
}
const file = await response.json();
console.log(Uploaded ${file.pathdisplay | | file.name});
}
}
/2/files/upload expects the file content in the request body and the file arguments in the Dropbox-API-Arg header.
Example: download a file
const { token, error } = await api.getOAuthToken('my-dropbox');
if (error | | !token) throw new Error(error | | 'Missing Dropbox token');
const resp = await fetch('https://content.dropboxapi.com/2/files/download', {
method: 'POST',
headers: {
Authorization: Bearer ${token},
'Dropbox-API-Arg': JSON.stringify({ path: '/reports/config.json' }),
},
});
if (!resp.ok) {
throw new Error(Dropbox API ${resp.status}: ${await resp.text()});
}
const text = await resp.text();
/2/files/download returns file bytes in the response body. Dropbox file metadata is returned in the Dropbox-API-Result response header.
---
URL: https://help.jsworkflows.com/md/oauth/github.txt
GitHub OAuth
Connect GitHub repositories, issues, pull requests, and notifications.
JsWorkflows provides a platform-managed GitHub OAuth app. You do not need to create a GitHub OAuth app or provide credentials.
Available scopes
| Resource | Operation | Scope |
| Repositories | Read public repositories | publicrepo |
| Repositories | Read/write all repositories (including private) | repo |
| Issues & Pull Requests | Create/update issues & PRs | repo |
| GitHub Actions | Manage workflow files | workflow |
| User Profile | Read user profile | read:user |
| User Profile | Read user email | user:email |
| Notifications | Read & manage notifications | notifications |
Connecting
1. Go to OAuth Connections → Add Connection → GitHub.
2. Select the resources and operations your workflow needs.
3. Sign in with your GitHub account and grant the requested permissions.
4. Give the connection a handle (e.g., my-github).
Use lowercase letters, numbers, and hyphens only for the handle.
GitHub OAuth tokens do not expire and are not automatically refreshed.
Example: create an issue
export class Workflow {
async start(data, headers, api) {
const { token, error } = await api.getOAuthToken('my-github');
if (error | | !token) throw new Error(error | | 'Missing GitHub token');
const response = await fetch('https://api.github.com/repos/OWNER/REPO/issues', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: Bearer ${token},
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2026-03-10',
},
body: JSON.stringify({
title: Order ${data.name} requires attention,
body: Order total: ${data.totalprice} ${data.currency}\nCustomer: ${data.email},
labels: ['orders', 'needs-review'],
}),
});
if (!response.ok) {
throw new Error(GitHub API ${response.status}: ${await response.text()});
}
const issue = await response.json();
console.log(Created issue #${issue.number});
}
}
labels, assignees, and milestone are optional when creating an issue. If you use them, send normal JSON arrays or values in the same request body.
Example: trigger a GitHub Actions workflow
const { token, error } = await api.getOAuthToken('my-github');
if (error | | !token) throw new Error(error | | 'Missing GitHub token');
const response = await fetch('https://api.github.com/repos/OWNER/REPO/actions/workflows/deploy.yml/dispatches', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: Bearer ${token},
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2026-03-10',
},
body: JSON.stringify({
ref: 'main',
inputs: {
shop: 'nice-sale-demo',
environment: 'production',
},
}),
});
if (!response.ok) {
throw new Error(GitHub API ${response.status}: ${await response.text()});
}
For workflow dispatch, ref is required. inputs is optional and the keys must match the workflow's declared workflowdispatch inputs.
---
URL: https://help.jsworkflows.com/md/oauth/google.txt
Google OAuth
Connect Google Sheets, Docs, and Drive to your workflows using one Google OAuth handle.
JsWorkflows provides a platform-managed Google OAuth app. You do not need to create your own Google Cloud project or manage Google client credentials.
How Google OAuth works in JsWorkflows
Google OAuth in JsWorkflows is built around one idea:
- create a Google connection once
- give it a handle such as my-google
- use that handle later in workflow code, templates, or the editor
You can then use that same handle to:
- get an access token in workflow code with api.getOAuthToken(handle)
- pick Google Sheets, Docs, or Drive folders from the app UI
- save those selected file references into template settings or workflow variables
Current Google scope
JsWorkflows currently documents the Google picker-based file flow around this scope:
| Resource | Use in JsWorkflows | Scope |
| Google Drive, Google Sheets, Google Docs | Select existing files and folders, create files, upload files, and work with selected Sheets and Docs | drive.file |
drive.file is the important scope for Google Sheets, Google Docs, and Google Drive file picker flows. It allows you to select the specific files you want to share with JsWorkflows and lets workflows create new files as needed.
Step 1: Create a Google connection
You can open the OAuth section in either of these places:
1. From Settings → OAuth2 Tokens
2. From the workflow code editor:
- open More actions
- select Manage OAuth2 tokens
Then:
1. Click Connect to Service
2. Choose Google
3. Complete Google's consent screen
4. Save the connection with a handle such as my-google
That handle is the stable name your workflows use later. Use lowercase letters, numbers, and hyphens only.
Step 2: Use the handle with a file picker
You do not pick files during the OAuth approval step.
Instead, the normal flow is:
1. create the Google OAuth connection
2. save the handle
3. later, enter that handle in either:
- the Manage Environment Variables page in the editor
- a template settings field that uses the Google picker
4. select the Google Sheet, Doc, or Drive folder you want the workflow to use
This keeps the Google connection separate from the file selection.
Using the picker in the editor
In Manage Environment Variables, the Google picker is an optional helper. It does not replace normal secret variables.
Typical flow:
1. open Manage Environment Variables
2. in Optional: Add Google File Reference, enter your Google OAuth handle
3. choose the resource type
4. click the picker button
5. select the file or folder
6. save the selected file reference as a workflow variable
The variable key you choose becomes the env key used in workflow code.
For example:
- if you save the picker value under the variable key GOOGLESHEET
- the workflow reads it as env.GOOGLESHEET
The saved value is a JSON reference, for example:
{
"id": "1abc123...",
"name": "Inventory Sheet",
"mimeType": "application/vnd.google-apps.spreadsheet",
"resourceKey": "",
"url": "https://docs.google.com/spreadsheets/d/1abc123..."
}
This is workflow-scoped. It is not a store-global file setting.
So a common pattern is:
1. save the selected spreadsheet reference under a key such as GOOGLESHEET
2. read that value later in code with env.GOOGLESHEET
Using the picker in templates
Templates can also use Google picker fields.
In a picker-enabled template:
1. enter the Google OAuth handle in the template settings
2. use the file picker field for the spreadsheet, document, or folder
3. save the workflow
When the workflow is saved, JsWorkflows generates the runtime config.js for that workflow using the selected template values.
The original template file is not modified. The saved file references belong to the workflow created from the template.
Using the saved file reference in code
The safest pattern is to support either:
- a raw Google file ID
- or a JSON reference saved by the picker
Use a small helper:
function parseGoogleReference(input) {
const raw = String(input | | '').trim();
if (!raw) return { id: '', resourceKey: '' };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return {
id: String(parsed.id | | '').trim(),
resourceKey: String(parsed.resourceKey | | '').trim(),
};
}
} catch {}
return { id: raw, resourceKey: '' };
}
Example: Google Sheets
This example appends one row to a spreadsheet selected with the picker.
function parseGoogleReference(input) {
const raw = String(input | | '').trim();
if (!raw) return { id: '', resourceKey: '' };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return {
id: String(parsed.id | | '').trim(),
resourceKey: String(parsed.resourceKey | | '').trim(),
};
}
} catch {}
return { id: raw, resourceKey: '' };
}
export class Workflow {
async start(payload, headers, api) {
const { token, error } = await api.getOAuthToken('my-google');
if (error | | !token) throw new Error(error | | 'Missing Google token');
const { id: spreadsheetId, resourceKey } = parseGoogleReference(env.SHEETREF);
const range = encodeURIComponent('Sheet1!A1');
const requestHeaders = {
Authorization: Bearer ${token},
'Content-Type': 'application/json',
};
if (resourceKey) {
requestHeaders['X-Goog-Drive-Resource-Keys'] = ${spreadsheetId}/${resourceKey};
}
const response = await fetch(
https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${range}:append?valueInputOption=USERENTERED,
{
method: 'POST',
headers: requestHeaders,
body: JSON.stringify({
values: [[
'JsWorkflows',
'Google Sheets test',
new Date().toISOString(),
]],
}),
}
);
if (!response.ok) {
throw new Error(Google Sheets append failed: ${response.status} ${await response.text()});
}
}
}
If your sheet name contains spaces or special characters, always URL-encode the full range string before placing it in the Sheets API URL.
Example: Google Docs
This example appends text to a Google Doc selected with the picker.
function parseGoogleReference(input) {
const raw = String(input | | '').trim();
if (!raw) return { id: '', resourceKey: '' };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return {
id: String(parsed.id | | '').trim(),
resourceKey: String(parsed.resourceKey | | '').trim(),
};
}
} catch {}
return { id: raw, resourceKey: '' };
}
export class Workflow {
async start(payload, headers, api) {
const { token, error } = await api.getOAuthToken('my-google');
if (error | | !token) throw new Error(error | | 'Missing Google token');
const { id: documentId, resourceKey } = parseGoogleReference(env.DOCREF);
const requestHeaders = { Authorization: Bearer ${token} };
if (resourceKey) {
requestHeaders['X-Goog-Drive-Resource-Keys'] = ${documentId}/${resourceKey};
}
const docRes = await fetch(https://docs.googleapis.com/v1/documents/${documentId}, {
headers: requestHeaders,
});
if (!docRes.ok) {
throw new Error(Google Docs read failed: ${docRes.status} ${await docRes.text()});
}
const doc = await docRes.json();
const endIndex = Math.max(1, (doc.body?.content?.[doc.body.content.length - 1]?.endIndex ?? 1) - 1);
const updateHeaders = {
Authorization: Bearer ${token},
'Content-Type': 'application/json',
};
if (resourceKey) {
updateHeaders['X-Goog-Drive-Resource-Keys'] = ${documentId}/${resourceKey};
}
const updateRes = await fetch(https://docs.googleapis.com/v1/documents/${documentId}:batchUpdate, {
method: 'POST',
headers: updateHeaders,
body: JSON.stringify({
requests: [
{
insertText: {
location: { index: endIndex },
text: Updated by JsWorkflows at ${new Date().toISOString()}\n,
},
},
],
}),
});
if (!updateRes.ok) {
throw new Error(Google Docs update failed: ${updateRes.status} ${await updateRes.text()});
}
}
}
Example: Google Drive folder
This example uploads a text file to a Drive folder selected with the picker.
function parseGoogleReference(input) {
const raw = String(input | | '').trim();
if (!raw) return { id: '', resourceKey: '' };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return {
id: String(parsed.id | | '').trim(),
resourceKey: String(parsed.resourceKey | | '').trim(),
};
}
} catch {}
return { id: raw, resourceKey: '' };
}
export class Workflow {
async start(payload, headers, api) {
const { token, error } = await api.getOAuthToken('my-google');
if (error | | !token) throw new Error(error | | 'Missing Google token');
const { id: folderId, resourceKey } = parseGoogleReference(env.DRIVEFOLDERREF);
const boundary = jsw-${Date.now()};
const metadata = {
name: jsworkflows-demo-${Date.now()}.txt,
mimeType: 'text/plain',
parents: folderId ? [folderId] : undefined,
};
const body = [
--${boundary},
'Content-Type: application/json; charset=UTF-8',
'',
JSON.stringify(metadata),
--${boundary},
'Content-Type: text/plain; charset=UTF-8',
'',
'Created by JsWorkflows',
--${boundary}--,
'',
].join('\r\n');
const requestHeaders = {
Authorization: Bearer ${token},
'Content-Type': multipart/related; boundary=${boundary},
};
if (folderId && resourceKey) {
requestHeaders['X-Goog-Drive-Resource-Keys'] = ${folderId}/${resourceKey};
}
const response = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', {
method: 'POST',
headers: requestHeaders,
body,
});
if (!response.ok) {
throw new Error(Google Drive upload failed: ${response.status} ${await response.text()});
}
}
}
Picker values vs pasted file IDs
In JsWorkflows, a Google file reference can come from two places:
- the built-in picker
- a manually pasted Google file ID
Use the picker when
- you want to choose an existing Google Sheet, Doc, or Drive folder from the app UI
- you want the workflow to use the file you selected through your Google connection
- you want the safest setup for drive.file
This is the recommended option for most merchants.
Use a pasted file ID only when
- you already know the exact Google file ID
- you copied it from a Google URL
- the file is already accessible to the same Google OAuth connection
- you are updating an existing workflow that already has access to that file
A Google file ID is the long identifier inside a Google URL. For example:
https://docs.google.com/spreadsheets/d/1abc123XYZ456/edit
^^^^^^^^^^^^^
this part is the file ID
Knowing a Google file ID does not automatically give the workflow access to that file.
With drive.file, a workflow can only use files that are already accessible to the same Google OAuth connection. In practice, that usually means:
- the file was selected with the picker
- the file was created by the workflow
- or the file was already made accessible to that same Google OAuth app and connection
If you are unsure, use the picker. That is the clearest and safest way to make an existing Google Sheet, Doc, or Drive folder available to the workflow.
For most merchants, the picker should be treated as the normal way to connect an existing Google file to a workflow.
Manual file IDs are an advanced option. They do not grant access by themselves.
Summary
The normal Google OAuth pattern in JsWorkflows is:
1. create one Google OAuth connection
2. save a handle
3. use that handle in workflow code with api.getOAuthToken(handle)
4. optionally use the picker in:
- template settings
- Manage Environment Variables
5. save the selected file reference and use it in code
Note: JsWorkflows' use of Google API data complies with the Google API Services User Data Policy, including Limited Use requirements.
---
URL: https://help.jsworkflows.com/md/oauth/hubspot.txt
HubSpot OAuth
Connect HubSpot CRM contacts, companies, deals, tickets, and automation.
JsWorkflows provides a platform-managed HubSpot OAuth app. You do not need to create a HubSpot app or provide credentials.
Available scopes
| Resource | Operation | Scope |
| Contacts | Read contacts | crm.objects.contacts.read |
| Contacts | Create/update/delete contacts | crm.objects.contacts.read, crm.objects.contacts.write |
| Companies | Read companies | crm.objects.companies.read |
| Companies | Create/update/delete companies | crm.objects.companies.read, crm.objects.companies.write |
| Deals | Read deals | crm.objects.deals.read |
| Deals | Create/update/delete deals | crm.objects.deals.read, crm.objects.deals.write |
| Marketing Email | Read/send marketing emails | marketing-email |
| Files | Access file manager | files |
| Tickets | Read/manage tickets | tickets |
| Automation | Manage workflows & sequences | automation |
Connecting
1. Go to OAuth Connections → Add Connection → HubSpot.
2. Select the resources and operations your workflow needs.
3. Sign in with your HubSpot account and grant the requested permissions.
4. Give the connection a handle (e.g., my-hubspot).
Use lowercase letters, numbers, and hyphens only for the handle.
Access tokens are refreshed automatically when they expire.
Example: create a contact from a Shopify order
export class Workflow {
async start(data, headers, api) {
const { token, error } = await api.getOAuthToken('my-hubspot');
if (error | | !token) throw new Error(error | | 'Missing HubSpot token');
const customer = data.customer;
const response = await fetch('https://api.hubapi.com/crm/v3/objects/contacts', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: Bearer ${token},
},
body: JSON.stringify({
properties: {
email: customer.email,
firstname: customer.firstname,
lastname: customer.lastname,
},
}),
});
if (!response.ok) {
throw new Error(HubSpot API ${response.status}: ${await response.text()});
}
}
}
Example: create a deal
const { token, error } = await api.getOAuthToken('my-hubspot');
if (error | | !token) throw new Error(error | | 'Missing HubSpot token');
const response = await fetch('https://api.hubapi.com/crm/v3/objects/deals', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: Bearer ${token},
},
body: JSON.stringify({
properties: {
dealname: Order ${data.name},
amount: data.totalprice,
pipeline: 'default',
dealstage: 'closedwon',
},
}),
});
if (!response.ok) {
throw new Error(HubSpot API ${response.status}: ${await response.text()});
}
When creating deals, dealstage and pipeline must use HubSpot internal IDs or internal values, not the UI label text.
---
URL: https://help.jsworkflows.com/md/oauth/mailchimp.txt
Mailchimp OAuth
Connect Mailchimp to manage audiences, campaigns, and automations.
JsWorkflows provides a platform-managed Mailchimp OAuth app. You do not need to create a Mailchimp app or provide credentials.
Available scopes
Mailchimp OAuth grants full account access. There are no granular scope selections. All connected resources are available once you authorize.
Connecting
1. Go to OAuth Connections → Add Connection → Mailchimp.
2. Click through to the Mailchimp authorisation screen.
3. Log in and click Allow.
4. Give the connection a handle (e.g., my-mailchimp).
Use lowercase letters, numbers, and hyphens only for the handle.
Mailchimp tokens do not expire and are not automatically refreshed.
Finding your API endpoint and data centre
Mailchimp routes API requests through a data-centre-specific base URL (e.g., https://us1.api.mailchimp.com). After connecting, call the metadata endpoint to retrieve it:
const { token, error } = await api.getOAuthToken('my-mailchimp');
if (error | | !token) throw new Error(error | | 'Missing Mailchimp token');
const metaResponse = await fetch('https://login.mailchimp.com/oauth2/metadata', {
headers: { 'Authorization': OAuth ${token} },
});
if (!metaResponse.ok) {
throw new Error(Mailchimp metadata ${metaResponse.status}: ${await metaResponse.text()});
}
const meta = await metaResponse.json();
const apiBase = meta.apiendpoint; // e.g. "https://us1.api.mailchimp.com"
Example: add a subscriber to an audience
export class Workflow {
async start(data, headers, api) {
await api.scheduleNextStep({
delay: 10,
action: 'subscribe',
payload: {
email: data.email,
firstName: data.customer?.firstname,
lastName: data.customer?.lastname,
},
});
}
async subscribe({ email, firstName, lastName }, headers, api) {
const { token, error } = await api.getOAuthToken('my-mailchimp');
if (error | | !token) throw new Error(error | | 'Missing Mailchimp token');
const metaResponse = await fetch('https://login.mailchimp.com/oauth2/metadata', {
headers: { 'Authorization': OAuth ${token} },
});
if (!metaResponse.ok) {
throw new Error(Mailchimp metadata ${metaResponse.status}: ${await metaResponse.text()});
}
const meta = await metaResponse.json();
const listId = env.MAILCHIMPLISTID;
const response = await fetch(${meta.apiendpoint}/3.0/lists/${listId}/members, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: Bearer ${token},
},
body: JSON.stringify({
emailaddress: email,
status: 'subscribed',
mergefields: { FNAME: firstName | | '', LNAME: lastName | | '' },
}),
});
if (!response.ok) {
throw new Error(Mailchimp API ${response.status}: ${await response.text()});
}
const member = await response.json();
console.log(Subscribed ${member.emailaddress});
}
}
MAILCHIMPLISTID should be the audience ID. mergefields keys such as FNAME and LNAME must match merge tags that already exist on that audience.
---
URL: https://help.jsworkflows.com/md/oauth/microsoft.txt
Microsoft OAuth
Connect Outlook Mail, OneDrive, Calendar, and Contacts to your workflows.
JsWorkflows provides a platform-managed Microsoft OAuth app. You do not need to register an Azure application or provide credentials.
Available scopes
| Resource | Operation | Scope |
| Outlook Mail | Read mail | Mail.Read |
| Outlook Mail | Send mail | Mail.Send |
| Outlook Mail | Read & write mail | Mail.ReadWrite, Mail.Send |
| OneDrive | Read files | Files.Read |
| OneDrive | Read & write files | Files.ReadWrite |
| Outlook Calendar | Read calendar | Calendars.Read |
| Outlook Calendar | Read & write calendar | Calendars.ReadWrite |
| Outlook Contacts | Read contacts | Contacts.Read |
| Outlook Contacts | Read & write contacts | Contacts.ReadWrite |
| User Profile | Read profile | User.Read |
Connecting
1. Go to OAuth Connections → Add Connection → Microsoft.
2. Select the resources and operations your workflow needs.
3. Sign in with your Microsoft account and grant the requested permissions.
4. Give the connection a handle (e.g., my-microsoft).
Use lowercase letters, numbers, and hyphens only for the handle.
Access tokens are refreshed automatically when they expire.
Example: send an email via Outlook
export class Workflow {
async start(data, headers, api) {
const { token, error } = await api.getOAuthToken('my-microsoft');
if (error | | !token) throw new Error(error | | 'Missing Microsoft token');
const response = await fetch('https://graph.microsoft.com/v1.0/me/sendMail', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: Bearer ${token},
},
body: JSON.stringify({
message: {
subject: Order ${data.name} confirmed,
body: { contentType: 'Text', content: Thank you for your order of ${data.totalprice} ${data.currency}. },
toRecipients: [{ emailAddress: { address: data.email } }],
},
saveToSentItems: false,
}),
});
if (!response.ok) {
throw new Error(Microsoft Graph ${response.status}: ${await response.text()});
}
}
}
sendMail returns 202 Accepted with no response body on success.
Example: upload a file to OneDrive
const { token, error } = await api.getOAuthToken('my-microsoft');
if (error | | !token) throw new Error(error | | 'Missing Microsoft token');
const csvString = 'order,amount\\n1001,49.95\\n';
const fileBytes = new TextEncoder().encode(csvString);
const response = await fetch('https://graph.microsoft.com/v1.0/me/drive/root:/reports/orders.csv:/content', {
method: 'PUT',
headers: {
'Content-Type': 'text/csv; charset=utf-8',
Authorization: Bearer ${token},
},
body: fileBytes,
});
if (!response.ok) {
throw new Error(Microsoft Graph ${response.status}: ${await response.text()});
}
const driveItem = await response.json();
console.log(Uploaded ${driveItem.name} (${driveItem.size} bytes));
---
URL: https://help.jsworkflows.com/md/oauth/notion.txt
Notion OAuth
Connect Notion to read and write pages and databases from your workflows.
JsWorkflows provides a platform-managed Notion OAuth app. You do not need to create a Notion integration or provide credentials.
Available scopes
Notion OAuth grants access to the pages and databases you explicitly share with the integration during the authorization flow. There are no scope selections. Access is controlled by what you choose to share.
Connecting
1. Go to OAuth Connections → Add Connection → Notion.
2. Sign in with your Notion account.
3. Select the pages and databases you want to give JsWorkflows access to.
4. Click Allow access.
5. Give the connection a handle (e.g., my-notion).
Use lowercase letters, numbers, and hyphens only for the handle.
Notion tokens do not expire and are not automatically refreshed.
Example: create a page in a database
export class Workflow {
async start(data, headers, api) {
await api.scheduleNextStep({
delay: 10,
action: 'createRecord',
payload: {
orderId: data.name,
total: data.totalprice,
currency: data.currency,
},
});
}
async createRecord({ orderId, total, currency }, headers, api) {
const { token, error } = await api.getOAuthToken('my-notion');
if (error | | !token) throw new Error(error | | 'Missing Notion token');
const response = await fetch('https://api.notion.com/v1/pages', {
method: 'POST',
headers: {
Authorization: Bearer ${token},
'Content-Type': 'application/json; charset=utf-8',
'Notion-Version': '2022-06-28',
},
body: JSON.stringify({
parent: { databaseid: env.NOTIONDATABASEID },
properties: {
Name: { title: [{ text: { content: orderId } }] },
Total: { number: parseFloat(total) },
Currency: { richtext: [{ text: { content: currency } }] },
},
}),
});
if (!response.ok) {
throw new Error(Notion API ${response.status}: ${await response.text()});
}
const page = await response.json();
console.log(Created page ${page.id});
}
}
The property names and types in properties must match the target database schema. For example, Name must be a title property, Total a number property, and Currency a rich text property in that database.
Example: query a database
const { token, error } = await api.getOAuthToken('my-notion');
if (error | | !token) throw new Error(error | | 'Missing Notion token');
const resp = await fetch(https://api.notion.com/v1/databases/${env.NOTIONDATABASE_ID}/query, {
method: 'POST',
headers: {
Authorization: Bearer ${token},
'Content-Type': 'application/json; charset=utf-8',
'Notion-Version': '2022-06-28',
},
body: JSON.stringify({}),
});
if (!resp.ok) {
throw new Error(Notion API ${resp.status}: ${await resp.text()});
}
const { results } = await resp.json();
---
URL: https://help.jsworkflows.com/md/oauth/overview.txt
OAuth Connectors Overview
Connect third-party services to your workflows using OAuth 2.0.
JsWorkflows lets you connect external services using OAuth 2.0. Once connected, your workflows can call those services' APIs without manually storing tokens in workflow code. JsWorkflows stores the connection tokens and refreshes access tokens automatically when the provider supports refresh.
For Google, the same OAuth handle can also be used by the built-in file pickers in template settings and in Manage Environment Variables. That lets users select a Google Sheet, Doc, or Drive folder in the UI and then use the saved file reference in workflow code.
Connecting a service
OAuth connections in JsWorkflows are created first, then referenced later by a handle in workflow code, templates, or picker-enabled UI fields.
You can open the OAuth section in either of these places:
1. Settings → OAuth2 Tokens
2. From the workflow code editor:
- open More actions
- select Manage OAuth2 tokens
Then:
1. Click Connect to Service
2. Choose the service
3. Complete the provider's OAuth flow
4. Save the connection with a handle, a short name you use later in workflow code
Handles should use lowercase letters, numbers, and hyphens only.
Using a connection in a workflow
Call api.getOAuthToken(handle) to get a valid access token, then use it with the global fetch():
const { token, error } = await api.getOAuthToken('my-google');
if (error | | !token) { console.log('OAuth error:', error); return; }
const response = await fetch('https://www.googleapis.com/drive/v3/files', {
headers: { 'Authorization': Bearer ${token} },
});
Platform-managed connections
For the following services, JsWorkflows manages the OAuth app credentials. You only need to log in, no Client ID or Client Secret required:
- Google (Drive-based access for Sheets, Docs, and Drive picker flows)
- Slack
- Microsoft
- Dropbox
- GitHub
- Mailchimp
- HubSpot
- Notion
Custom OAuth providers are configured separately. See Custom OAuth (/oauth/custom/) when you need to connect a service that is not in the platform-managed list.
Supported services
| Service | Doc |
| Google | Google OAuth (/oauth/google/) |
| Slack | Slack OAuth (/oauth/slack/) |
| Microsoft | Microsoft OAuth (/oauth/microsoft/) |
| HubSpot | HubSpot OAuth (/oauth/hubspot/) |
| GitHub | GitHub OAuth (/oauth/github/) |
| Dropbox | Dropbox OAuth (/oauth/dropbox/) |
| Mailchimp | Mailchimp OAuth (/oauth/mailchimp/) |
| Notion | Notion OAuth (/oauth/notion/) |
| Shopify (custom app / secondary store) | Shopify Custom App (/oauth/shopify/) |
| Custom (any OAuth 2.0 provider) | Custom OAuth (/oauth/custom/) |
Google note
Google is slightly different from some other OAuth connectors.
The current Google file flow is documented around:
- one saved Google OAuth handle
- drive.file
- optional Google file pickers in:
- template settings
- Manage Environment Variables
If you plan to work with Google Sheets, Google Docs, or Google Drive files from JsWorkflows, read the dedicated Google OAuth (/oauth/google/) page for the current setup and examples.
---
URL: https://help.jsworkflows.com/md/oauth/shopify.txt
Shopify Custom App
Connect a Shopify custom app to call Shopify APIs from workflows. Covers dev stores and live stores with custom distribution.
You can connect a Shopify custom app to workflows, not just the store where JsWorkflows is installed. This lets a workflow call another Shopify store, use a separate Shopify app token, or access Shopify API surfaces that are outside the standard JsWorkflows app connection.
For the store where JsWorkflows is installed, you usually do not need this. Use the built-in Shopify Admin API access instead — JsWorkflows automatically injects the installed store's access token when your workflow calls that store's Admin API URL.
Use a separate Shopify connection when you need one of these patterns:
- Multi-store workflows: read from one Shopify store and update another
- Wholesale or regional stores: sync products, inventory, customer tags, or order data between related stores
- Migration and backfill jobs: pull data from a source store and write cleaned data into another store
- Separate Shopify scopes: use scopes that are not part of the main JsWorkflows app installation
- Agency or developer workflows: manage controlled access to a client's secondary store
1. Create the Shopify app
1. Go to dev.shopify.com/dashboard (https://dev.shopify.com/dashboard/) and log in
2. Click Create app, then Start from Dev Dashboard
3. Enter an app name and click Create. This opens the Create version screen
2. Configure the app version
On the Create version screen:
1. App URL: Enter any URL, or leave the default https://example.com
2. Scopes: Click Select scopes and choose the Admin API scopes your workflow needs
3. Redirect URLs: Add the following URL exactly as shown:
``text
https://oauth.jsworkflows.com/oauth2/callback
`
4. Click Release to publish the version
3. Get the Client Secret
In the left sidebar, click Settings. Under Credentials you will find the Client ID and Secret. Keep the Secret — you will need it when adding the connection.
Connecting a dev store
If the target store is a development store, you can use the standard Custom (OAuth 2.0) connector.
Go to Settings → OAuth2 Tokens, click Connect to Service, and choose Custom (OAuth 2.0).
Fill in the fields:
| Field | Value |
| Client ID | From Dev Dashboard → Settings → Credentials |
| Client Secret | From Dev Dashboard → Settings → Credentials |
| Authorization URL | https://your-store.myshopify.com/admin/oauth/authorize |
| Token URL | https://your-store.myshopify.com/admin/oauth/accesstoken |
| Scopes | Comma-separated list of your configured scopes. Copy from Versions → [latest version] → Scopes in the Dev Dashboard |
| OAuth Name | A friendly label (e.g. Dev Store) |
| Handle | A short identifier used in workflow code (e.g. dev-store) |
Click Generate and Authorize. A popup opens Shopify's authorization screen. Approve the request — the token is stored and the connection appears in your list.
Connecting a live store
Live stores require the app to use Custom distribution. This adds an extra step before authorizing.
Set the distribution method
From the app overview page in the Dev Dashboard:
1. Click Select distribution method
2. Select Custom distribution
3. Enter the live store's domain. Shopify accepts either format:
`text
shop1.myshopify.com
admin.shopify.com/store/shop1
`
Shopify generates an installation link for the store. Copy that link — you need it in the next steps.
Install the app on the target store
Open the installation link in a browser tab. You will need to be logged in to the target Shopify store. Shopify will install the app on that store.
This step happens outside JsWorkflows and only needs to be done once per store. After the app is installed, you can connect it.
Add the connection in JsWorkflows
Go to Settings → OAuth2 Tokens, click Connect to Service, and choose Shopify Custom App.
Fill in the fields:
| Field | Value |
| Installation Link | The full installation link from the Dev Dashboard distribution settings |
| Client Secret | From Dev Dashboard → Settings → Credentials |
| Scopes | Comma-separated list of your configured scopes. Copy from Versions → [latest version] → Scopes in the Dev Dashboard |
| OAuth Name | A friendly label (e.g. Secondary Store) |
| Handle | A short identifier used in workflow code (e.g. secondary-store) |
The Client ID, Authorization URL, and Token URL are extracted and derived automatically from the installation link. You do not need to enter them manually.
Click Generate and Authorize. A popup opens Shopify's authorization screen for the target store. Approve the request — the token is stored and the connection appears in your list.
Use it in a workflow
export class Workflow {
async start(data, headers, api) {
const { token, error } = await api.getOAuthToken('secondary-store');
if (error | | !token) throw new Error(error | | 'Missing Shopify token');
const res = await fetch(
'https://your-store.myshopify.com/admin/api/2026-04/graphql.json',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': token,
},
body: JSON.stringify({
query: {
products(first: 5) {
edges {
node {
id
title
status
totalInventory
}
}
}
},
}),
}
);
if (!res.ok) throw new Error(Shopify API ${res.status}: ${await res.text()});
const { data } = await res.json();
const products = data.products.edges.map(e => e.node);
console.log(Fetched ${products.length} products);
}
}
Replace your-store with the store's myshopify.com subdomain and secondary-store with the handle you chose.
Common scopes
Select the scopes you need from the Select scopes UI in the Dev Dashboard when creating your app version.
| Scope | What it allows |
| readproducts | Read products and variants |
| writeproducts | Create and update products and variants |
| readorders | Read orders (last 60 days) |
| readallorders | Read all orders regardless of age |
| writeorders | Update orders |
| readinventory | Read inventory levels and locations |
| writeinventory | Update inventory levels |
| readcustomers | Read customer data |
| writecustomers | Create and update customers |
| readthemes | Read theme files |
| write_themes` | Create and update theme files |
For a full list, see the Shopify API access scopes reference (https://shopify.dev/docs/api/usage/access-scopes).
---
URL: https://help.jsworkflows.com/md/oauth/slack.txt
Slack OAuth
Connect Slack to send messages, read channels, and manage files.
JsWorkflows provides a platform-managed Slack OAuth app. You do not need to create your own Slack app or manage client credentials.
Available scopes
| Resource | Operation | Scope |
| Messaging | Send message | chat:write, users:read |
| Messaging | Read messages | channels:history, groups:history, im:history, mpim:history |
| Users | Get user info / list users | users:read |
| Channels | List channels | channels:read |
| Channels | Join channel | channels:join |
| Files | Upload file | files:write |
| Files | Read files | files:read |
| Reactions | Add/read reactions | reactions:write, reactions:read |
Example: post a message
export class Workflow {
async start(data, headers, api) {
const { token, error } = await api.getOAuthToken('my-slack');
if (error | | !token) throw new Error(error | | 'Missing Slack token');
const response = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: Bearer ${token},
},
body: JSON.stringify({
channel: 'C0123456789',
text: New order #${data.ordernumber}: ${data.totalprice} ${data.currency},
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: New order #${data.ordernumber}: ${data.totalprice} ${data.currency},
},
},
],
}),
});
if (!response.ok) {
throw new Error(Slack HTTP ${response.status}: ${await response.text()});
}
const result = await response.json();
if (!result.ok) {
throw new Error(Slack API error: ${result.error | | 'unknownerror'});
}
}
}
Use a channel ID when possible. It is the safest value to store in workflow config and pass to Slack APIs.
Example: upload a file
Slack's current upload flow is:
1. call files.getUploadURLExternal
2. upload the file bytes to the returned uploadurl
3. call files.completeUploadExternal
const { token, error } = await api.getOAuthToken('my-slack');
if (error | | !token) throw new Error(error | | 'Missing Slack token');
const csvString = 'sku,available\\nABC-123,42\\n';
const fileBytes = new TextEncoder().encode(csvString);
const initResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: Bearer ${token},
},
body: JSON.stringify({
filename: 'report.csv',
length: fileBytes.byteLength,
}),
});
if (!initResponse.ok) {
throw new Error(Slack HTTP ${initResponse.status}: ${await initResponse.text()});
}
const initResult = await initResponse.json();
if (!initResult.ok) {
throw new Error(Slack API error: ${initResult.error | | 'unknownerror'});
}
const uploadResponse = await fetch(initResult.uploadurl, {
method: 'POST',
headers: { 'Content-Type': 'text/csv; charset=utf-8' },
body: fileBytes,
});
if (!uploadResponse.ok) {
throw new Error(Slack upload failed: ${uploadResponse.status} ${await uploadResponse.text()});
}
const completeResponse = await fetch('https://slack.com/api/files.completeUploadExternal', {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: Bearer ${token},
},
body: JSON.stringify({
files: [{ id: initResult.fileid, title: 'report.csv' }],
channelid: 'C0123456789',
initialcomment: 'Daily inventory report',
}),
});
if (!completeResponse.ok) {
throw new Error(Slack HTTP ${completeResponse.status}: ${await completeResponse.text()});
}
const completeResult = await completeResponse.json();
if (!completeResult.ok) {
throw new Error(Slack API error: ${completeResult.error | | 'unknownerror'});
}
For file sharing, use channelid or channels with Slack IDs. Do not use #channel-name in the file upload completion request.
---
URL: https://help.jsworkflows.com/md/templates.txt
Templates
Pre-built workflow templates to get started quickly.
Templates are production-ready starter workflows for common automation use cases. Installing a template creates a copy in your store that you can review, edit, test, and activate like any other workflow.
Templates are useful when your workflow is close to a common pattern such as:
- importing products or inventory
- logging Shopify activity to Google Sheets
- sending alerts or summaries
- tagging customers, orders, or products
- running scheduled syncs or cleanup tasks
Browse the template catalog
The full template catalog is hosted separately so it can grow without crowding the product docs.
Open the template catalog (https://templates.jsworkflows.com/)
Use the catalog to search by use case, category, trigger type, connected service, Shopify scope, and complexity. The docs on this page explain how templates work after you choose one.
What a template includes
A template can include:
- a trigger
- prebuilt workflow code
- a setup form for configurable values
- required secret fields
- OAuth-connected fields such as Google or Slack handles
- template-specific setup guidance
After you install a template, it is your workflow copy. Editing it does not change the original catalog template, and future template updates do not automatically change workflows you already installed.
Install a template
1. Go to Templates in the JsWorkflows dashboard.
2. Browse or search for a template.
3. Click Open template.
4. Review the template setup form.
5. Fill in the required values, secrets, or OAuth handles.
6. Save the workflow.
7. Run a test if the trigger supports it.
8. Activate the workflow when you are ready.
Before you install
Some templates work with only a few simple values. Others require additional setup first.
Common requirements include:
- an OAuth connection such as Google, Slack, Microsoft, GitHub, Dropbox, Mailchimp, HubSpot, or Notion
- workflow secrets such as API keys or service-account credentials
- an existing Google Sheet, Drive folder, or document
- Shopify publications, locations, or other store resources
- extra Shopify scopes for specific operations
Check the template setup form and setup guidance before saving.
How template setup works
Templates can expose several kinds of setup inputs:
- normal configuration values such as names, thresholds, tags, or schedule settings
- secret values that should not be stored in plain workflow code
- OAuth handles that refer to connected third-party services
- picker fields for supported resources such as Google files
- list or mapping fields for cases like publication selections or header mappings
The setup form is part of the template. Different templates expose different fields based on what they need.
Template types
Templates in the catalog may vary in complexity and operating style.
Starter-friendly
These are simpler workflows with a smaller setup surface and more direct behavior.
Advanced
These templates usually involve more setup, more business rules, or more integration detail.
Heavy operation
These templates handle larger or more operationally intensive jobs such as imports and large syncs.
Heavy-operation templates commonly:
- process work in batches
- fan out into multiple continuation steps
- use internal pacing to avoid throttling
- take longer to finish than simple webhook or notification workflows
That is expected behavior. A long-running sync template is not designed to behave like an instant single-action workflow.
Trigger types you will see
Templates are also shaped by how they start.
| Trigger type | Typical use |
| Shopify webhook | React to orders, customers, products, inventory, and other Shopify events |
| Scheduled | Run imports, summaries, audits, and recurring syncs on a schedule |
| Email trigger | Start from an inbound email, often with attachments |
| HTTP trigger | Start from an external system sending an HTTP request |
Choose templates by both use case and trigger model.
Common setup patterns
| Pattern | What it usually means |
| OAuth handle required | Connect the external service first, then select or enter the handle in template setup |
| Secret field required | Enter the value in the setup form so it is stored securely instead of hard-coded in the workflow |
| Google picker field | Select the target spreadsheet, document, or folder through the Google connection |
| Publication or location mapping | Choose or match Shopify resources the workflow will use |
| Header mapping or import options | Map source-file columns or control how import/sync logic behaves |
Troubleshooting template setup
If a template does not work after installation, the most common causes are:
- a required OAuth handle is missing or points to the wrong service account
- a required secret was not filled in
- the target Google Sheet tab, document, or folder does not exist
- Shopify location or publication names do not match what the template expects
- the workflow needs Shopify scopes that are not currently granted
- a heavy-operation template is still processing in batches and has not finished yet
Check the workflow logs first. Most template failures come from missing setup values, missing access, or resource-name mismatches.
Template or custom workflow?
Use a template when the overall workflow is already close to what you need.
Build a custom workflow when:
- your business logic is unique
- the trigger or data mapping is highly specific
- you need unusual API calls or a custom external integration
- the template would require more rewriting than reusing
---
URL: https://help.jsworkflows.com/md/triggers/email-trigger.txt
Email Trigger
Trigger a workflow when an email is sent to your workflow's dedicated address.
The Email trigger gives your workflow a dedicated email address. Whenever an email arrives at that address, the workflow fires with the parsed email contents (sender, subject, body, and any attachments) available in data.
The trigger address
Each email-trigger workflow is assigned a unique address in the format:
{shop-name}.{workflow-id}@jsworkflow.com
The address is shown on the workflow detail page under Trigger email address.
Caution: Treat the trigger address as a secret. Anyone who knows it can trigger your workflow by sending an email to it.
Sender whitelist
By default, the trigger accepts email from any sender. To restrict to specific senders, add a comma-separated list of allowed email addresses under Trigger email address on the workflow detail page.
orders@myapp.com, alerts@monitoring.io
Emails from addresses not on the list are silently rejected with a 403 and any uploaded attachments are cleaned up immediately. Leave the field empty to accept all senders.
The data object
data is the parsed email, passed directly to your start step:
{
from: "sender@example.com",
to: "mystore.abc123@jsworkflow.com",
subject: "New order confirmation",
text: "Plain-text body of the email",
html: "HTML body of the email
",
attachments: [
{
filename: "invoice.pdf",
contentType: "application/pdf",
key: "mystore/abc123/email-/invoice.pdf"
}
]
}
| Field | Type | Description |
| from | string | Sender's email address |
| to | string | The trigger address the email was sent to |
| subject | string | Email subject line |
| text | string | Plain-text body (empty string if none) |
| html | string | HTML body (empty string if none) |
| attachments | array | List of attachment descriptors (see below) |
Attachment descriptor
Each item in data.attachments describes one attached file:
| Field | Type | Description |
| filename | string | Original filename of the attachment |
| contentType | string | MIME type, e.g. application/pdf, image/png |
| key | string | Storage key; pass to api.getAttachment() to retrieve the file |
Attachments are stored for 7 days from the time the email was received. After that, the key becomes invalid and api.getAttachment() returns null.
Accessing attachments
Use api.getAttachment(key) to retrieve the raw file content as an ArrayBuffer. This method is documented in full on the Attachments (/workflow-api/attachments/) page.
Put attachment work in a retryable step
The start step is not retryable. If your code throws after reading an attachment, start cannot be re-run. The safe pattern is to pass the key (not the file content) into a subsequent step, which IS retryable:
export class Workflow {
async start(data, headers, api) {
for (const att of data.attachments) {
await api.scheduleNextStep({
action: 'processAttachment',
payload: { key: att.key, filename: att.filename, contentType: att.contentType }
});
}
}
async processAttachment(data, headers, api) {
// This step is retryable, safe to fetch the file here
const buffer = await api.getAttachment(data.key);
if (!buffer) {
console.log('Attachment expired or not found:', data.key);
return;
}
// Convert to base64 for an API that expects it
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
console.log(Processed ${data.filename} (${buffer.byteLength} bytes));
// Clean up once you're done
await api.deleteAttachment(data.key);
}
}
Example: process an emailed invoice
export class Workflow {
async start(data, headers, api) {
console.log('Email from:', data.from);
console.log('Subject:', data.subject);
console.log('Attachments:', data.attachments.length);
if (data.attachments.length === 0) {
console.log('No attachments, nothing to do.');
return;
}
// Schedule one step per attachment
for (const att of data.attachments) {
if (!att.contentType.startsWith('application/pdf')) continue;
await api.scheduleNextStep({
action: 'uploadInvoice',
payload: { key: att.key, filename: att.filename, sender: data.from }
});
}
}
async uploadInvoice(data, headers, api) {
const buffer = await api.getAttachment(data.key);
if (!buffer) {
console.log('Attachment no longer available:', data.key);
return;
}
// Upload the PDF to an external service
const formData = new FormData();
formData.append('file', new Blob([buffer], { type: 'application/pdf' }), data.filename);
formData.append('sender', data.sender);
const res = await fetch('https://your-api.example.com/invoices', {
method: 'POST',
body: formData,
});
console.log('Upload result:', res.status);
// Remove from storage now that we're done
await api.deleteAttachment(data.key);
}
}
Testing email-trigger workflows
After your workflow has been triggered at least once, open the workflow code editor and select More actions > Workflow runs. Click the Email link in the trigger column for any run to view the full payload in a modal. From there, click Use as test data to copy the payload and headers into the test input, useful for re-running with the same email content.
Note: Attachment files referenced by key values in a saved payload remain accessible via api.getAttachment() for up to 7 days from when the original email was received.
---
URL: https://help.jsworkflows.com/md/triggers/http-trigger.txt
HTTP Trigger
Trigger a workflow from any external HTTP request.
The HTTP trigger exposes a unique public URL for your workflow. Any system that can send an HTTP request — Zapier, Make, another service, your own backend — can trigger it.
The trigger URL
Each workflow gets a unique URL shown in the editor under Trigger → HTTP Trigger. The URL includes a secret token that authenticates the request, so you do not need to add your own authentication unless you want additional validation.
Caution: Treat your trigger URL as a secret. Anyone with the URL can trigger your workflow.
The data object
{
// Parsed JSON body (or null if the body is not valid JSON)
...bodyFields,
// URL query parameters are merged into data at the top level
// e.g. ?foo=bar makes data.foo === 'bar'
}
The entire parsed JSON body is spread into data at the top level. Query string parameters are also available on data. You can access them directly:
export class Workflow {
async start(data, headers, api) {
console.log('Order ID from body:', data.orderId);
console.log('Source from query string:', data.source);
}
}
Returning a custom response
The return value of your start step becomes the HTTP response sent back to the caller.
Return a plain object — serialised as JSON with Content-Type: application/json:
async start(data, headers, api) {
// ... do work ...
return { received: true, id: data.orderId };
}
// Caller receives: {"received":true,"id":"12345"}
Return a Response object — full control over status, headers, and body:
async start(data, headers, api) {
if (!data.orderId) {
return new Response('Missing orderId', { status: 400 });
}
// ... do work ...
return new Response('Accepted', { status: 202 });
}
Return nothing — caller receives {"success":true}:
async start(data, headers, api) {
// fire and forget
}
Note: Only the start step's return value is sent to the caller. Any subsequent steps scheduled with api.scheduleNextStep() run asynchronously after the response has already been sent.
Example — validate and enqueue
export class Workflow {
async start(data, headers, api) {
if (!data.orderId | | !data.action) {
return new Response(
JSON.stringify({ error: 'Missing required fields' }),
{ status: 400, headers: { 'content-type': 'application/json' } }
);
}
// Schedule heavy processing asynchronously
await api.scheduleNextStep({
delay: 10,
action: 'processOrder',
payload: { orderId: data.orderId, action: data.action }
});
return { queued: true, orderId: data.orderId };
}
async processOrder(data, headers, api) {
// This runs 10 seconds later, after the HTTP response was already returned
console.log('Processing order', data.orderId);
}
}
---
URL: https://help.jsworkflows.com/md/triggers/scheduled.txt
Scheduled Trigger
Run a workflow automatically on a repeating or one-time schedule.
The Scheduled trigger runs your workflow at a fixed interval or at a specific point in time — no external event needed.
Schedule types
Repeat
Runs the workflow repeatedly at a fixed interval. Configure in the workflow editor:
| Setting | Options |
| Frequency | Every N minutes, hours, days, or months |
| Start | The date and time of the first run (UTC) |
| End | Never, on a specific date, or after N runs |
Minimum interval: 10 minutes.
Once
Runs the workflow once at a specific date and time, then stops.
The data object
For scheduled triggers, data contains run context:
{
counter: 1, // How many times this workflow has run (1-indexed)
nextscheduletime: 3600000 // Milliseconds until the next scheduled run (0 if no next run)
}
counter starts at 1 on the first run and increments on each subsequent run. Use it to differentiate early runs from later ones or to branch logic based on how many times the workflow has executed.
Your store domain and API version are always available via env.SHOPIFYSTORE and env.SHOPIFYAPIVERSION.
Example — daily order count to Slack
A workflow that runs every day and posts how many orders were placed in the last 24 hours. start fetches the count from Shopify and passes it to notifySlack, which sends the message:
export class Workflow {
async start(data, headers, api) {
console.log(Daily summary — run #${data.counter});
// Fetch order count for the last 24 hours
const since = new Date(Date.now() - 24 60 60 * 1000).toISOString();
const resp = await fetch(
https://${env.SHOPIFYSTORE}/admin/api/${env.SHOPIFYAPIVERSION}/graphql.json,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query OrdersCount($query: String!) {
ordersCount(query: $query) {
count
precision
}
},
variables: { query: created_at:>${since} },
}),
}
);
const { data: gqlData } = await resp.json();
const { count, precision } = gqlData.ordersCount;
const countLabel = precision === 'EXACT' ? ${count} : ${count}+;
await api.scheduleNextStep({
delay: 10,
action: 'notifySlack',
payload: { counter: data.counter, countLabel },
});
}
async notifySlack({ counter, countLabel }, headers, api) {
const { token } = await api.getOAuthToken('my-slack');
await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${token},
},
body: JSON.stringify({
channel: '#orders',
text: Daily summary (run #${counter}): ${countLabel} orders in the last 24 hours.,
}),
});
}
}
Set this workflow to run on a Repeat schedule of every 1 day.
For longer workloads (e.g. processing many products), use api.scheduleNextStep() to spread work across steps rather than doing everything in start. See Steps & Workflow Design (/getting-started/steps/) for patterns.
---
URL: https://help.jsworkflows.com/md/triggers/shopify-flow.txt
Shopify Flow
Run a JsWorkflows workflow as an action step inside a Shopify Flow automation, and return data back into the Flow.
The Shopify Flow integration works in both directions:
- Flow action: Shopify Flow can call a JsWorkflows workflow as the Run JsWorkflow action
- Flow trigger source: JsWorkflows can fire JsWorkflow Data Received, JsWorkflow Run Completed, and JsWorkflow Run Failed events into Shopify Flow
Using the Run JsWorkflow action
Use the Run JsWorkflow action when a Shopify Flow automation needs custom logic, external processing, or other work that is better handled in JsWorkflows. Flow calls the workflow synchronously, and the workflow can return data back into Flow for later conditions, actions, or Run code steps.
Common use cases
The Run JsWorkflow action is most useful when Shopify Flow should hand work off to JsWorkflows at that point in the automation, whether or not Flow needs a value returned immediately.
Common examples include:
- starting a custom operational workflow from Flow, then letting JsWorkflows handle the work from that point
- handing off heavier or multi-step operational work to JsWorkflows once a Flow condition is met
- running business logic that is easier to maintain in JavaScript than in Flow conditions
- calling external systems from a Flow automation, especially when the request or response needs custom handling
- returning a decision value or structured result back into Flow for later conditions or actions
- centralizing reusable logic so multiple Flow automations can call the same JsWorkflow instead of duplicating rules
In practice, this action is strongest when Flow decides when the work should start, and JsWorkflows handles how that work should run.
How it works
There are two sides to the Flow action integration:
- JsWorkflows side: create a workflow with the Flow action trigger type. This exposes it as a selectable action in Shopify Flow.
- Shopify Flow side: add the Run JsWorkflow action to any Flow automation and select which workflow to run. Shopify calls it synchronously and makes the workflow's return value available as a variable in subsequent steps.
Step 1: Create a Flow action workflow
1. In JsWorkflows, click Create workflow.
2. Under Build a custom workflow, select Flow action.
3. Give the workflow a clear name. This is what you will see in the Flow editor dropdown when selecting which workflow to run.
4. Save and activate the workflow.
Caution: Only workflows with the Flow action trigger type appear in the Shopify Flow action selector. Shopify Webhook, HTTP Trigger, and other types are not listed.
Step 2: Add the action in Shopify Flow
1. Open Shopify Admin → Apps → Flow and create or edit an automation.
2. Add an action step and search for Run JsWorkflow.
3. Click Select workflow. A configuration panel opens where you choose which JsWorkflows workflow to run.
4. Optionally fill in the Additional Payload field to pass data from preceding Flow steps into your workflow. Click Add variable inside the field to select data from earlier steps in the automation.
5. Save the automation.
The data object
When Shopify Flow triggers the workflow, data is populated from the Additional Payload field you configured in the action step. How you access the data depends on what you put in that field.
When the payload is a valid JSON object (most common)
If the Additional Payload field contains valid JSON, the fields are available directly on data:
export class Workflow {
async start(data, headers, api) {
console.log('Order ID:', data.orderId);
console.log('Customer email:', data.customerEmail);
}
}
If the Additional Payload field is left empty, data is an empty object ({}).
When the payload is a plain string
If you intentionally pass a plain string instead of a JSON object, it is available at data.payload:
export class Workflow {
async start(data, headers, api) {
console.log('String value:', data.payload); // e.g. "hello"
}
}
This also applies when the Additional Payload field contains invalid JSON. For example, entering {id:"{{order.id}}"} instead of {"id":"{{order.id}}"} produces invalid JSON (keys must be quoted), so JsWorkflows cannot parse it as an object. The raw string falls through to data.payload instead of being available at data.id.
Caution: A common mistake is forgetting to quote JSON keys. {id:"123"} is not valid JSON — it must be {"id":"123"}. If your workflow receives undefined for an expected field, check that your Additional Payload is well-formed JSON. Use a JSON validator if you are unsure.
Additional Payload field
The Additional Payload field accepts a JSON object. Type the JSON structure you want, and use the Add variable button to insert dynamic values from preceding steps in the automation, such as the order ID, customer email, or any other field available in the Flow trigger event.
{
"orderId": "{{order.id}}",
"customerEmail": "{{order.email}}",
"totalPrice": "{{order.totalDiscountsSet.shopMoney.amount}}"
}
This is the primary way to pass data from the Flow trigger event into your workflow.
Note: The Additional Payload is optional. If your workflow only needs to run some fixed logic (send a notification, update a record, call an API) without needing input from the Flow trigger event, you can leave it empty.
Returning data to Shopify Flow
The return value of the start step is passed back to Shopify Flow as an output named runJsworkflow, available in the variable picker under the Run JsWorkflow action.
The output contains a data field of type String. This field holds a JSON-encoded string of whatever your start step returned, and is available in the variable picker for use in subsequent Flow steps.
Returning a plain object
export class Workflow {
async start(data, headers, api) {
const result = await fetch('https://your-api.example.com/orders/' + data.orderId);
const order = await result.json();
return {
status: order.status,
trackingUrl: order.trackingUrl,
eta: order.estimatedDelivery,
};
}
}
runJsworkflow.data in Flow contains: {"status":"shipped","trackingUrl":"https://...","eta":"2025-06-01"}
Returning a Response
You can also return a Response.json() directly, which gives you the same result:
async start(data, headers, api) {
return Response.json({
status: 'shipped',
trackingUrl: 'https://tracking.example.com/123',
eta: '2025-06-01',
});
}
Returning a single value
Wrap single values in an object so they have a named key:
async start(data, headers, api) {
const score = await getRiskScore(data.orderId);
return { riskScore: score };
}
runJsworkflow.data will contain {"riskScore":92}, available as a string in subsequent Flow steps.
Returning nothing
If the start step returns nothing, runJsworkflow.data is null in Flow. This is expected when the workflow performs a side-effect (send an email, tag a customer) and does not need to pass anything back.
In practice, this also applies when start returns undefined or null.
async start(data, headers, api) {
await tagCustomer(data.customerId, 'vip');
// no return — runJsworkflow.data is null
}
Caution: Only the start step's return value is sent back to Flow. Any subsequent steps scheduled with api.scheduleNextStep() run asynchronously after Shopify has already received the response. Their return values are not visible to Flow.
If you need data from an external API in Flow, make that API call inside the start step and return the result directly.
Calling the Shopify API
The Shopify access token is injected automatically. Use env.SHOPIFYSTORE and env.SHOPIFYAPIVERSION:
export class Workflow {
async start(data, headers, api) {
const resp = await fetch(
https://${env.SHOPIFYSTORE}/admin/api/${env.SHOPIFYAPIVERSION}/graphql.json,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: mutation AddTag($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) {
node { id }
userErrors { field message }
}
},
variables: {
id: gid://shopify/Customer/${data.customerId},
tags: ['vip'],
},
}),
}
);
const { data: gqlData } = await resp.json();
const { userErrors } = gqlData.tagsAdd;
if (userErrors.length) {
console.log('GraphQL errors:', userErrors);
return { success: false, errors: userErrors.map(e => e.message) };
}
return { success: true, customerId: data.customerId };
}
}
Example: enrich order with fraud score and return it to Flow
This workflow calls an external fraud-scoring API in start, returns the score to Flow so a subsequent condition can branch on it, and schedules heavier logging asynchronously:
export class Workflow {
async start(data, headers, api) {
// data.orderId and data.totalPrice were set via the Additional Payload field in Flow
const scoreResp = await fetch('https://fraud-api.example.com/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': Bearer ${env.FRAUDAPIKEY} },
body: JSON.stringify({ orderId: data.orderId, total: data.totalPrice }),
});
const { score, reason } = await scoreResp.json();
// Schedule detailed logging in the background (does not block the Flow response)
await api.scheduleNextStep({
delay: 10,
action: 'logResult',
payload: { orderId: data.orderId, score, reason },
});
// This is what Flow's variable picker sees as runJsworkflow.data
return { score, reason, flagged: score > 80 };
}
async logResult(data, headers, api) {
// Runs after Flow has already received the score
console.log(Order ${data.orderId}: fraud score ${data.score} (${data.reason}));
if (data.flagged) {
await fetch('https://hooks.slack.com/services/...', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: Order ${data.orderId} flagged with score: ${data.score} }),
});
}
}
}
In Shopify Flow, set the Additional Payload using Add variable to insert the order ID and total price from the trigger event:
{
"orderId": "{{order.id}}",
"totalPrice": "{{order.totalDiscountsSet.shopMoney.amount}}"
}
After the action runs, runJsworkflow.data contains {"score":92,"reason":"high-velocity","flagged":true}, available as a string in subsequent Flow steps.
Using the action output in Shopify Flow
runJsworkflow.data is returned to Flow as a string, not as a structured object. That means you cannot reliably use score, id, trackingUrl, or other nested fields directly in later Flow steps until you parse the JSON string first.
The clearest pattern is:
1. Run the Run JsWorkflow action
2. Add a Run code step immediately after it
3. Parse runJsworkflow.data
4. Return the specific fields you want to use later in Flow
Example: extract an id field
If your workflow returns:
async start(data, headers, api) {
return { id: 'gid://shopify/Product/1234567890' };
}
then Shopify Flow receives that value in:
- runJsworkflow.data
but the value is still a JSON string:
"{\"id\":\"gid://shopify/Product/1234567890\"}"
To make id usable in later Flow steps, add a Run code step after the action.
Use this input query:
query {
runJsworkflow {
data
}
}
Use code like this:
export default function main(input) {
const parsed = JSON.parse(input.runJsworkflow.data);
return { id: parsed.id };
}
Define outputs like this:
type Output {
"ID extracted from the JsWorkflow result"
id: ID!
}
After that, later Flow steps can use:
- {{runCode.id}}
This is usually the best way to expose returned values cleanly into the rest of the automation.
Example: extract multiple fields
If your workflow returns:
return {
success: true,
score: 92,
flagged: true,
reviewTag: 'manual-review',
};
then your Run code step can parse and expose all of them:
export default function main(input) {
const parsed = JSON.parse(input.runJsworkflow.data);
return {
success: parsed.success,
score: parsed.score,
flagged: parsed.flagged,
reviewTag: parsed.reviewTag,
};
}
with outputs such as:
type Output {
success: Boolean!
score: Int
flagged: Boolean!
reviewTag: String
}
Later Flow steps can then branch or act on:
- {{runCode.success}}
- {{runCode.score}}
- {{runCode.flagged}}
- {{runCode.reviewTag}}
Note: If your workflow may return null, no value, or an error-shaped result, make your Run code step defensive. For example, check that input.runJsworkflow.data exists before calling JSON.parse(...).
Error handling
The Run JsWorkflow action still moves to the next step in Flow even when your workflow returns an error status. Do not rely on HTTP status codes to control Flow branching. Instead, return a structured result such as { success: false, error: '...' } and add a Condition step in Flow that branches on that result.
If your start step returns nothing (or undefined or null), runJsworkflow.data is null. Flow still advances to the next step. There is no automatic failure signal. A null result looks the same to Flow as a deliberate no-op.
This means error signalling must happen explicitly inside the data you return. Use a field like success or error in your return value, then add a Condition step in Flow that branches on that field.
async start(data, headers, api) {
if (!data.orderId) {
return { success: false, error: 'orderId is required' };
}
try {
const resp = await fetch('https://fraud-api.example.com/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId: data.orderId }),
});
if (!resp.ok) {
return { success: false, error: Fraud API returned ${resp.status} };
}
const result = await resp.json();
return { success: true, score: result.score };
} catch (err) {
return { success: false, error: err.message };
}
}
In Flow, add a Condition after the action that checks whether runJsworkflow.data contains "success":true before continuing with the happy path. The error field is available in the same string for a failure branch.
Note: An unhandled exception in start marks the JsWorkflows run as failed internally and fires the JsWorkflow Run Failed trigger. However, from Flow's perspective the action step still completes. Flow does not see it as a failure and will continue to the next step.
JsWorkflows as a Shopify Flow trigger source
Instead of Shopify Flow calling a JsWorkflows workflow as an action, JsWorkflows can also push events into Shopify Flow and start automations there. This is useful when a workflow needs to notify Flow about an important result, when a run completes or fails, or when a background step produces data that should drive a downstream Flow automation.
JsWorkflow Data Received
This trigger fires when a workflow step calls api.sendToFlow(data) to push data into Shopify Flow. Use it when your workflow produces useful data after it has already started running, especially in later or background steps, and you want Shopify Flow to continue with conditions, notifications, or other follow-up actions.
The main use case is pushing a result into Flow from a background step, after the start step has already returned its response to the caller.
export class Workflow {
async start(data, headers, api) {
await api.scheduleNextStep({ delay: 30, action: 'enrich', payload: { orderId: data.orderId } });
return { queued: true };
}
async enrich(data, headers, api) {
const resp = await fetch('https://fraud-api.example.com/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId: data.orderId }),
});
const result = await resp.json();
await api.sendToFlow({ score: result.score, flagged: result.score > 80 });
}
}
Common use cases
JsWorkflow Data Received is most useful when the important result only becomes available after JsWorkflows has done work that Shopify Flow cannot do well on its own.
Common examples include:
- human-in-the-loop workflows where a JsWorkflow waits for a review, approval, or external signal, then sends the outcome into Flow
- running a delayed or background step and only later sending the final outcome into Flow
- enriching orders, customers, or products with data from supplier, ERP, WMS, PIM, fraud, or other third-party systems
- processing files, spreadsheets, or email attachments in JsWorkflows, then sending summary data or extracted values into Flow
- pushing reconciliation or exception results into Flow after a multi-step sync or validation workflow finishes
In practice, this trigger is strongest when JsWorkflows does the hard operational work first, and Shopify Flow handles the downstream conditions, notifications, tagging, or other merchant-facing actions.
Available fields
| Field | Type | Description |
| workflowName | String | Name of the workflow that called api.sendToFlow() |
| workflowId | String | ID of the workflow that called api.sendToFlow() |
| runId | String | Unique ID for this specific run |
| stepAction | String | Name of the step that called it (e.g. enrich, start) |
| data | String | String payload sent by api.sendToFlow(). If you pass an object, JsWorkflows JSON-encodes it automatically. |
Use workflowName or workflowId in a Flow Condition step to filter the automation to a specific workflow.
Setting up
1. Open Shopify Admin → Apps → Flow and create a new automation.
2. Click Select a trigger and search for JsWorkflow Data Received.
3. Add a Condition step on workflowName or workflowId to target a specific workflow.
4. Add a Run code step to parse the data field and extract the values you need.
5. Save and activate the automation.
Using the data in a Run code step
Add a Run code step after the trigger. The fields are accessed flat in the input query:
query {
workflowName
workflowId
runId
stepAction
data
}
If your workflow passed an object to api.sendToFlow(...), the data field will contain JSON and you can parse it in the code block:
export default function main(input) {
const parsed = JSON.parse(input.data);
return {
score: parsed.score,
flagged: parsed.flagged,
};
}
If your workflow passed a plain string instead, Flow receives that plain string directly in data. In that case, do not use JSON.parse(...) unless the string itself contains valid JSON.
JsWorkflow Run Completed
Fires when any workflow run finishes successfully, regardless of what originally started it (Shopify webhook, HTTP trigger, scheduled trigger, email trigger, or Flow action).
Available fields
| Field | Type | Description |
| workflowId | String | ID of the workflow that ran |
| runId | String | Unique ID for this specific run |
| triggerType | String | What started the run (shopify-webhook, http-trigger, scheduled-trigger, email-trigger, flow-trigger) |
| startedAt | String | Unix timestamp in milliseconds when the run started |
| durationMs | String | Total run duration in milliseconds |
Setting up
1. Open Shopify Admin → Apps → Flow and create a new automation.
2. Click Select a trigger and search for JsWorkflow Run Completed.
3. Add conditions and actions as needed. All fields above are available in the variable picker.
4. Save and activate the automation.
JsWorkflow Run Failed
Fires when any workflow run fails with an error, regardless of what originally started it.
Available fields
| Field | Type | Description |
| workflowId | String | ID of the workflow that ran |
| runId | String | Unique ID for this specific run |
| triggerType | String | What started the run (shopify-webhook, http-trigger, scheduled-trigger, email-trigger, flow-trigger) |
| startedAt | String | Unix timestamp in milliseconds when the run started |
| durationMs | String | Total run duration in milliseconds |
| error | String | The error message that caused the run to fail |
Setting up
1. Open Shopify Admin → Apps → Flow and create a new automation.
2. Click Select a trigger and search for JsWorkflow Run Failed.
3. Add conditions and actions as needed. All fields above are available in the variable picker.
4. Save and activate the automation.
Example: notify on failure
A common use case is sending an alert whenever a workflow fails. Add a Send internal email action using the error and workflowId fields to describe what went wrong.
You can also add a Condition step that checks workflowId so the automation only fires for a specific workflow.
---
URL: https://help.jsworkflows.com/md/triggers/shopify-webhook.txt
Shopify Webhook
Trigger a workflow from any Shopify event topic.
The Shopify Webhook trigger fires your workflow whenever a selected event occurs in your store. Any Shopify webhook topic is supported.
The data object
For Shopify Webhook triggers, data is the raw Shopify webhook payload passed directly as the top-level object. There is no wrapper — you access order fields, customer fields, etc. directly:
export class Workflow {
async start(data, headers, api) {
// data IS the order (for orders/* topics)
console.log('Order ID:', data.id);
console.log('Total:', data.totalprice);
console.log('Customer email:', data.email);
}
}
Useful headers
The headers object includes all Shopify webhook headers:
| Header | Description |
| x-shopify-shop-domain | Your store's myshopify.com domain |
| x-shopify-topic | The webhook topic, e.g. orders/paid |
| x-shopify-webhook-id | Unique ID for this delivery |
| x-shopify-attempt-number | Which retry attempt this is (1 = first) |
export class Workflow {
async start(data, headers, api) {
const shop = headers['x-shopify-shop-domain'];
const topic = headers['x-shopify-topic'];
console.log(Received ${topic} from ${shop});
}
}
Common webhook topics
| Category | Topics |
| Orders | orders/create, orders/updated, orders/paid, orders/fulfilled, orders/cancelled, orders/partiallyfulfilled |
| Customers | customers/create, customers/update, customers/delete |
| Products | products/create, products/update, products/delete |
| Inventory | inventorylevels/update, inventoryitems/update |
| Refunds | refunds/create |
| Checkouts | checkouts/create, checkouts/update, checkouts/delete |
| Fulfilments | fulfillments/create, fulfillments/update |
| Collections | collections/create, collections/update, collections/delete |
Calling the Shopify API from within the workflow
Use the global fetch() with your store's myshopify.com URL. The platform automatically injects the X-Shopify-Access-Token header — no manual token management needed. Use env.SHOPIFYSTORE and env.SHOPIFYAPIVERSION which are always available:
export class Workflow {
async start(data, headers, api) {
// Token is injected automatically — env.SHOPIFYSTORE is "mystore.myshopify.com"
const resp = await fetch(
https://${env.SHOPIFYSTORE}/admin/api/${env.SHOPIFYAPI_VERSION}/graphql.json,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: mutation UpdateOrderNote($input: OrderInput!) {
orderUpdate(input: $input) {
order { id note }
userErrors { field message }
}
},
variables: { input: { id: gid://shopify/Order/${data.id}, note: 'Processed by JsWorkflows' } },
}),
}
);
const { data: gqlData } = await resp.json();
const { order, userErrors } = gqlData.orderUpdate;
if (userErrors.length) { console.log('Errors:', userErrors); return; }
console.log('Updated note:', order.note);
}
}
Deduplication
Shopify retries undelivered webhooks up to 19 times over 48 hours. JsWorkflows automatically deduplicates deliveries using the x-shopify-webhook-id header — if a webhook ID has already been processed, subsequent retries are silently ignored within a 120-second window.
Caution: Only active workflows receive and process incoming webhooks. Inactive workflows ignore all events, even while the toggle is off.
---
URL: https://help.jsworkflows.com/md/triggers/test.txt
Test Trigger
Run a workflow manually from the editor with a custom payload.
The Test trigger runs your workflow from the editor with a payload you supply. It behaves the same as a real webhook trigger — steps are scheduled and run, credits are consumed, and live API calls execute. Use it to verify your workflow with controlled input before enabling it on live traffic.
Running a test
1. Open the workflow editor.
2. Click More actions → Test data to open the editable Request body and Request headers panels. The request body becomes the data argument to start; the headers become the headers argument.
3. For Shopify webhook triggers, the request body is pre-filled with the topic's sample payload. You can view the sample at any time (read-only) via More actions → Sample payload.
4. Click More actions → Run test.
The workflow runs from start exactly as it would on a live trigger. A Live Output panel opens below the editor and listens for real-time step events. Each row in the table represents one step execution:
| Column | Description |
| Status | Green tick for success, red for failure |
| Status code | HTTP response code from the step dispatcher |
| Time | When the step executed |
| Duration | Wall-clock time for that step |
| Step | The method name that ran (- for the initial trigger) |
| Retries | How many times this step was retried |
| Data id | Internal identifier for this step's payload |
| Payload size | Size of the payload passed to this step |
| Task | Workflow name |
| Method | HTTP method used to dispatch the step |
Caution: Test runs execute real API calls — fetch() calls hit live endpoints and credits are consumed. Use test order IDs or sandbox credentials if you want to avoid side-effects.
---
URL: https://help.jsworkflows.com/md/workflow-api/attachments.txt
Email Attachments
Read and delete email attachment files captured by email-trigger workflows.
These methods are available in Email Trigger (/triggers/email-trigger/) workflows. When an email arrives with file attachments, each attachment descriptor in data.attachments includes a key. Use that key to retrieve and clean up the file in a later retryable step.
Attachment descriptor shape
{
filename: "invoice.pdf",
contentType: "application/octet-stream",
key: "shop-name/workflow-id/email-/invoice.pdf"
}
api.getAttachment(key)
Retrieves an attachment by its storage key and returns the raw file content.
const buffer = await api.getAttachment(key);
Parameters
| Parameter | Type | Description |
| key | string | The key value from data.attachments[n].key |
Returns Promise
Returns an ArrayBuffer containing the raw file bytes, or null if the key does not exist or has expired.
Example
async processAttachment(data, headers, api) {
const buffer = await api.getAttachment(data.key);
if (!buffer) {
console.log('Attachment not found or expired:', data.key);
return;
}
console.log(Got ${buffer.byteLength} bytes: ${data.filename});
// Work with the buffer
const blob = new Blob([buffer], { type: data.contentType });
const text = new TextDecoder().decode(buffer); // if text-based
}
api.getAttachment() returns the ArrayBuffer directly. Do not read a .buffer property from its result.
api.deleteAttachment(key)
Deletes an attachment from storage. Safe to call even if the key no longer exists. It is a no-op in that case.
await api.deleteAttachment(key);
Parameters
| Parameter | Type | Description |
| key | string | The key value from data.attachments[n].key |
Returns Promise
Example
async processAttachment(data, headers, api) {
const buffer = await api.getAttachment(data.key);
if (!buffer) return;
// ... process the file ...
// Clean up once done, don't leave files in storage indefinitely
await api.deleteAttachment(data.key);
}
Attachment retention
Attachments are stored for 7 days from when the email was received. After that, api.getAttachment() returns null for those keys. Plan your workflow so that any attachment processing happens well within that window.
Attachment size guidance
This page does not define a hard attachment size limit. However, api.getAttachment() reads the full file into an ArrayBuffer, so very large attachments are still a poor fit for normal step processing.
Use attachments for small to moderate files. If a workflow needs to handle large CSV-style inputs, move quickly into a chunked processing pattern and never pass raw attachment buffers between steps.
The retryable step pattern
The start step is not retryable. If an error occurs mid-execution, start does not run again, which means you cannot re-fetch the attachment there safely. The correct pattern is to pass the key string (not the file content) into a subsequent step:
export class Workflow {
async start(data, headers, api) {
// Pass keys to retryable steps, do NOT read file content here
for (const att of data.attachments) {
await api.scheduleNextStep({
delay: 10,
action: 'handleFile',
payload: { key: att.key, filename: att.filename, contentType: att.contentType }
});
}
}
async handleFile(data, headers, api) {
// This step is retryable, safe to call api.getAttachment() here
const buffer = await api.getAttachment(data.key);
if (!buffer) {
console.log('File unavailable:', data.key);
return;
}
// ... process and then clean up ...
await api.deleteAttachment(data.key);
}
}
Pass only the key into the scheduled payload. Do not pass the full attachment buffer between steps.
Note: api.getAttachment() and api.deleteAttachment() are only available in email-trigger workflows. Calling them in other trigger types throws an error.
Caution: api.getAttachment() and api.deleteAttachment() are not available inside onWorkflowComplete or onWorkflowError lifecycle hooks.
---
URL: https://help.jsworkflows.com/md/workflow-api/csv.txt
CSV Import
Fetch a CSV from a URL, split it into chunks, and process each chunk in a separate step.
api.csv lets a workflow fetch a CSV from a public URL, split it into fixed-size chunks in temporary storage, and process each chunk in a separate step. This is the standard pattern for large CSV imports that would otherwise risk execution limits.
Methods
// Phase 1: fetch and split (call once in the start step)
const meta = await api.csv.importCSV(url, options);
// Phase 2: read one chunk (call in each fan-out step)
const rows = await api.csv.readCSVChunk(key, chunkIndex);
// Phase 3: cleanup (call once after all chunks are processed)
await api.csv.deleteCSV(key);
api.csv.importCSV(url, options?)
Fetches the CSV from url, parses it, and writes the rows into chunked temporary storage. Returns metadata used to schedule the fan-out steps.
| Option | Type | Default | Description |
| url | string | required | Public URL of the CSV file. It must be accessible without auth |
| options.chunkSize | number | 500 | Rows per chunk. Lower values mean shorter per-step processing time; higher values mean fewer scheduled steps |
| options.delimiter | string | ',' | Field separator character. Use '\t' for TSV files |
Returns { key, totalRows, chunkCount, headers }:
| Field | Description |
| key | Unique import ID. Pass this to every readCSVChunk and deleteCSV call |
| totalRows | Total row count, excluding the header row |
| chunkCount | Number of chunks written. Schedule this many fan-out steps |
| headers | Column names from the first row |
api.csv.readCSVChunk(key, chunkIndex)
Reads one chunk of a previously imported CSV. Returns an array of row objects (one object per row, keys are column names), or null if the chunk does not exist. Always null-guard the result.
api.csv.deleteCSV(key)
Deletes all stored chunks for a CSV import. Call this in the final step after all chunks have been processed to release temporary storage immediately rather than waiting for the automatic 7-day expiry.
Standard fan-out pattern
export class Workflow {
async start(data, headers, api) {
const meta = await api.csv.importCSV('https://example.com/products.csv', { chunkSize: 500 });
console.log(Imported ${meta.totalRows} rows across ${meta.chunkCount} chunks);
for (let i = 0; i < meta.chunkCount; i++) {
await api.scheduleNextStep({
delay: 10 + i 5, // stagger chunks to avoid burst API calls
action: 'processChunk',
payload: { key: meta.key, chunkIndex: i, totalChunks: meta.chunkCount },
});
}
}
async processChunk({ key, chunkIndex, totalChunks }, headers, api) {
const rows = await api.csv.readCSVChunk(key, chunkIndex);
if (!rows) {
console.log(Chunk ${chunkIndex} not found; skipping);
return;
}
for (const row of rows) {
console.log(Row: ${JSON.stringify(row)});
// process each row, for example create or update a Shopify resource
}
// Clean up only after all chunk steps have finished.
const finished = await api.runStore.increment(csv:${key}:chunksFinished);
if (finished === totalChunks) {
await api.csv.deleteCSV(key);
await api.runStore.delete(csv:${key}:chunksFinished);
}
}
}
Using chunkIndex === totalChunks - 1 as the cleanup trigger is not safe in retry-heavy or out-of-order fan-out flows. Count completed chunks instead.
Using a public Google Sheet as source
Google Sheets can serve as a CSV source without OAuth credentials if the sheet is shared as Anyone with the link can view. Convert the share URL to a CSV export URL:
const csvUrl = SHEET_URL.replace(/\/edit.$/, '/export?format=csv');
// For a specific tab, append: + '&gid=123456789' (copy gid from the sheet URL bar)
const meta = await api.csv.importCSV(csvUrl, { chunkSize: 500 });
Limitations
| Limitation | Detail |
| Public URL required | importCSV uses a plain fetch(url) with no auth. URLs that require login or redirect to an auth page will fail |
| UTF-8 encoding only | Other encodings produce garbled text. Google Sheets and Shopify CSV exports are UTF-8 |
| Header row required | The first row is always treated as column headers. Header-less CSVs are not supported |
| Delimiter is not auto-detected | Defaults to ,. Pass { delimiter: '\t' } explicitly for TSV files |
| 7-day chunk expiry | Chunk files are automatically deleted from temporary storage after 7 days. Do not schedule processChunk steps more than 7 days after importCSV |
| Waits are limited by chunk TTL | If you pause between importCSV and chunk processing with api.waitForEvent(), resume within 7 days or the chunk data may already be gone |
| Import blocks before processing | The entire file is fetched and split before importCSV returns. There is no way to begin processing chunks while the import is still running |
| Snapshot at import time | importCSV reads the source once. Later changes to the file are not reflected in already-created chunks |
| Not available in lifecycle hooks | api.csv is not available inside onWorkflowComplete or onWorkflowError |
---
URL: https://help.jsworkflows.com/md/workflow-api/fetch.txt
HTTP Requests
Make HTTP requests from a workflow step using the global fetch API.
Workflow methods use the standard global fetch(). There is no api.fetch() wrapper.
fetch() is available in normal workflow steps and in lifecycle hooks.
Shopify Admin GraphQL
When your fetch() URL targets your store's Admin API domain, the platform automatically injects the X-Shopify-Access-Token header. Do not set that header manually.
Your store domain and Admin API version are available on the global env object:
export class Workflow {
async start(data, headers, api) {
const orderId = data.admingraphqlapiid ?? gid://shopify/Order/${data.id};
const res = await fetch(
https://${env.SHOPIFYSTORE}/admin/api/${env.SHOPIFYAPIVERSION}/graphql.json,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: mutation UpdateOrderNote($input: OrderInput!) {
orderUpdate(input: $input) {
order { id note }
userErrors { field message }
}
},
variables: { input: { id: orderId, note: 'Processed' } },
}),
}
);
if (!res.ok) {
throw new Error(Shopify API ${res.status}: ${await res.text()});
}
const json = await res.json();
if (json.errors?.length) {
console.log('GraphQL errors:', json.errors);
return;
}
const { order, userErrors } = json.data.orderUpdate;
if (userErrors.length) {
console.log('User errors:', userErrors);
return;
}
console.log('Updated note:', order.note);
}
}
Read-only queries use the same endpoint and the same automatic token injection:
const res = await fetch(
https://${env.SHOPIFYSTORE}/admin/api/${env.SHOPIFYAPIVERSION}/graphql.json,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query GetOrders {
orders(first: 10) {
nodes { id name }
}
},
}),
}
);
if (!res.ok) {
throw new Error(Shopify API ${res.status}: ${await res.text()});
}
const json = await res.json();
if (json.errors?.length) {
throw new Error(JSON.stringify(json.errors));
}
const orders = json.data.orders.nodes;
OAuth-connected services
Use api.getOAuthToken(handle) to get a valid access token for a connected OAuth service, then add it to your request headers manually. The platform automatically refreshes the token if it has expired.
export class Workflow {
async start(data, headers, api) {
const { token, error } = await api.getOAuthToken('my-slack');
if (error) {
console.log('OAuth error:', error);
return;
}
await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${token},
},
body: JSON.stringify({ channel: '#orders', text: New order: ${data.id} }),
});
}
}
The handle is the name you assigned when connecting the service in OAuth Connections.
External APIs with secrets
Store API keys via More actions → Manage variables in the workflow editor and access them via env.*:
const res = await fetch('https://api.example.com/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${env.EXAMPLEAPIKEY},
},
body: JSON.stringify({ event: 'orderpaid', orderId: data.id }),
});
Sending JSON bodies
Pass a plain JavaScript object as the body by serialising it with JSON.stringify(). Set Content-Type: application/json when doing this:
const res = await fetch('https://api.example.com/endpoint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' }),
});
---
URL: https://help.jsworkflows.com/md/workflow-api/google-service-account.txt
Google Service Account
Authenticate with Google APIs using a service account key from your workflow.
Service account authentication lets your workflow call Google APIs (Sheets, Drive, Gmail, Calendar, etc.) without an interactive OAuth flow. You generate a key from Google Cloud, store the credentials as workflow secrets, and use api.google.getServiceAccountToken() to get a short-lived access token.
Treat these tokens as short-lived. In multi-step or fan-out workflows, fetch the token again in each step that needs it rather than passing the token through the payload.
api.google.getServiceAccountToken()
const { token, error } = await api.google.getServiceAccountToken(
privateKey,
clientEmail,
scopes,
impersonateUser // optional
);
| Parameter | Type | Description |
| privateKey | string | The privatekey field from your service account JSON key file |
| clientEmail | string | The clientemail field from your service account JSON key file |
| scopes | string[] | Array of scope keys (see table below) |
| impersonateUser | string | Optional. Email of a Google Workspace user to impersonate. Requires domain-wide delegation to be enabled on the service account. |
Returns { token, error }. Always check error before using token.
Scope keys
Pass scope keys as strings, not URLs. The platform resolves them to the correct Google OAuth scope URLs.
| Key | Google API |
| 'SHEETS' | Spreadsheets (read/write) |
| 'SHEETSREADONLY' | Spreadsheets (read only) |
| 'DRIVE' | Drive (full access) |
| 'DRIVEREADONLY' | Drive (read only) |
| 'DRIVEFILE' | Drive files created by the app |
| 'GMAIL' | Gmail (read/modify) |
| 'GMAILSEND' | Gmail (send only) |
| 'GMAILREADONLY' | Gmail (read only) |
| 'CALENDAR' | Calendar (read/write) |
| 'CALENDAREVENTS' | Calendar events (read/write) |
| 'CALENDARREADONLY' | Calendar (read only) |
| 'DOCS' | Google Docs (read/write) |
| 'DOCSREADONLY' | Google Docs (read only) |
| 'PEOPLE' | Contacts (read/write) |
| 'PEOPLEREADONLY' | Contacts (read only) |
| 'ADMINDIRECTORY' | Admin SDK directory |
| 'ADMINREPORTS' | Admin SDK usage reports |
api.google.sheet
api.google.sheet provides helpers for the Google Sheets API. Each method takes an access token obtained from api.google.getServiceAccountToken() as its first argument.
These helpers are only for Google Sheets. For Drive, Gmail, Calendar, Docs, or other Google APIs, get a token with api.google.getServiceAccountToken() and then call the API directly with fetch().
appendRows()
Appends one or more rows to a sheet. Rows are added after the last row with data in the specified range.
await api.google.sheet.appendRows(token, spreadsheetId, range, values);
| Parameter | Type | Description |
| token | string | Access token from getServiceAccountToken() |
| spreadsheetId | string | The spreadsheet ID from the Google Sheets URL |
| range | string | A1 notation of the range, e.g. 'Sheet1!A1' |
| values | any[][] | Array of rows, each row an array of cell values |
readRows()
Reads values from a range.
const rows = await api.google.sheet.readRows(token, spreadsheetId, range);
Returns a 2D array of cell values, e.g. [['Name', 'Email'], ['Alice', 'alice@example.com']].
updateRows()
Overwrites a range with new values.
await api.google.sheet.updateRows(token, spreadsheetId, range, values);
clearRange()
Clears all values in a range without deleting the cells.
await api.google.sheet.clearRange(token, spreadsheetId, range);
createSheet()
Creates a new spreadsheet and returns the API response (including the new spreadsheet ID).
const result = await api.google.sheet.createSheet(token, title, properties);
| Parameter | Type | Description |
| title | string | The name of the new spreadsheet |
| properties | object | Optional. Additional spreadsheet properties |
getSheetMetadata()
Returns the spreadsheet metadata (title, sheet names, IDs, etc.).
const meta = await api.google.sheet.getSheetMetadata(token, spreadsheetId);
shareSheet()
Shares a spreadsheet with a Google account.
await api.google.sheet.shareSheet(token, spreadsheetId, emailAddress, role, type);
| Parameter | Type | Default | Description |
| emailAddress | string | required | The email to share with |
| role | string | 'writer' | 'reader', 'commenter', or 'writer' |
| type | string | 'user' | 'user', 'group', 'domain', or 'anyone' |
createAndShareSheet()
Creates a spreadsheet and immediately shares it. This is a convenience wrapper around createSheet() plus shareSheet().
const result = await api.google.sheet.createAndShareSheet(token, title, shareWithEmail, role, properties);
Setup
1. Create a service account in Google Cloud
1. Go to Google Cloud Console → IAM & Admin → Service Accounts
2. Click Create Service Account, give it a name, and click Done
3. Enable the Google APIs your workflow needs, such as Sheets API or Drive API
4. Click the service account → Keys → Add Key → Create new key → JSON
5. Download the JSON key file. You need privatekey and clientemail from it
2. Grant access to your spreadsheet
Share the spreadsheet with the service account's clientemail address (the same way you would share with a person), giving it the role your workflow needs (Viewer or Editor).
For shared Sheets or Drive files, impersonation is usually not needed. For Gmail, Calendar, or other user-owned Google Workspace data, domain-wide delegation and impersonateUser are often required.
3. Store credentials as workflow secrets
In the workflow code editor, open More actions → Manage variables and add:
- GOOGLEPRIVATEKEY - the privatekey value from the JSON key file
- GOOGLECLIENTEMAIL - the clientemail value from the JSON key file
- SPREADSHEETID - if you want to use the example below without hardcoding a spreadsheet ID
4. Use in your workflow
export class Workflow {
async start(data, headers, api) {
await api.scheduleNextStep({
delay: 10,
action: 'writeToSheet',
payload: { orderId: data.id, total: data.totalprice },
});
}
async writeToSheet({ orderId, total }, headers, api) {
const { token, error } = await api.google.getServiceAccountToken(
env.GOOGLEPRIVATEKEY,
env.GOOGLECLIENTEMAIL,
['SHEETS']
);
if (error) {
console.log('Failed to get Google token:', error);
return;
}
await api.google.sheet.appendRows(
token,
env.SPREADSHEET_ID,
'Orders!A1',
[[orderId, total, new Date().toISOString()]]
);
}
}
If you need Gmail, Calendar, Drive, or Docs with a service account token, use the token in a normal fetch() call to the Google API you need.
---
URL: https://help.jsworkflows.com/md/workflow-api/lifecycle-hooks.txt
Lifecycle Hooks
Run code when a workflow run finishes or fails using onWorkflowComplete and onWorkflowError.
Two optional methods on the Workflow class are called once when the entire run reaches a terminal state. They are useful for sending a completion notification, recording a summary, or alerting on failure.
Signatures
export class Workflow {
async start(data, headers, api) { ... }
async onWorkflowComplete(api) {
// Called once when all steps (including all fan-out branches) complete successfully
}
async onWorkflowError(err, api) {
// Called once when a step throws an uncaught error that terminates the run
}
}
Both hooks are optional. Omit either one if you do not need it.
Available API inside hooks
Hooks receive a restricted api object. Only the following are available:
| Method | Available |
| api.getOAuthToken() | ✅ |
| api.google. | ✅ |
| console.log() | ✅ |
| api.dedupe() | ✅ |
| api.runStore. | ✅ |
| api.sendEmail() | ✅ |
| api.getAttachment() / api.deleteAttachment() | ❌ |
| api.scheduleNextStep() | ❌ |
| api.sendToFlow() | ❌ |
| api.waitForEvent() | ❌ |
| api.csv.* | ❌ |
The restriction above applies to the api object only. Global fetch() is still available inside hooks, so you can call external APIs directly after obtaining any required token or secret.
Behavioral rules
onWorkflowComplete fires only when all branches finish
For fan-out workflows, the platform tracks a branch counter. onWorkflowComplete is called only when the counter reaches zero, meaning every parallel branch has finished. It does not fire per-branch.
onWorkflowError is skipped if the failing step had already scheduled a next step
If a step calls api.scheduleNextStep() and then throws an uncaught error, onWorkflowError is not called. The scheduled step was already armed, so the run is considered still in progress, not failed.
onWorkflowError fires only for uncaught step failures
If a step catches an error internally and returns normally, the run does not fail and onWorkflowError is not called. This includes retry logic that catches an error, schedules a retry, and returns.
onWorkflowError fires at most once
If a second fan-out branch fails after the run has already been marked failed, the hook is not called again.
A hook that throws does not affect the run status
If your hook implementation throws, the error is caught and logged. The run's final status (completed or failed) is unaffected.
Example: Slack notification on completion or failure
This example uses fetch() plus api.getOAuthToken() because the pattern is explicit and works across any OAuth-connected service:
export class Workflow {
async start(data, headers, api) {
for (const item of data.line_items) {
await api.scheduleNextStep({
delay: 10,
action: 'processItem',
payload: { itemId: item.id },
});
}
}
async processItem({ itemId }, headers, api) {
await api.runStore.increment('processed');
// ... do work
}
async onWorkflowComplete(api) {
const processed = await api.runStore.get('processed') ?? 0;
const { token } = await api.getOAuthToken('my-slack');
await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': Bearer ${token} },
body: JSON.stringify({
channel: '#ops',
text: Run complete: ${processed} items processed.,
}),
});
}
async onWorkflowError(err, api) {
const { token } = await api.getOAuthToken('my-slack');
await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': Bearer ${token} },
body: JSON.stringify({
channel: '#ops',
text: Workflow failed: ${err.message},
}),
});
}
}
Using api.runStore to pass data into hooks
api.runStore persists for the lifetime of a run and is accessible inside both hooks. Use it to collect per-step data (counters, error lists) and compile a final report in the hook.
async processItem({ itemId }, headers, api) {
try {
// ... process
await api.runStore.increment('succeeded');
} catch (err) {
await api.runStore.push('errors', { itemId, reason: err.message });
await api.runStore.increment('failed');
}
}
async onWorkflowComplete(api) {
const succeeded = await api.runStore.get('succeeded') ?? 0;
const failed = await api.runStore.get('failed') ?? 0;
const errors = await api.runStore.get('errors') ?? [];
console.log(Done. ${succeeded} succeeded, ${failed} failed.);
// send report, write to sheet, etc.
}
---
URL: https://help.jsworkflows.com/md/workflow-api/logging.txt
Logging
Write debug output from workflow steps.
Use standard console methods directly in your workflow code. console.log() messages appear in the DEBUG output of the test runner and in run history details.
Usage
export class Workflow {
async start(data, headers, api) {
console.log('Order received:', data.id);
console.log('Customer:', data.email, 'Total:', data.totalprice);
}
}
Multiple arguments are joined with a space, exactly as they would be in normal JavaScript runtime logging.
Console methods
The most useful console methods are captured:
| Method | Prefix in DEBUG output |
| console.log() | (none) |
| console.info() | ℹ️ |
| console.warn() | ⚠️ |
| console.error() | ❌ |
Use console.warn() and console.error() to draw attention to recoverable problems and failures respectively. All output appears in the same DEBUG array in the order it was written.
Logging objects
Pass objects directly — they are serialised automatically in the output:
console.log('Order details:', { id: data.id, status: data.financialstatus });
Live runs vs test runs
In live runs, console.log() output is stored as part of the run record and is visible in the Runs panel in the dashboard. In test runs, it appears immediately in the test result panel.
Output is captured per step — each step shows its own DEBUG array.
---
URL: https://help.jsworkflows.com/md/workflow-api/overview.txt
Overview
The api object and global env available in every workflow step.
Every executable step method, including start and any named continuation step you schedule later, receives three arguments:
async start(data, headers, api) { ... }
| Argument | Description |
| data | The trigger payload or step payload (order object, HTTP body, scheduled context, or the payload from api.scheduleNextStep()) |
| headers | Trigger/request headers when the step was entered from an HTTP-style source. For scheduled runs and most continuation steps, treat this as optional context and do not rely on it unless the trigger docs say it is present. |
| api | The JsWorkflows API object, which exposes platform capabilities |
api methods
| Method | Description |
| api.scheduleNextStep() (/workflow-api/scheduling/) | Schedule the next step (or fan-out to multiple steps) |
| api.waitForEvent() (/workflow-api/wait-for-event/) | Pause a run and resume it when an external event arrives |
| api.dedupe() (/workflow-api/scheduling/#deduplication--apidedupe) | Prevent duplicate processing within a time window for your own business logic or external-event idempotency |
| api.getOAuthToken() (/oauth/overview/) | Get a valid access token for a connected OAuth service |
| api.google.getServiceAccountToken() (/workflow-api/google-service-account/) | Get a Google access token using a service account key (cached 58 min) |
| api.google.sheet. (/workflow-api/google-service-account/#apigooglesheet) | Google Sheets helpers such as appendRows, readRows, updateRows, and clearRange |
| api.csv. (/workflow-api/csv/) | Import a CSV from a URL, split it into chunks, and process each chunk in a separate step |
| api.runStore.* (/workflow-api/state/) | Store and retrieve values scoped to the current run |
| console.log() (/workflow-api/logging/) | Write debug output visible in the test runner and run history |
| api.getAttachment() (/workflow-api/attachments/) | Retrieve an email attachment as an ArrayBuffer (email-trigger workflows only) |
| api.deleteAttachment() (/workflow-api/attachments/) | Delete an email attachment from storage (email-trigger workflows only) |
| api.sendToFlow() (/triggers/shopify-flow/#pushing-data-into-flow-from-any-step) | Fire a JsWorkflow Data Received trigger in Shopify Flow from any step |
| api.sendEmail() (/workflow-api/send-email/) | Send a notification email to your configured notification address (paid plans) |
Global env object
All secret variables you store via More actions → Manage variables in the workflow editor are available as properties on the global env object:
export class Workflow {
async start(data, headers, api) {
const apiKey = env.MYAPIKEY; // a secret you stored
const shop = env.SHOPIFYSTORE; // auto-injected: "mystore.myshopify.com"
const ver = env.SHOPIFYAPIVERSION; // auto-injected: e.g. "2026-04"
}
}
Two values are always present regardless of your stored secrets:
| Key | Value |
| env.SHOPIFYSTORE | Your store's myshopify.com domain |
| env.SHOPIFYAPIVERSION | The Shopify API version configured for your app |
For Shopify webhook triggers, duplicate delivery protection already happens before your workflow code runs. api.dedupe() is still useful when you need your own workflow-specific or business-level deduplication rules.
Making HTTP requests
Use the global fetch() to call any URL. For Shopify Admin API requests, the platform automatically injects the X-Shopify-Access-Token header when the URL matches your store domain, so no extra configuration is needed:
// Shopify GraphQL, token injected automatically
const res = await fetch(
https://${env.SHOPIFYSTORE}/admin/api/${env.SHOPIFYAPI_VERSION}/graphql.json,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query { orders(first: 10) { nodes { id name } } } }),
}
);
const { data } = await res.json();
For OAuth-connected services, retrieve the token with api.getOAuthToken() and add it yourself:
const { token } = await api.getOAuthToken('my-slack');
const res = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${token},
},
body: JSON.stringify({ channel: '#orders', text: 'Hello' }),
});
Lifecycle hooks
Your Workflow class can optionally define two lifecycle hooks that run once when the entire run reaches a terminal state. These hooks do not use the normal (data, headers, api) step signature:
export class Workflow {
async start(data, headers, api) { ... }
// Called when the entire run (all branches) completes successfully
async onWorkflowComplete(api) { ... }
// Called when a step throws an uncaught error that terminates the run
async onWorkflowError(err, api) { ... }
}
Inside hooks, api is restricted to: getOAuthToken, google, log, dedupe, runStore, and sendEmail. The methods scheduleNextStep, waitForEvent, and csv are not available. The global fetch() API is still available inside hooks.
See Lifecycle Hooks (/workflow-api/lifecycle-hooks/) for full behavioral rules and examples.
---
URL: https://help.jsworkflows.com/md/workflow-api/scheduling.txt
Scheduling Steps
Schedule subsequent steps and build multi-step or fan-out workflows.
api.scheduleNextStep() queues the next step to execute after a delay. Calling it multiple times in the same step creates a fan-out — parallel branches that each run independently.
Signature
await api.scheduleNextStep({ delay, action, payload });
| Option | Type | Description |
| delay | number \ | string | Seconds until the next step runs. Accepts a number or a duration string. Minimum: 10 seconds. Maximum: 400 days |
| action | string | The method name on your Workflow class to call. It must be a named continuation step, not start |
| payload | any | Data passed as the data argument to the next step |
Duration strings
Instead of raw seconds, you can pass a human-readable string:
| String | Duration |
| "30 sec" / "30 seconds" | 30 seconds |
| "5 min" / "5 minutes" | 5 minutes |
| "2 hr" / "2 hours" | 2 hours |
| "1 day" / "3 days" | 1 or 3 days |
| "1 week" / "2 weeks" | 1 or 2 weeks |
Unsupported strings throw an error. Months and years are not supported because they are not a fixed number of seconds.
Simple linear chain
export class Workflow {
async start(data, headers, api) {
// Schedule the next step to run in 10 minutes
await api.scheduleNextStep({
delay: '10 min',
action: 'sendReminder',
payload: { orderId: data.id, email: data.email },
});
}
async sendReminder({ orderId, email }, headers, api) {
console.log('Sending reminder for order', orderId, 'to', email);
// ... send email via fetch()
}
}
Fan-out (parallel branches)
Call scheduleNextStep() multiple times in one step to launch parallel branches. Each call is an independent branch. The run is not marked complete until all branches finish.
export class Workflow {
async start(data, headers, api) {
// Process each line item in parallel
for (const item of data.lineitems) {
await api.scheduleNextStep({
delay: 10,
action: 'processItem',
payload: { itemId: item.id, title: item.title },
});
}
// start() completes here — all N branches are now running concurrently
}
async processItem({ itemId, title }, headers, api) {
console.log('Processing item:', title);
// Each branch runs independently
}
}
Note: Credits are consumed per step execution. A fan-out of 10 branches uses 10 credits for those branches, plus 1 for the start step that scheduled them.
Chain depth
Each call to scheduleNextStep() increments the chain depth. Fan-out branches at the same level all share the same depth counter — a fan-out of 100 branches from start has depth 1, not 100.
There is no per-plan chain depth limit. A platform-wide circuit breaker prevents runaway infinite loops.
Deduplication — api.dedupe()
api.dedupe() prevents a step from running more than once for the same logical event within a time window. It is useful for business-level idempotency, duplicate suppression on HTTP or external-event workflows, and other workflow-specific safety rules.
const { locked } = await api.dedupe(uniqueid, delay);
| Argument | Type | Default | Description |
| uniqueid | string | required | An identifier that should be unique per logical event |
| delay | number | 120 | How long (in seconds) to hold the lock before it expires |
Returns { locked: boolean, key: string }. If locked is true, another invocation already acquired the lock — skip processing.
For Shopify webhook triggers, duplicate delivery protection already happens before your workflow code runs. api.dedupe() is still useful there when you want additional workflow-specific or business-level deduplication. When you do use it in a Shopify webhook start(...), prefer headers['x-shopify-event-id'] as the primary dedupe key and fall back to a stable payload-based key if the header is missing.
export class Workflow {
async start(data, headers, api) {
const dedupeKey =
headers?.['x-shopify-event-id'] | |
order-${data.admingraphqlapiid ?? data.id};
const { locked } = await api.dedupe(dedupeKey, 300);
if (locked) {
console.log('Duplicate event for order', data.id, '- skipping');
return;
}
// Safe to process — this invocation holds the lock
await api.scheduleNextStep({ delay: 10, action: 'processOrder', payload: data });
}
}
---
URL: https://help.jsworkflows.com/md/workflow-api/secrets.txt
Secrets & Environment Variables
Store API keys and other secrets for use in a workflow's steps.
Secrets are key-value pairs you store per workflow. They are encrypted at rest and exposed to that workflow's code through the global env object.
Secrets are scoped to the workflow they are defined in. A secret added to one workflow is not accessible from any other workflow.
Storing a secret
1. Open a workflow in the code editor.
2. Click More actions (top-right menu).
3. Select Manage variables.
4. Enter a name (e.g. SLACKBOTTOKEN) and its value.
5. Save. It is then available in that workflow's code as env.YOURKEY.
Accessing secrets in code
Read any secret by name via the global env object:
export class Workflow {
async start(data, headers, api) {
const slackToken = env.SLACKBOTTOKEN;
await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${slackToken},
},
body: JSON.stringify({ channel: '#orders', text: 'New order received' }),
});
}
}
You do not import env. It is already available globally in workflow code.
Platform-injected variables
Two variables are always present on env in every workflow, regardless of what you have stored:
| Key | Value |
| env.SHOPIFYSTORE | Your store's myshopify.com domain, e.g. "mystore.myshopify.com" |
| env.SHOPIFYAPIVERSION | The latest Shopify API version configured for the app |
When not to use workflow secrets
- Do not store Shopify Admin API access tokens here. When you call your store's Admin API with fetch(), authentication is injected automatically.
- Do not store rotating OAuth access tokens here. For connected OAuth services, use api.getOAuthToken(handle) instead.
- Do not store normal non-secret configuration here unless it truly needs to be private. Use template config or regular constants for non-sensitive values.
Security
- Secrets are encrypted at rest.
- Secrets are scoped to the individual workflow. No other workflow can read them.
- Secret values are not intended to be shown in the editor UI, but your code can still expose them if you log, return, or send them to another system.
- Deleting a secret prevents later workflow invocations from reading it.
---
URL: https://help.jsworkflows.com/md/workflow-api/send-email.txt
Workflow Notification Emails
Send internal workflow notifications to your configured notification address.
api.sendEmail() sends an internal workflow notification to the notification address configured in Settings. It is intended for operational alerts: notifying you or your team when a workflow reaches a specific state, needs attention, completes with a useful summary, or encounters a condition worth flagging.
This API is not a general email-sending feature. It always sends to the single notification email address in Settings and is designed for internal workflow notifications, not customer or external messaging.
Requirements
- To send emails successfully, a notification email address should be set in Settings.
- The shop must be on a paid plan (Starter or above).
- Each plan includes a sending quota that resets at the start of each billing cycle.
| Plan | Quota |
| Free | Not available |
| Starter | 500 emails / billing cycle |
| Growth | 2,000 emails / billing cycle |
| Business | 5,000 emails / billing cycle |
| Enterprise | 10,000 emails / billing cycle |
Signature
await api.sendEmail({ subject, text, html });
| Option | Type | Required | Description |
| subject | string | yes | Email subject line |
| text | string | no | Plain-text body |
| html | string | no | HTML body |
At least one of text or html must be provided. If both are given, email clients that support HTML will show the HTML version; others fall back to plain text.
No per-message routing fields are supported. Do not pass to, cc, bcc, replyTo, or custom sender fields to api.sendEmail().
Returns Promise<{ sent: boolean, reason?: string }>.
| Result | When |
| { sent: true } | Email was sent successfully |
| { sent: false, reason } | Email was skipped (free plan, quota reached, no notification email set). The workflow continues normally. |
An exception is thrown only when the platform itself is unable to send the email due to an internal configuration or delivery error.
The to address is always the notification email from Settings. It cannot be changed per-call.
Common use cases
The strongest uses for api.sendEmail() are internal operational notifications such as:
- alert me when something needs attention
- send me a completion summary
- notify me when a workflow failed or skipped important records
These are the kinds of notifications this API is designed for: a workflow can notify your team, but it cannot email arbitrary recipients.
api.sendEmail() is available in both regular workflow steps and in the onWorkflowComplete and onWorkflowError lifecycle hooks. Sending an alert from onWorkflowError is a common pattern: it fires regardless of which step failed.
Example: alert when something needs attention
export class Workflow {
async start(data, headers, api) {
if (data.refundamount > 500) {
await api.scheduleNextStep({
delay: 10,
action: 'flagForReview',
payload: { orderId: data.orderid, amount: data.refund_amount },
});
}
}
async flagForReview(data, headers, api) {
await api.sendEmail({
subject: High-value refund flagged: order ${data.orderId},
text: A refund of $${data.amount} was requested for order ${data.orderId} and requires review.,
});
}
}
Example: send a completion summary
async sendSummary(data, headers, api) {
await api.sendEmail({
subject: Inventory sync complete: ${data.updatedCount} items updated,
text: Sync finished. Updated: ${data.updatedCount}. Skipped: ${data.skippedCount}. Errors: ${data.errorCount}.,
html:
Inventory sync complete
| Updated | ${data.updatedCount} |
| Skipped | ${data.skippedCount} |
| Errors | ${data.errorCount} |
,
});
}
Example: send a completion summary from run state
If earlier steps collect totals or summary data in api.runStore, you can read that data in onWorkflowComplete(api) and send a single completion email after the entire run finishes.
export class Workflow {
async start(data, headers, api) {
for (const row of data.rows) {
await api.scheduleNextStep({
delay: 10,
action: 'processRow',
payload: { row },
});
}
}
async processRow(data, headers, api) {
if (data.row.status === 'updated') {
await api.runStore.increment('updatedCount');
} else if (data.row.status === 'skipped') {
await api.runStore.increment('skippedCount');
} else if (data.row.status === 'error') {
await api.runStore.increment('errorCount');
}
}
async onWorkflowComplete(api) {
const updatedCount = (await api.runStore.get('updatedCount')) ?? 0;
const skippedCount = (await api.runStore.get('skippedCount')) ?? 0;
const errorCount = (await api.runStore.get('errorCount')) ?? 0;
await api.sendEmail({
subject: 'Inventory sync complete',
text: Updated: ${updatedCount}. Skipped: ${skippedCount}. Errors: ${errorCount}.,
});
}
}
Example: notify when records were skipped
async notifySkippedRows(data, headers, api) {
if (!data.skippedCount) return;
await api.sendEmail({
subject: Inventory sync skipped ${data.skippedCount} records,
text: The workflow skipped ${data.skippedCount} records and may need review.,
});
}
Checking the result
api.sendEmail() returns { sent: false, reason } rather than throwing when the email cannot be sent due to a predictable condition. This keeps the workflow running. Check the result if you need to react or log:
async notifyTeam(data, headers, api) {
const result = await api.sendEmail({
subject: 'Order flagged for review',
text: Order ${data.orderId} has been flagged.,
});
if (!result.sent) {
console.log('Email skipped:', result.reason);
}
}
Conditions that return { sent: false } (workflow continues):
- No notification email set. Configure one in Settings.
- Free plan. Upgrade to a paid plan to use this feature.
- Quota reached. The sending limit for your plan has been exhausted. It resets at the start of your next billing cycle.
Conditions that throw (workflow fails):
- The platform email service is not configured.
- The email delivery service returns an error.
Usage and quota
Current email usage for your billing cycle is visible in Settings under the notification email field. The quota resets at the start of each billing cycle, not on the calendar month. Upgrading your plan starts a fresh quota window immediately.
Note: api.sendEmail() always delivers to the single notification address in Settings. It cannot be used to email customers or any external address.
---
URL: https://help.jsworkflows.com/md/workflow-api/state.txt
Run State
Store and share data across steps within a single workflow run.
api.runStore lets you persist values between steps within the same run. All keys are scoped to the current run and are deleted automatically when the run completes.
Methods
| Method | Description |
| api.runStore.get(key) | Read a stored value (returns null if not set) |
| api.runStore.set(key, value) | Write any JSON-serialisable value |
| api.runStore.push(key, item) | Append an item to an ordered list |
| api.runStore.increment(key, delta?) | Atomically increment a counter (default delta: 1) |
| api.runStore.delete(key) | Remove a key |
Use run state for compact data
api.runStore is best for small run-scoped state such as:
- IDs and keys
- row numbers or chunk indexes
- counters
- warnings and compact error records
- summary inputs for onWorkflowComplete
Do not use api.runStore as a cache for large raw datasets such as:
- full parsed CSV row arrays
- raw attachment text or buffers
- entire imported API responses
For large files or imports, parse or import once, then pass compact references between steps such as chunk keys, row ranges, cursors, or counters.
Each stored value or pushed item should stay compact. Use run state for small operational data, not multi-megabyte payloads.
Passing data between steps
export class Workflow {
async start(data, headers, api) {
// Store the customer ID before scheduling the next step
await api.runStore.set('customerId', data.customer.id);
await api.scheduleNextStep({
delay: 10,
action: 'sendEmail',
payload: {},
});
}
async sendEmail(data, headers, api) {
// Retrieve the value stored in the previous step
const customerId = await api.runStore.get('customerId');
console.log('Sending to customer:', customerId);
}
}
Collecting results across fan-out branches
api.runStore.push() is safe to call from concurrent parallel branches. Use it to append items without building your own shared array in memory:
export class Workflow {
async start(data, headers, api) {
// Fan out — schedule one branch per line item
for (const item of data.line_items) {
await api.scheduleNextStep({
delay: 10,
action: 'processItem',
payload: { item },
});
}
}
async processItem({ item }, headers, api) {
// Each parallel branch pushes its result
await api.runStore.push('results', { id: item.id, status: 'done' });
}
}
Do not use get() plus set() on the same key for shared aggregation in fan-out workflows. Parallel branches can race and overwrite each other. Use increment() for counters and push() for collected records instead.
Atomic counters
api.runStore.increment() is safe for concurrent fan-out use. The optional second argument adds or subtracts that delta:
const newCount = await api.runStore.increment('processedCount');
// Decrement:
const newCount = await api.runStore.increment('processedCount', -1);
The method returns the new value after the increment.
Note: Run state is scoped to a single run. It cannot be shared between different workflow runs. For cross-run persistence, write to Shopify metafields or an external store via fetch().
---
URL: https://help.jsworkflows.com/md/workflow-api/wait-for-event.txt
Waiting for Events
Pause a workflow run and resume it when an external event arrives.
api.waitForEvent() pauses a workflow branch and returns a resume URL. When that URL is called by a human or another system, the workflow continues from the specified next step. The caller can pass data via a POST JSON body or via URL query parameters on a GET request.
Use api.waitForEvent() for human approvals, external callbacks, and indefinite waits on outside systems. Do not use it for fixed delays or polling. For those cases, use api.scheduleNextStep() (/workflow-api/scheduling/).
Human-in-the-loop
The most common use case is inserting a human decision point into an automated workflow. The workflow pauses after reaching a step that requires human judgement, sends a message with the resume URL (via Slack, email, or another channel), and waits. When the person clicks the link or submits a form, the workflow resumes with their input.
Common examples:
- an order flagged as high-risk is held for manual review before being fulfilled
- a refund above a threshold requires manager approval before being issued
- a generated document or email draft is sent to a team member for sign-off before delivery
- a supplier change request pauses for a purchasing manager to confirm
- a customer escalation waits for a support lead to decide the resolution before the workflow applies it
In each case the workflow handles the automation before and after the decision, and the human only sees the specific question they need to answer.
Signature
const { token, resumeUrl } = await api.waitForEvent(options);
| Option | Type | Default | Description |
| action | string | required | Workflow method to call when resumed. Must be a named step method ('start' is not allowed) |
| payload | any | required | Data passed to the next step alongside any event data sent by the caller. Pass {} if you do not need stored payload fields |
| timeout | number \ | string | 86400 | Seconds until the token expires. Accepts numbers (seconds) or strings like "7 days", "2 hours". Max: 30 days |
| redirecturl | string | null | If set, browser callers are redirected to this URL after processing instead of seeing the default confirmation page |
Returns { token: string, resumeUrl: string }.
Example: wait for approval
export class Workflow {
async start(data, headers, api) {
// Pause this branch and get a resume URL
const { resumeUrl } = await api.waitForEvent({
action: 'onApproved',
payload: { orderId: data.id },
timeout: 86400, // 24 hours
});
// Send the URL to someone who needs to act
const { token } = await api.getOAuthToken('my-slack');
await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': Bearer ${token} },
body: JSON.stringify({
channel: '#approvals',
text: Order ${data.id} needs approval. <${resumeUrl} | Approve>,
}),
});
// start() returns here. The run stays open and waits for the resume URL to be called.
}
async onApproved(data, headers, api) {
// data contains the payload passed to waitForEvent; data.event holds what the caller sent
console.log('Order approved:', data.orderId);
// Continue processing...
}
}
Resuming the workflow
The resume URL accepts both GET and POST. In the resumed step, the caller's data is always available at data.event, and the payload you set in waitForEvent is merged into data directly.
POST with a JSON body
Send a JSON body to pass structured data to the resumed step:
curl -X POST "{resumeUrl}" \
-H "Content-Type: application/json" \
-d '{ "approvedBy": "jane@example.com", "note": "Looks good" }'
In the resumed step:
async onApproved(data, headers, api) {
data.event.approvedBy // "jane@example.com"
data.event.note // "Looks good"
data.orderId // from the payload set in waitForEvent
}
GET with query parameters
A GET request is useful for click-to-approve links in emails or Slack messages. Add any data you need as query parameters:
{resumeUrl}?approved=true&reviewer=alice
In the resumed step, query parameters arrive as strings at data.event:
async onApproved(data, headers, api) {
data.event.approved // "true"
data.event.reviewer // "alice"
data.orderId // from the payload set in waitForEvent
}
If no query parameters are present, data.event is null.
Do not call api.scheduleNextStep() later in the same method invocation after api.waitForEvent(). The branch is already parked.
Example: approve or reject by email
Generate one resume URL and append different query parameters to create two distinct action links sent in the same email. The resumed step reads data.event to know which link was clicked.
export class Workflow {
async start(data, headers, api) {
const { resumeUrl } = await api.waitForEvent({
action: 'onReviewed',
payload: { orderId: data.id, amount: data.totalPrice },
timeout: '24 hours',
});
const approveUrl = ${resumeUrl}?approve=true;
const rejectUrl = ${resumeUrl}?approve=false;
const resp = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${env.SENDGRIDAPIKEY},
},
body: JSON.stringify({
personalizations: [
{
to: [{ email: 'manager@example.com' }],
},
],
from: {
email: 'noreply@example.com',
name: 'JsWorkflows',
},
subject: Refund request for order ${data.id},
content: [
{
type: 'text/plain',
value: A refund of ${data.totalPrice} was requested for order ${data.id}.\n\nApprove: ${approveUrl}\nReject: ${rejectUrl},
},
],
}),
});
if (!resp.ok) {
const errText = await resp.text();
throw new Error(SendGrid email send failed: ${resp.status} ${errText});
}
}
async onReviewed(data, headers, api) {
if (data.event.approve === 'true') {
console.log('Refund approved for order', data.orderId);
// issue the refund...
} else {
console.log('Refund rejected for order', data.orderId);
// notify the customer...
}
}
}
Note that query parameter values are always strings, so compare data.event.approve against 'true' and 'false' rather than boolean values.
This example uses SendGrid's Mail Send API and expects a SENDGRIDAPIKEY secret in the workflow.
Browser redirect
Set redirecturl to send the browser to a custom page after resuming instead of the default confirmation screen:
const { resumeUrl } = await api.waitForEvent({
action: 'onApproved',
payload: { orderId: data.id },
redirecturl: 'https://mystore.com/approved',
});
Resume responses
The resume endpoint adapts its response to the caller:
- browser requests that accept HTML are resumed in the background and receive either the default confirmation page or your redirecturl
- server or API callers are awaited and receive JSON: { ok: true } on success or { ok: false, error: '...' } on failure
Timeout behaviour
If the resume URL is not called before timeout seconds have elapsed, the token expires. The run is then healed to failed either by the RunTracker alarm or sooner on the next runs-list refresh, for example when the merchant opens the runs dashboard.
Caution: The resume URL is single-use. After a successful resume, later calls normally return 404. Test runs return { token: 'test-mode', resumeUrl: '' }, and the step does not actually pause.
Duration strings
The timeout option accepts human-readable duration strings in addition to raw seconds:
| String | Seconds |
| "30 sec" / "30 seconds" | 30 |
| "5 min" / "5 minutes" | 300 |
| "2 hr" / "2 hours" | 7200 |
| "1 day" / "7 days" | 86400 / 604800 |
| "1 week" / "2 weeks" | 604800 / 1209600 |
Unsupported strings throw. Months and years are not supported.