A Shopify Flow trigger extension is a shopify.extension.toml file in your app that declares an event and the fields it carries. Your backend fires it by calling the Admin GraphQL flowTriggerReceive mutation with the trigger’s handle and a payload, and merchants build whatever workflow they like on top. Two parts need more care than the tutorial gives them: the field naming rules and the lifecycle callback.
This post covers the trigger side only. Flow actions (where a workflow calls into your app) share the TOML conventions but have their own request format, and they deserve their own write-up.
Who can actually use your trigger?
Merchants need two things installed: your app and Shopify Flow. Flow is available on any paid Shopify plan, but the Flow overview carries one restriction to check before you quote for the work. If your app is a custom app rather than a public one, its Flow extensions only work on Shopify Plus stores (and dev stores on the Plus plan). A custom app for a standard-plan merchant can define a trigger, deploy it, and the merchant will never see it in the editor.
The older way of doing this, a “trigger discovery webhook” created in the Partner Dashboard, is deprecated. Shopify’s lifecycle events guide says those legacy extensions have to be recreated with Shopify CLI before they can be edited at all, so if you inherit one, budget for the rewrite rather than a tweak.
How do you define the trigger in shopify.extension.toml?
Run shopify app generate extension and pick Flow Trigger. The CLI drops a folder under extensions/ with one TOML file. Here is a complete one for an app that runs product auctions:
[[extensions]]
name = "Auction bid placed"
type = "flow_trigger"
handle = "auction-bid-placed"
description = "Fires when a customer places a bid on an auction."
[settings]
[[settings.fields]]
type = "customer_reference"
description = "The customer who placed the bid."
[[settings.fields]]
type = "product_reference"
[[settings.fields]]
type = "number_decimal"
key = "Bid amount"
[[settings.fields]]
type = "single_line_text_field"
key = "Auction name"
Three rules from the trigger reference matter here.
The handle is permanent. It must be unique across the app’s extensions, alphanumeric and hyphens only, and it cannot change once you have run dev or deploy. Pick it as carefully as a database column name, because every call to the mutation will carry it.
Reference fields take no key. customer_reference, order_reference, product_reference, company_reference and company_contact_reference each have a fixed payload key (customer_id, order_id, product_id, company_id, company_contact_id) and you send the numeric legacy id, not the gid:// string. This is the field type that makes the trigger useful: once Flow has a customer id, the merchant can condition on customer / amountSpent or tags and use {{customer.email}} in an action, none of which your payload had to include. A trigger that carries customer_reference is also exposed to Shopify’s marketing automations.
Custom field keys are human text, not identifiers. The allowed characters are letters and spaces, in sentence case: Bid amount, not bid_amount or bidAmount. Flow converts the key to camelCase for template variables in the editor, so the merchant sees {{ bidAmount }}, but your payload has to use the key verbatim. The custom types are boolean, email, single_line_text_field, number_decimal, url, and schema.<Type> for objects.
That last one needs the optional schema property on the extension, pointing at a GraphQL SDL file. Flow accepts String, Int, Float, Boolean, ID, enums, objects, lists and the ! non-null marker. Unions, interfaces, custom scalars and directives are not supported. The complex data types guide has the full Bid and Auction example. Reach for it when a flat list of fields would force the merchant to reassemble an object; otherwise stay flat, because flat fields render as individual conditions in the editor.
How do you fire it with flowTriggerReceive?
Once the extension is deployed (or running as a draft under shopify app dev on your dev store) and a merchant has an enabled workflow using it, your backend sends one mutation per event:
type AuctionBid = {
customerId: number;
productId: number;
amount: number;
auctionName: string;
};
export async function fireBidPlaced(
shop: string,
accessToken: string,
bid: AuctionBid,
): Promise<void> {
const response = await fetch(
`https://${shop}/admin/api/2026-10/graphql.json`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': accessToken,
},
body: JSON.stringify({
query: `mutation FireTrigger($handle: String, $payload: JSON) {
flowTriggerReceive(handle: $handle, payload: $payload) {
userErrors { field message }
}
}`,
variables: {
handle: 'auction-bid-placed',
payload: {
customer_id: bid.customerId,
product_id: bid.productId,
'Bid amount': bid.amount,
'Auction name': bid.auctionName,
},
},
}),
},
);
const { data, errors } = await response.json();
if (errors?.length) throw new Error(JSON.stringify(errors));
const userErrors = data?.flowTriggerReceive?.userErrors ?? [];
if (userErrors.length) throw new Error(JSON.stringify(userErrors));
}
Written against Admin API 2026-10. The signature is flowTriggerReceive(handle: String, payload: JSON), and the older body: String argument is deprecated; the reference notes that handle and payload do not exist on 2023-07 or earlier, which is well past removal anyway.
Every field declared in the TOML is required in the payload. Leave one out and the workflow will not run. The payload, keys included, must stay under 50 KB; go over and the response carries the validation error Properties size exceeds the limit of 50000 bytes and nothing starts. Calls count against the cost-based Admin API throttle like any other mutation, so a trigger fired inside a hot loop needs the same budgeting.
An empty userErrors array means Flow accepted the event, not that a workflow ran. There is no return value telling you which workflows matched, how many stores had one enabled, or whether the merchant’s actions succeeded. Treat it as fire-and-forget and log the event id on your side.
Why do you need the lifecycle callback?
Without it, you fire the mutation for every event on every store that has your app installed, including the stores where nobody has built a workflow. That is wasted API budget on your side and, on a busy app, a lot of it.
The fix is a second extension of type flow_trigger_lifecycle_callback:
[[extensions]]
name = "Flow lifecycle callback"
handle = "flow-lifecycle-callback"
type = "flow_trigger_lifecycle_callback"
uid = "generated-by-the-cli-do-not-edit"
url = "/flow/lifecycle"
Since the 8 October 2026 changelog entry, url can be a relative path. shopify app dev resolves it against your tunnel URL and shopify app deploy against the application_url in your app config, which has to be HTTPS before you deploy. Protocol-relative //host/path URLs are not supported. Before that change you had to hard-code an absolute URL and update it every time the tunnel moved, which made the callback easy to put off.
Flow POSTs JSON to that URL whenever a merchant enables or disables a workflow that uses your trigger:
{
"flow_trigger_definition_id": "Auction bid placed",
"has_enabled_flow": true,
"shop_id": 123456789,
"shopify_domain": "example-store.myshopify.com",
"timestamp": "2026-10-19T09:30:00.000Z"
}
The docs type shop_id as a number but their own example quotes it, so accept both.
Two hard constraints. You must answer with a 2xx within five seconds, or the merchant cannot enable the workflow and gets told to try later. And the request carries an x-shopify-hmac-sha256 header you verify with your app’s client secret, the same header webhooks use. Our post on verifying Shopify webhook HMACs covers the raw-body and timing-safe details; the handler below assumes you have that in place.
import { timingSafeEqual, createHmac } from 'node:crypto';
type LifecycleCallback = {
flow_trigger_definition_id: string;
has_enabled_flow: boolean;
shop_id: number;
shopify_domain: string;
timestamp: string;
};
export async function handleLifecycle(request: Request): Promise<Response> {
const rawBody = await request.text();
const header = request.headers.get('x-shopify-hmac-sha256') ?? '';
const digest = createHmac('sha256', process.env.SHOPIFY_API_SECRET!)
.update(rawBody, 'utf8')
.digest('base64');
const a = Buffer.from(digest);
const b = Buffer.from(header);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response('invalid hmac', { status: 401 });
}
const body = JSON.parse(rawBody) as LifecycleCallback;
// Newer timestamps win; a late-arriving "disabled" must not undo a newer "enabled".
await db.flowTriggerState.upsertIfNewer({
shop: body.shopify_domain,
trigger: body.flow_trigger_definition_id,
enabled: body.has_enabled_flow,
seenAt: new Date(body.timestamp),
});
return new Response(null, { status: 200 });
}
Store it, then gate fireBidPlaced on it: only call the mutation for shops where enabled is true. The timestamp ordering matters because callbacks can arrive out of order and the docs are explicit that the newer one is authoritative. Do any slow work (queueing, notifications) after the 200, not before.
When is a Flow trigger the wrong tool?
If the thing reacting to the event is your own code, use a webhook or your own job queue. A Flow trigger exists so the merchant can react without you writing the reaction.
If the merchant wants your app to do something when a Shopify event happens, that is a Flow action, with a runtime_url Flow calls, not a trigger. The mix-up to avoid is building a trigger and then expecting the workflow to “call back” into the app with results. It does not. Triggers are one-way.
And if your merchants are on standard plans and you only ship a custom app, do not build this at all until the app is public or the merchant moves to Plus.
For a public app with events merchants care about (a bid, a review, a sync finished, a fraud score), I would ship the trigger with a customer_reference or order_reference wherever one exists and keep the custom fields flat. Add the lifecycle callback on day one, now that relative URLs make it cheap, and gate every mutation call on the stored has_enabled_flow. Skip the schema file until a merchant asks for a nested object.
Whoooop builds and maintains Shopify apps for UK merchants, including Flow triggers and actions that let their automations reach into third-party systems. If your app has events a merchant would want to automate on, our Shopify development team can scope the extension and the backend changes together.