---
title: "Transform Functions: Reshape Payloads with JavaScript"
description: "Run your own function on every webhook request: reshape or enrich the payload, drop requests you do not need, or answer the caller directly."
canonical: "https://docs.workflow-webhooks.app/transform-functions"
---

# Transform functions

> [!NOTE]
> **Rolling out gradually**
> Transform functions are being enabled store by store. If your webhook has no **Function** tab yet, the update has not reached your store.

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

```javascript
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.

```javascript
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.

```javascript
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 |

```javascript
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
}
```

> [!WARNING]
> **It is not a database**
> There is no expiry, no transaction and no atomic counter: two runs writing the same key at the same moment means the last one wins. Storage and key counts are capped by your plan, each run may make at most 100 storage calls, and every call is a network round trip, so keep values small and calls few. Do not store customer personal data in it unless you also delete it yourself when asked to.

## 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.

```javascript
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.

```javascript
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
}
```

> [!NOTE]
> **Response rules**
> A status between 100 and 599, a body up to 256KB (anything that is not a string is sent as JSON), and only `content-type` and `x-*` response headers. On a webhook with a synchronous response, answering from the function replaces waiting for Flow's reply.

## 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.fetch` is the only way out, and internal or private addresses are refused.
- `ctx.shopify` allows 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.
