Transform functions
Run your own function on every webhook request: reshape or enrich the payload, drop requests you do not need, or answer the caller directly.
Mapping picks values out of a request. A transform function goes further: it is your own JavaScript, running on every request before Shopify Flow, so you can reshape the payload, look something up, decide whether the request is worth a workflow run at all, or answer the caller yourself.
It lives on the Function tab of a webhook. An empty editor means off.
The shape of a function
export default async function transform(payload, ctx) {
// payload - the parsed request body (JSON, form or XML)
// return an object -> it becomes the payload for mapping and Flow
// return null -> nothing is sent to Flow, and nothing counts
return { ...payload, source: "warehouse" }
}What ctx gives you
ctx.request |
method, contentType, the request headers (secrets masked) and query |
ctx.webhook |
The webhook's id and name |
ctx.shop |
Your myshopify domain |
ctx.log(...) |
Writes a line you can read in the test panel and in History |
await ctx.fetch(url, init) |
Calls a public API |
await ctx.shopify(query, variables) |
Reads store data with Admin GraphQL |
ctx.storage |
Keeps small values between runs (see below) |
ctx.respond(body, options) |
Answers the caller immediately |
Reshaping a payload
The most common use: turn a sender's structure into the few values your workflow needs.
export default async function transform(payload) {
const order = payload.data.attributes
return {
orderNumber: String(order.number),
email: order.customer.email.toLowerCase(),
total: Number(order.total_cents) / 100,
}
}Dropping requests you do not need
Return null and the request stops there: no Flow run, no history entry, and nothing counted against your plan. The caller gets 200 with { "skipped": true }, so it does not retry.
export default async function transform(payload, ctx) {
if (payload.status !== "paid" || payload.total < 100) {
ctx.log("skipping", payload.id, payload.status, payload.total)
return null
}
return payload
}Remembering things between runs
ctx.storage is a small key-value store that belongs to your shop. It is the tool for "have I seen this before?": store an id when an event arrives, and skip the next request that carries the same id, even if it comes days later.
| Call | Does |
|---|---|
await ctx.storage.get(key) |
The stored value, or null |
await ctx.storage.set(key, value) |
Stores any JSON value, overwriting |
await ctx.storage.delete(key) |
Removes it; true if it existed |
await ctx.storage.list({ prefix, limit, cursor }) |
Key names and sizes, page by page |
export default async function transform(payload, ctx) {
const key = `seen:${payload.id}`
if (await ctx.storage.get(key)) return null
await ctx.storage.set(key, { at: Date.now() })
return payload
}Enriching with store data
ctx.shopify runs read-only Admin GraphQL queries for your store, so a workflow can start with data the sender never had. It only sees what you allow: grant the scopes you need under Store data access on the Function tab. Nothing is granted by default, and mutations are refused.
export default async function transform(payload, ctx) {
const data = await ctx.shopify(
`query($q: String!) {
productVariants(first: 1, query: $q) { nodes { id title price } }
}`,
{ q: `sku:${payload.sku}` },
)
const variant = data.productVariants.nodes[0]
if (!variant) return null
return { sku: payload.sku, variantId: variant.id, price: variant.price }
}Answering the caller yourself
ctx.respond() sends a response back right away, which turns the webhook into a small API endpoint: validate something, compute an answer, and reply in one request. Flow still runs with whatever you return.
export default async function transform(payload, ctx) {
const ok = typeof payload.email === "string" && payload.email.includes("@")
ctx.respond({ accepted: ok }, { status: ok ? 200 : 422 })
return ok ? payload : null
}Testing
The Test panel on the Function tab runs the code in the editor, including unsaved changes, against a sample payload. It shows the return value, the response your function would send, the Flow fields it produces, your log lines and the duration. Nothing fires Flow, nothing is written to History, and nothing counts toward your plan. Storage calls are real, though: a test run reads and writes the same store as live requests.
When a function fails
If your code throws, times out or returns something unusable, the request is rejected with 422 and the failure is recorded in History with the error and your log lines, so you can see what happened. Fix the code and replay that entry: the replay runs the new version.
Limits
- 5 seconds and 64MB per run, and 50,000 characters of code.
- No npm packages.
ctx.fetchis the only way out, and internal or private addresses are refused. ctx.shopifyallows 10 calls per run, queries only, limited to the scopes you granted.- Every run that reaches Flow is a normal invocation and counts toward your plan. A skipped request does not.
