---
title: "Building a Shopify Flow integration: triggers, actions, and the gotchas that cost us a cycle"
description: "Trigger and action field keys use opposite casing conventions, and every trigger field is required - two traps that cost us a debugging cycle."
url: "https://www.quotway.com/blog/building-a-shopify-flow-integration"
type: "blog post"
category: "Engineering"
published: "2026-07-04"
updated: "2026-10-07"
verified: "2026-10-07"
shopify_api_version: "2026-10"
audience: "Shopify app developers building Flow triggers, actions and templates"
scope: "A Flow app integration at API 2026-10: the trigger, action and template extensions, the opposite casing conventions, the all-fields-required contract, and the deployment and debugging traps from our own build"
locale: "en"
source: "QuotWay - B2B Quote & Negotiation App for Shopify"
---

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

**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](/features/shopify-flow).

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

[Shopify Flow](https://shopify.dev/docs/apps/build/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**:

```toml
# 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:

```toml
# 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. |
| **Lifecycle callbacks are a separate extension** | A `flow_trigger` extension on its own doesn't tell you who's listening. Flow reports workflows being turned on and off only through a separate `flow_trigger_lifecycle_callback` extension, and once you add one, a merchant can't enable a workflow unless your endpoint answers with a `2xx` within five seconds. Gate emission on subscription records you never receive and it fails **closed** - nobody gets events. | Either emit for **every entitled shop**, which is what we do, or add the lifecycle-callback extension and keep its endpoint fast and up. |
| **`.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). |

*Corrected 7 October 2026:* the lifecycle-callback row previously said that triggers defined in TOML get no subscribe/unsubscribe callbacks and that `flowTriggerReceive` no-ops when nobody is listening. Shopify already documented lifecycle callbacks as a separate CLI extension when this post was written, and it doesn't document the no-op behaviour, so the row now says what the [lifecycle events guide](https://shopify.dev/docs/apps/build/flow/track-lifecycle-events) says.

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.

If you're a merchant rather than a developer: the integration described here ships in QuotWay as [Shopify Flow triggers and actions](/features/shopify-flow), on Professional and up - see [plans and pricing](/pricing). Systems outside Shopify, such as an ERP, a CRM or a headless storefront, connect through the [QuotWay API and webhooks](/features/api-webhooks) on Enterprise instead; [six example integrations](/blog/quotway-api-webhooks-recipes) show which events and endpoints each one uses.

## Further reading

Shopify pages re-verified on 7 October 2026; the gotchas above are from our own build, and the lifecycle-callback row was corrected against Shopify's [lifecycle events guide](https://shopify.dev/docs/apps/build/flow/track-lifecycle-events) on that date:

- Shopify: [Flow for app developers](https://shopify.dev/docs/apps/build/flow), [triggers](https://shopify.dev/docs/apps/build/flow/triggers), [actions](https://shopify.dev/docs/apps/build/flow/actions), [templates](https://shopify.dev/docs/apps/build/flow/templates)
- Shopify Help Center: [Shopify Flow](https://help.shopify.com/en/manual/shopify-flow) - Flow is free on Basic, Grow, Advanced and Plus, which is why an app's triggers and actions reach any paid plan
- Sibling post: [Building a Shopify Sidekick app extension](/blog/building-shopify-sidekick-app-extensions)
- What the triggers are for on the merchant's side: [ERP, CRM and PIM integration patterns for Shopify B2B quoting](/blog/shopify-b2b-erp-crm-pim-integration-patterns)
