Skip to content

Engineering

Building a Shopify Flow integration: triggers, actions, and the gotchas that cost us a cycle

By Jahangir Alam · July 4, 2026 · 11 min read

Short version: a Shopify Flow integration has three moving parts - triggers (events your app emits into Flow), actions (steps Flow calls back into your app), and templates (ready-made workflows). The extension configs are simple TOML; the traps are in the platform's undocumented rules. The two that cost us a real debugging cycle: trigger field keys and action field keys use opposite casing conventions, and every field you declare on a trigger is required in every payload - so an "optional reference field" is a contradiction that silently breaks guest events. Here's the full map, from shipping 10 triggers, 3 actions, and 6 templates in QuotWay.

What a Flow integration is (and who can use it)

Shopify Flow is Shopify's no-code automation builder. Your app plugs in three ways:

  • Triggers (flow_trigger) - your app emits an event ("quote submitted"), and merchants build workflows that start from it.
  • Actions (flow_action) - a Flow workflow calls your app to do something ("update quote status"), passing fields the merchant configured.
  • Templates (flow_template) - pre-built workflows that appear in the Flow template gallery so merchants start from a working example instead of a blank canvas.

One myth worth killing first: Flow is not Plus-only for app-provided extensions. A public App Store app's Flow triggers and actions are available to merchants on any paid Shopify plan, not just Plus. (Merchant-authored Flow workflows have their own plan rules, but that's separate from your extensions being available.) We gate ours by packaging choice - triggers on our Professional tier, actions on Enterprise - not because the platform forces it.

The three-part anatomy

Each trigger, action, and template is its own extension directory with a shopify.extension.toml. There's no shared "Flow app" object - you compose the integration out of many small extensions.

Triggers: your app → Flow

A trigger declares a name and a set of fields that become the workflow's available data. Field keys are the public payload contract:

# extensions/flow-quote-submitted/shopify.extension.toml
[[extensions]]
type = "flow_trigger"
name = "Quote submitted"
handle = "quote-submitted"

  [[settings.fields]]
  type = "single_line_text_field"
  key = "Quote id"            # ← Title Case, with spaces

  [[settings.fields]]
  type = "number_decimal"
  key = "Total amount"        # merchants add "Total amount >= X" conditions

  [[settings.fields]]
  type = "single_line_text_field"
  key = "Customer id"         # EMPTY string for guests — see gotcha #2

You emit an event by calling the flowTriggerReceive mutation with the trigger handle and a payload keyed by exactly those field names. We emit from a small flow/emit service on every state transition (submitted, proposal sent, accepted, countered, declined, expired, converted, and the three approval events - 10 in all).

Actions: Flow → your app

An action declares the fields a merchant fills in, and a runtime_url Flow POSTs to when the step runs. We route all actions to one endpoint and dispatch by handle:

# extensions/flow-update-quote-status/shopify.extension.toml
[[extensions]]
type = "flow_action"
name = "Update quote status"
handle = "update-quote-status"
runtime_url = "/api/flow/action"     # one endpoint for every action
validation_url = "/api/flow/validate" # save-time field validation
schema = "./schema.graphql"
return_type_ref = "QuoteActionResult"

  [[settings.fields]]
  type = "single_line_text_field"
  key = "quote_id"            # ← snake_case (opposite of triggers!)
  required = true

  [[settings.fields]]
  type = "single_line_text_field"
  key = "target_status"       # IN_REVIEW | DECLINED | EXPIRED
  required = true

The /api/flow/action handler verifies Shopify's HMAC, dispatches on the action handle, is idempotent on action_run_id (Flow can retry), and returns a typed result matching return_type_ref. We shipped three actions: update status, send a notification, and assign to staff - each Enterprise-gated at the service entry point, not the route.

Templates: a working starting point

flow_template extensions bundle a .flow file (the workflow definition) plus locale copy. They're how a merchant gets "when a high-value quote arrives, post to Slack" without wiring it themselves. We shipped six. The catch: templates only appear in the gallery after Flow-team review (a few business days), so they ship on a different clock than the rest of your app.

The gotchas (each one is a scar)

Gotcha What bites you The fix
Opposite key casing Trigger field keys are Title Case With Spaces ("Total amount"); action field keys are snake_case (quote_id). Mixing them up = fields that never bind. Memorize the split. Triggers title-case, actions snake-case.
All trigger fields are required Every field you declare must be present in every payload. An "optional reference field" is a contradiction - omit it once (e.g. a guest quote with no customer) and the whole event is rejected. Use plain-text fields that can be "" instead of reference fields; let merchants branch in Flow with "is not empty".
Config pages can't save App-provided config pages for a step are read-only - intent.finish({properties}) is a no-op (confirmed by Shopify staff). A custom picker UI can never persist its selection. Use native [[settings.fields]] + a validation_url that rejects bad values at save time.
No lifecycle callbacks for CLI triggers Triggers defined in TOML get no subscribe/unsubscribe webhooks, so you can't know who's listening. Gate emission on a subscription record and it fails closed - nobody gets events. Emit for every entitled shop; flowTriggerReceive simply no-ops when there's no listener.
.flow files self-verify Template files carry a self-SHA-256 digest and are editor exports. Hand-edit one and it's rejected. Never hand-edit .flow; re-export from the Flow editor with merchant-specific fields left blank. preInstallNote lives in the locale JSON, not the TOML.
Schema changes need a coordinated release Change a trigger's fields and your emitted payloads must match the deployed schema. Deploy the extension without pushing the emitter (or vice-versa) and every event is rejected. Release together: shopify app deploy and your backend push in lockstep.
Types are strict number_decimal must be a JSON number, not a string; a *_reference field wants the numeric legacy id, not the GID. Coerce at the emit boundary; prefer plain fields over reference fields (see gotcha #2).

The first two are the ones that cost us production debugging time. "All fields required" is especially sneaky because it only breaks the subset of events missing a field - for us, guest quotes (no customer id) and a couple of edge transitions - so it looks intermittent until you realize it's structural.

Debugging Flow in production

Flow failures are quiet - a rejected payload doesn't surface to the merchant. Log both sides of the boundary and grep for them:

  • Emit side: log a flow.emit.sent on success and flow.emit.user_errors when flowTriggerReceive returns userErrors (schema mismatch shows up here).
  • Action side: log the dispatched handle, the action_run_id, and the result. HMAC failures and idempotency short-circuits should each leave a line.

When a trigger "doesn't fire," check the emit log first: if you see sent with no error, the payload was accepted and the issue is in the merchant's workflow; if you see user_errors, your payload doesn't match the deployed trigger schema (almost always gotcha #6).

A reasonable first milestone

  1. One trigger for your most important event, with only the fields a workflow actually needs (fewer fields = fewer "required" traps). Title-case keys.
  2. Emit it for every entitled shop from your existing state-transition code - no subscription gating.
  3. One action POSTing to a single runtime_url, dispatched by handle, HMAC-verified, idempotent on action_run_id, with a validation_url instead of a config page.
  4. Deploy the extension and the backend together, and verify on a dev store with a real workflow.
  5. Add templates last - they gate on Flow-team review, so don't let them block the trigger/action launch.

Triggers and actions are individually small; the integration's difficulty is entirely in the platform rules above. Get the casing and the "all fields required" contract right on day one and the rest is wiring events you already emit.

Further reading

See how QuotWay handles this on your store.