# Welcome!

## Welcome to FieldsRaven developer docs

Here you'll find all the documentation you need to get up and running with FieldsRaven and/or Storefront Kit. Here is the [app page](https://apps.shopify.com/fieldsraven) on the Shopify app store.

## Want to jump right in?

Feeling like an eager beaver? Jump in to the quick start docs and get making your first request:

* [**Quick Start**](https://github.com/BeSpark/fieldsraven-docs/tree/main/quick-start/README.md) — create a raven, paste the generated code, make your first request.
* [**FAQ**](https://github.com/BeSpark/fieldsraven-docs/tree/main/faq/README.md) — the questions that come up most, including deleting metafields and why a write is not instant.

## What FieldsRaven does

FieldsRaven lets your Shopify theme write **metafields from the storefront** — saving a customer's answer, choice or list straight onto their Shopify record, at the moment they give it to you.

Normally that needs a server: Shopify's Admin API can't be called from a theme, so collecting anything custom means standing up middleware to hold an API token and relay the write. FieldsRaven is that layer. You write Liquid, HTML and JavaScript; it handles authentication, queueing, Shopify's rate limits, and retries.

It is built for developers. There is no admin UI for composing forms — you build the storefront experience you want, and FieldsRaven carries the data to Shopify.

## How it fits together

1. **Create a Raven.** A raven is the configuration that says *which* metafield a submission writes to — its owner resource, namespace, key and value type. See [Raven identity](/raven-identity).
2. **Copy the generated code.** The **Get Code** panel emits the Liquid, HTML and JavaScript for that specific raven, with the request signature computed inline. Paste it into your theme.
3. **The customer submits.** Your storefront posts to the app proxy, signed. FieldsRaven accepts it, queues it, and writes the metafield in the background — so a `200` means *accepted*, not yet *stored*.
4. **Verify if you need to.** A successful write returns a receipt you can look up later — see [Workflows and receipts](/overview/workflows).

## What people build with it

The recipes below are complete, working implementations rather than sketches:

* [**Quiz profiles**](/example-features/shopify-quiz-profiles) — save structured quiz answers to one JSON metafield.
* [**Wishlist**](/example-features/wish-list) — add and remove products from a customer-owned list.
* [**Saved product configurations**](/example-features/saved-product-configurations) — let customers save, reopen and update a configured product.
* [**Vehicle garage**](/example-features/shopify-vehicle-garage) — add, select and remove customer vehicles.
* [**Product registration**](/example-features/customer-product-registration) — append validated registrations, with serial and purchase details.
* [**Marketing preferences**](/example-features/shopify-klaviyo-sync) — collect preferences and map them to Klaviyo profile properties.

The shape they share: something the customer tells you on the storefront, kept on their Shopify customer record, available to Liquid, Flow, and every other app that reads Shopify data.

## Where the data can go

A metafield write is the baseline. A raven can also, optionally:

* **Sync to** [**Klaviyo**](/klaviyo) — turn a customer's submission into profile properties for segmentation and flows.
* **Sync to** [**Airtable**](/airtable) — append each submission as a row.
* **Mirror into** [**Shopify metaobjects**](/metaobject-sync) — turn a JSON submission into a typed, referenceable Shopify record.

## Beyond the storefront

* [**MCP overview**](/overview) — an MCP endpoint that lets an AI client discover field types, preview and manage ravens, and verify submissions, with read or manage tokens you control.
* [**App embeds**](/app-embeds) — theme blocks you enable without writing code, including the Storefront Kit the other embeds depend on.
* [**Troubleshooting**](/troubleshooting) — the exact errors the app returns, and what each one means.


# Quick Start

Create a Raven, paste its signed storefront configuration, and send a direct app-proxy request.

FieldsRaven lets a Shopify theme submit a value to a configured metafield through an app proxy. A Raven defines the owner resource, namespace, key, type, and any optional integration.

{% hint style="danger" %}
Only let a logged-in customer submit a customer-owned Raven. The signed resource id identifies that customer, but your theme still needs the login boundary and appropriate storefront UI.
{% endhint %}

## 1. Create a Raven

In the app, create a Raven for the metafield you want to write. For a first request, a customer-owned `custom.favourite_colour` Raven with type `single_line_text_field` is easy to inspect in Shopify Admin.

Open **Ravens** from the FieldsRaven section of Shopify Admin's app sidebar, then choose **New Raven**. The FieldsRaven app name or icon returns to the Dashboard.

## 2. Paste Get Code

Open the Raven's **Get Code** panel and paste its Liquid tab into the theme. The generated code uses the Raven's real id and the correct Liquid resource. It computes the HMAC inline at render time and exposes the signed values to JavaScript.

A representative generated block looks like this:

```liquid
{% if customer %}
  {%- liquid
    assign fr_resource_id = customer.id
    assign fr_digest = "generated-raven-id" | append: fr_resource_id
    assign fr_mac = fr_digest | hmac_sha256: shop.metafields.fields_raven.api_secret
  -%}
  <script>
    window.FR_CUSTOM__CUSTOMER_FAVOURITE_COLOUR = {
      ravenId: "generated-raven-id",
      resourceId: "{{ fr_resource_id }}",
      ravenMac: "{{ fr_mac }}"
    }
  </script>
{% endif %}
```

Do not copy the representative id. Paste the block generated for your Raven. A Raven's resource, namespace, key and value type are fixed after creation; if you need a different identity, create a replacement Raven and paste its newly generated configuration.

## 3. Send the direct request

The current endpoint is `PUT /apps/raven/create_metafield`. The request body is wrapped in a `raven` object. A successful response includes a `submission.receipt` your script can use to [confirm the write actually landed](/verify-submission) (FieldsRaven 0.31.9+).

The app-proxy value parameter is always a string (`value`). Send scalar values as strings. For a JSON object or array, serialize the local payload with `JSON.stringify` at the request boundary.

### Scalar value

```javascript
async function saveFavouriteColour(colour) {
  const config = window.FR_CUSTOM__CUSTOMER_FAVOURITE_COLOUR
  const response = await fetch("/apps/raven/create_metafield", {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ raven: {
      raven_id: config.ravenId,
      resource_id: config.resourceId,
      raven_mac: config.ravenMac,
      value: String(colour)
    } })
  })

  const contentType = response.headers.get("content-type") || ""
  if (response.status === 429) throw new Error("Too many requests. Try again shortly.")
  if (!contentType.includes("application/json")) throw new Error("Unexpected non-JSON response.")

  let result
  try {
    result = await response.json()
  } catch (error) {
    throw new Error("Unexpected invalid JSON response.")
  }
  if (!response.ok) {
    const message = typeof result?.message === "string" ? result.message : "FieldsRaven rejected the request."
    throw new Error(message)
  }
  return result
}
```

### JSON object or array

```javascript
const payload = {
  skin_type: "balanced",
  goals: ["hydration", "texture"]
}

const config = window.FR_CUSTOM__CUSTOMER_QUIZ_PROFILE
const response = await fetch("/apps/raven/create_metafield", {
  method: "PUT",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ raven: {
    raven_id: config.ravenId,
    resource_id: config.resourceId,
    raven_mac: config.ravenMac,
    value: JSON.stringify(payload)
  } })
})
```

## Request fields

| Name          | Type   | Description                                                 |
| ------------- | ------ | ----------------------------------------------------------- |
| `raven_id`    | string | The Raven id from Get Code.                                 |
| `resource_id` | string | The owner id, byte-identical to the value signed by Liquid. |
| `raven_mac`   | string | The HMAC generated for that Raven and resource id.          |
| `value`       | string | A scalar string or a serialized JSON object/array.          |

## What a response means

A successful 200 response means FieldsRaven accepted and queued the metafield write. It does not prove that Shopify, Klaviyo, a metaobject, or another optional integration has completed. Update local UI only after acceptance, and avoid claiming downstream completion from the app-proxy response.

A 422 response means the request was rejected. Show its message safely with `textContent`. Treat 429 as retryable, guard non-JSON responses, and restore disabled controls in a `finally` block after network failures.

## Optional storefront helper

Storefront Kit is optional. The complete direct requests above are the canonical starting point and work without an app embed. If a merchant already loads the helper, it can reduce repeated client code, but the endpoint boundary and acceptance semantics stay the same.

## Build a feature

Continue with the [Example features](/example-features) for complete, customer-guarded recipes that read existing Liquid values and handle accepted, rejected, rate-limited, non-JSON, and network responses.


# MCP overview

FieldsRaven's production Model Context Protocol endpoint is `https://fieldsraven.app/mcp`. It lets an MCP client inspect supported field types and Ravens, preview configuration, manage Ravens with explicit permission, and verify storefront submissions without exposing shop secrets or customer values.

## Eligibility and permissions

Any merchant with the already installed FieldsRaven app who can open authenticated Settings may create MCP tokens. FieldsRaven does not perform a separate MCP billing lookup or require a second MCP plan.

Choose the narrowest capability you need:

* A **read** token can discover types and Ravens, preview changes, and verify submissions.
* A **manage** token includes every read permission and can create or update Ravens. A manage token can change Raven configuration, so protect it like an administrator credential.

See [Tools and permissions](/overview/tools-reference) for the exact matrix.

## Safe token lifecycle

Create and revoke MCP tokens from authenticated FieldsRaven Settings at `/settings` within the embedded app. The old numeric `/shops/<shop-id>/settings/index` path is compatibility-only; use `/settings` for bookmarks and instructions. Token creation uses a short-lived, shop-bound, one-time form nonce. The plaintext has a one-time reveal and is shown exactly once; FieldsRaven stores only its digest and cannot recover it later.

After copying the one-time plaintext, save it manually in a project-root `.env`, confirm `.env` is ignored by git, and never commit it. Load `FIELDSRAVEN_MCP_TOKEN` into the environment that launches the client, then leave it out of client configuration files, URLs, support messages, logs, and screenshots. Revoking a token takes effect on later authenticated requests.

If the creation response is lost, Settings may show an active token row even though you never received the plaintext. Revoke that row and mint a replacement. Do not expect FieldsRaven to recover or re-show the token.

Continue with [Client setup](/overview/client-setup).

## From storefront to verification

The MCP tools configure Ravens; your theme still submits customer data through the FieldsRaven storefront app proxy. Follow the [Quick Start](/quick-start) for the current direct-request example and theme snippet placement, then use the receipt returned by a successful storefront submission with the verification tools described in [Workflows and receipts](/overview/workflows).

For help, email <karim@fieldsraven.app>. Never include bearer tokens, receipts, submitted values, customer email addresses, or integration payloads in a support message.


# Client setup

Follow these three steps for Codex CLI, Claude Code, or Cursor Agent. The token stays in your project's local environment; client configuration and AI chat contain only the environment-variable name.

## 1. Create an access token

First, create a token in authenticated FieldsRaven Settings at `/settings` within the embedded app. The numeric `/shops/<shop-id>/settings/index` path is compatibility-only; use the shopless path for bookmarks and instructions.

Choose **Read** for inspection and verification, or **Manage** only when the client must create or update Ravens. The plaintext is revealed once. If it is lost, revoke the active token row and create a replacement.

## 2. Save the token in this project

Add the token manually to a project-root `.env` using this shape:

```dotenv
FIELDSRAVEN_MCP_TOKEN=fr_mcp_paste-your-token-here
```

Confirm `.env` is in `.gitignore` before adding the token. Never commit `.env`, and never paste the token into an MCP configuration or AI chat. The `.env` file is storage, not automatic process loading.

### macOS or Linux

From the project root, load the variables into the current terminal:

```sh
set -a && source .env && set +a
```

Start or restart your AI client from this same terminal so it inherits the variable. Missing `FIELDSRAVEN_MCP_TOKEN`? Add the line to `.env` manually. Do not ask an AI agent to open or inspect `.env`.

### Windows PowerShell

From the project root, load only the named value into the current PowerShell process:

```powershell
$line = Get-Content .env | Where-Object { $_ -match '^FIELDSRAVEN_MCP_TOKEN=' } | Select-Object -First 1
$env:FIELDSRAVEN_MCP_TOKEN = $line.Substring($line.IndexOf('=') + 1)
```

Start or restart your AI client from this same terminal so it inherits the variable. Missing `FIELDSRAVEN_MCP_TOKEN`? Add the line to `.env` manually. Do not ask an AI agent to open or inspect `.env`.

## 3. Connect your AI agent

Use the exact project destination and configuration for your client. You can also copy the matching prompt into a new agent session; it contains the complete configuration but never the bearer token.

### Codex CLI

Codex CLI `0.145.0`

Project destination: `.codex/config.toml`

```toml
[mcp_servers.fieldsraven]
url = "https://fieldsraven.app/mcp"
bearer_token_env_var = "FIELDSRAVEN_MCP_TOKEN"
```

Tell Codex CLI:

```
Set up the FieldsRaven MCP server for this project in Codex CLI.

1. Confirm that .env is ignored by git. Do not open, read, print, log,
   move, or commit its contents. If the FIELDSRAVEN_MCP_TOKEN environment
   variable is unavailable, ask me to add it to .env manually; never ask
   me to paste the token into chat.
2. Create .codex/config.toml with the exact configuration between the markers
   below. It connects to https://fieldsraven.app/mcp and references only the environment-
   variable name FIELDSRAVEN_MCP_TOKEN. Never write the token into the
   configuration file.

BEGIN FIELDSRAVEN MCP CONFIG
[mcp_servers.fieldsraven]
url = "https://fieldsraven.app/mcp"
bearer_token_env_var = "FIELDSRAVEN_MCP_TOKEN"
END FIELDSRAVEN MCP CONFIG

3. Tell me how to load the project .env for my operating system and
   restart Codex CLI so the new process inherits the variable. Do not load
   or inspect .env yourself.
4. In the fresh client process, confirm that FieldsRaven exposes exactly
   11 tools, then call get_app_info and report the negotiated protocol and
   server version. Stop and explain any missing prerequisite; do not mutate
   store data merely to test connectivity.
```

### Claude Code

Claude Code `2.1.220`

Project destination: `.mcp.json`

```json
{
  "mcpServers": {
    "fieldsraven": {
      "type": "http",
      "url": "https://fieldsraven.app/mcp",
      "headers": {
        "Authorization": "Bearer ${FIELDSRAVEN_MCP_TOKEN}"
      }
    }
  }
}
```

Tell Claude Code:

```
Set up the FieldsRaven MCP server for this project in Claude Code.

1. Confirm that .env is ignored by git. Do not open, read, print, log,
   move, or commit its contents. If the FIELDSRAVEN_MCP_TOKEN environment
   variable is unavailable, ask me to add it to .env manually; never ask
   me to paste the token into chat.
2. Create .mcp.json with the exact configuration between the markers
   below. It connects to https://fieldsraven.app/mcp and references only the environment-
   variable name FIELDSRAVEN_MCP_TOKEN. Never write the token into the
   configuration file.

BEGIN FIELDSRAVEN MCP CONFIG
{
  "mcpServers": {
    "fieldsraven": {
      "type": "http",
      "url": "https://fieldsraven.app/mcp",
      "headers": {
        "Authorization": "Bearer ${FIELDSRAVEN_MCP_TOKEN}"
      }
    }
  }
}
END FIELDSRAVEN MCP CONFIG

3. Tell me how to load the project .env for my operating system and
   restart Claude Code so the new process inherits the variable. Do not load
   or inspect .env yourself.
4. In the fresh client process, confirm that FieldsRaven exposes exactly
   11 tools, then call get_app_info and report the negotiated protocol and
   server version. Stop and explain any missing prerequisite; do not mutate
   store data merely to test connectivity.
```

### Cursor Agent

Cursor Agent `2026.08.04-aaa8809`

Project destination: `.cursor/mcp.json`

```json
{
  "mcpServers": {
    "fieldsraven": {
      "type": "http",
      "url": "https://fieldsraven.app/mcp",
      "headers": {
        "Authorization": "Bearer ${env:FIELDSRAVEN_MCP_TOKEN}"
      }
    }
  }
}
```

Tell Cursor Agent:

```
Set up the FieldsRaven MCP server for this project in Cursor Agent.

1. Confirm that .env is ignored by git. Do not open, read, print, log,
   move, or commit its contents. If the FIELDSRAVEN_MCP_TOKEN environment
   variable is unavailable, ask me to add it to .env manually; never ask
   me to paste the token into chat.
2. Create .cursor/mcp.json with the exact configuration between the markers
   below. It connects to https://fieldsraven.app/mcp and references only the environment-
   variable name FIELDSRAVEN_MCP_TOKEN. Never write the token into the
   configuration file.

BEGIN FIELDSRAVEN MCP CONFIG
{
  "mcpServers": {
    "fieldsraven": {
      "type": "http",
      "url": "https://fieldsraven.app/mcp",
      "headers": {
        "Authorization": "Bearer ${env:FIELDSRAVEN_MCP_TOKEN}"
      }
    }
  }
}
END FIELDSRAVEN MCP CONFIG

3. Tell me how to load the project .env for my operating system and
   restart Cursor Agent so the new process inherits the variable. Do not load
   or inspect .env yourself.
4. In the fresh client process, confirm that FieldsRaven exposes exactly
   11 tools, then call get_app_info and report the negotiated protocol and
   server version. Stop and explain any missing prerequisite; do not mutate
   store data merely to test connectivity.
```

The placeholder syntax differs between Claude Code and Cursor Agent. Preserve it exactly. In the fresh client process, confirm that FieldsRaven exposes exactly 11 tools, then call `get_app_info`. Report the negotiated protocol and server version; do not create, update, or otherwise mutate store data merely to test connectivity.

These versions, destinations, and file shapes are the tested onboarding contract. Live client connection checks are a separate release gate; configuration alone does not prove connectivity.


# Tools and permissions

FieldsRaven exposes exactly eleven MCP tools. Read tokens may call the nine tools other than `create_raven` and `update_raven`. Manage tokens include every read permission and may also call those two mutation tools.

| Order | Tool                          | Read | Manage | Purpose                                                                                    |
| ----: | ----------------------------- | :--: | :----: | ------------------------------------------------------------------------------------------ |
|     1 | `get_app_info`                |  Yes |   Yes  | Where to get help, and what this server can do.                                            |
|     2 | `list_resource_types`         |  Yes |   Yes  | List the Shopify resources a Raven can write to — customer, product, page, and so on.      |
|     3 | `list_value_types`            |  Yes |   Yes  | List the value types a Raven can use, and what each expects on the wire.                   |
|     4 | `list_metaobject_definitions` |  Yes |   Yes  | List the shop's Shopify metaobject definitions, a page at a time.                          |
|     5 | `list_ravens`                 |  Yes |   Yes  | List the shop's Ravens and their settings.                                                 |
|     6 | `get_raven`                   |  Yes |   Yes  | Read a single Raven, including the revision number you need in order to update it.         |
|     7 | `preview_raven_configuration` |  Yes |   Yes  | Check a Raven's configuration before committing to it. Writes nothing, anywhere.           |
|     8 | `create_raven`                |  No  |   Yes  | Create a Raven. Runs the same validation the app's own form does.                          |
|     9 | `update_raven`                |  No  |   Yes  | Update mutable Raven configuration using its expected revision.                            |
|    10 | `verify_submission`           |  Yes |   Yes  | Look up a storefront submission by its receipt, and optionally check it against Shopify.   |
|    11 | `list_failed_operations`      |  Yes |   Yes  | List submissions that failed in a way the merchant can act on, with what to do about each. |

Tool arguments never include `shop_id`; the bearer token determines the authenticated shop. A Raven, Field, cursor, receipt, or failed operation from another shop never becomes accessible by supplying an identifier.

Mutation tools use optimistic revisions and idempotency keys. Read [Workflows and receipts](/overview/workflows) before automating changes, and use [Errors, limits, and security](/overview/errors-and-limits) for the stable error contract and retry rules.


# Workflows and receipts

## Configure a Raven safely

Discover, then preview, then create, then update — in that order:

1. **Discover** — list the supported resource types, value types and metaobject definitions.
2. **Preview** — check the Raven you intend to create. Nothing is written.
3. **Create** — only once the preview comes back valid.
4. **Update** — against the revision you actually read, not one you assumed.

Updates use optimistic revision checks. On a revision conflict, read the Raven again and decide whether your change is still correct. Send an idempotency key with each create or update attempt; reusing a key with a different request produces an idempotency conflict instead of silently applying different work.

Preview may read a few things it needs to check against — but it writes nothing to Shopify, saves no Raven, and queues no work. Create and update can return a partial outcome when safe remote effects completed but the local optimistic write did not; inspect `remote_effects` and `local_applied` before reconciling.

## Submit, poll, and investigate

Submit, then poll, then go deep, then list failed operations — in that order:

1. **Submit** through the storefront app proxy, as your theme already does.
2. **Poll** the receipt it returns, using state mode.
3. **Go deep** only once the work has settled, and only if you need read-back proof.
4. **List failed operations** when something failed in a way the merchant can fix.

Every successful storefront submission response contains one stateless encrypted receipt for each committed Field. `create_metafield` and deprecated `create_update_metafield` each return one receipt. `create_multiple_metafields` returns one ordered receipt per committed Field. A successful `delete_metafield` response does not return a receipt. Receipts are generated inside the Field transaction. Their default and maximum expiry is seven days. If receipt issuance fails, the transaction rolls back the Field or the whole batch, and a failed batch returns no receipts.

A successful receipt is never reissued after response loss. Preserve the response received by the storefront; a later search cannot reconstruct that success receipt. `list_failed_operations` may issue a fresh receipt only for an existing retained failed-operation Field.

Fields are retained only while their installed Shop row exists. Fields cascade-delete when that Shop is destroyed or uninstalled, so receipt expiry never promises evidence beyond Field existence.

Malformed, expired, cross-shop, and missing-Field receipts all return the same `SUBMISSION_NOT_FOUND`. The response deliberately does not say which of the four it was, so a receipt cannot be used to probe for what exists.

## State and deep verification

State mode returns fresh local evidence on every call, including the safe overall status and configured hop states. Pending work includes retry guidance and is a valid domain result, not a transport failure.

Deep mode does everything state mode does, then reads back from Shopify, Klaviyo, the metaobject and the customer-link to confirm the write actually landed. It only does this for work that has finished. It compares expected and observed values in memory but returns only stable verdicts, safe messages, evidence kinds, and observation times. Airtable is always `state_only`; FieldsRaven does not claim independent Airtable read-back.

Deep calls have both a time limit and a quota. Results are briefly cached, but a cache hit still counts against the quota. See [Errors, limits, and security](/overview/errors-and-limits) before writing a polling loop.

## Privacy boundary

A receipt is a lookup token, not a record of what was submitted. Tool output and support diagnostics omit submitted values, customer email addresses, raw remote values, integration JSON, access tokens, and unredacted vendor errors. Follow the [Quick Start](/quick-start) for storefront theme placement; do not move customer submission data into MCP configuration files.


# Errors, limits, and security

## Stable error codes

FieldsRaven returns fixed safe messages and structured details. Recovery guidance never includes a receipt, submitted value, customer email, raw vendor response, or secret.

| Order | Code                         | Safe recovery                                                                           |
| ----: | ---------------------------- | --------------------------------------------------------------------------------------- |
|     1 | `INVALID_INPUT`              | Correct the named safe validation fields and retry.                                     |
|     2 | `FORBIDDEN_CAPABILITY`       | Use a manage token for create\_raven or update\_raven.                                  |
|     3 | `RAVEN_NOT_FOUND`            | List Ravens, then retry with a Raven owned by this shop.                                |
|     4 | `SUBMISSION_NOT_FOUND`       | Check the receipt, then use list\_failed\_operations for a retained failed operation.   |
|     5 | `CONFIGURATION_CONFLICT`     | Preview the configuration and resolve the reported safe prerequisite conflicts.         |
|     6 | `REVISION_CONFLICT`          | Read the Raven again, then retry with its current revision.                             |
|     7 | `IDEMPOTENCY_CONFLICT`       | Reuse the original request for that idempotency key or choose a new key.                |
|     8 | `RATE_LIMITED`               | Wait for details.retry\_after\_seconds and any transport Retry-After delay, then retry. |
|     9 | `SHOPIFY_SCOPES_NOT_GRANTED` | Grant the reported Shopify scopes, then retry.                                          |
|    10 | `UPSTREAM_UNAVAILABLE`       | Wait and retry; contact support if the condition persists.                              |
|    11 | `UPSTREAM_TIMEOUT`           | Wait and retry; contact support if the condition persists.                              |
|    12 | `PARTIAL`                    | Inspect remote\_effects and local\_applied, then reconcile before retrying.             |
|    13 | `INTERNAL_ERROR`             | Retry once; if it persists, contact support with the request ID.                        |

`SUBMISSION_NOT_FOUND` is deliberately uninformative: malformed, expired, cross-shop, and missing-Field receipts all return that same code and message. It will not tell you which, so there is nothing to learn from retrying with variations.

## Shop-wide limits

Limits are shop-wide across all tokens; minting more tokens does not add capacity.

* 120 authenticated requests per minute.
* 20 Raven mutations per minute.
* 6 deep verifications per minute.

Mutation calls consume their mutation quota and general admission. Deep verification calls consume their deep quota and general admission, including cache hits. When FieldsRaven returns `RATE_LIMITED`, wait for `details.retry_after_seconds` and any transport `Retry-After` delay rather than retrying immediately.

## Privacy and protected customer data

MCP can verify protected customer data without returning it. Tool results and diagnostics never include a bearer token, never include a submitted value or customer email, and never include raw integration JSON or a third-party payload. Receipts, cursors, and request IDs are lookup tokens — they identify a submission without carrying its contents. Still, do not log or publish them.

Use environment-backed bearer configuration, grant manage only where mutation is required, and revoke unused or exposed tokens. Client configuration must contain the environment-variable placeholder, never token plaintext.

## Key and rotation boundaries

API-token digests, issuance nonces, and cursors use separate Rails key-generator purposes and rotate with `SECRET_KEY_BASE`. These purposes do not make the artifacts interchangeable.

Receipts use the current `FR_RECEIPT_KEY` root or the `SECRET_KEY_BASE` fallback, labeled by `FR_RECEIPT_KEY_ID`. A configured `FR_RECEIPT_KEY_PREVIOUS` and `FR_RECEIPT_KEY_PREVIOUS_ID` provide only a one-version receipt overlap.

Never expose key values. Rotating a root invalidates artifacts outside its explicit overlap. Revoke and mint tokens separately; receipt-key overlap does not preserve bearer tokens, issuance nonces, or cursors.

If a safe retry does not resolve a problem, contact <karim@fieldsraven.app> with the request ID and error code only.


# Raven identity

What identifies a raven, why two ravens can share a key, and how that shapes the code FieldsRaven generates.

A raven is identified by **four things together**:

| Part      | Example            |
| --------- | ------------------ |
| Shop      | your store         |
| Resource  | `customer`         |
| Namespace | `fields_raven`     |
| Key       | `favourite_colour` |

All four form the identity. The database enforces it — you cannot create two ravens on one shop with the same resource, namespace and key.

## Identity is fixed after creation

After you create a raven, its **resource, namespace, key and value type cannot be changed**. This prevents an edit from silently pointing existing storefront code and Shopify definitions at a different metafield contract.

If any of those four values needs to change, create a new raven with the new identity, replace the storefront code with the new raven's **Get Code** output, and retire the old raven only after the replacement is live. Settings such as active state, approval, integrations and metaobject options remain editable.

## Two ravens can share a key

This is the part that surprises people. A key on its own does **not** identify a raven, because a Shopify metafield is identified by its owner resource as well as its namespace and key.

So these are two legitimately different ravens:

* `customer` · `fields_raven` · `size`
* `product` · `fields_raven` · `size`

A customer's shirt size and a product's size are unrelated metafields that happen to share a key. FieldsRaven allows it because Shopify allows it — a narrower rule would forbid a state Shopify itself considers valid.

The same applies across namespaces: `custom` · `size` and `fields_raven` · `size` on the same resource are also distinct.

### Why it matters in your theme

Because identity is four-part, the code the **Get Code** panel generates derives its identifiers from resource, namespace and key together — never the key alone:

```
form id      fr-customer-favourite_colour
JS global    FR_CUSTOMER_FAVOURITE_COLOUR
```

A non-default namespace adds a segment: `fr-<namespace>--<resource>-<key>`.

If you hand-write identifiers from the key alone and put two forms on one page, they collide. `getElementById` returns the first match, and **the second form silently never binds** — no error, no console warning, it just does nothing. Generating both snippets from the panel avoids this.

## The slug is the public id

`raven_id` in your storefront code is the raven's **slug** — a short random string, not the key.

Three things to know:

* **It's assigned once, at creation, and never changes.** Editing mutable settings such as active state, approval or integrations leaves the slug alone, so storefront code you've already shipped keeps working.
* **You can't set or edit it.** It isn't editable in the app, by design — a slug that could change would silently break every snippet already deployed.
* **It's unique per shop, not globally.** A slug from your dev store will not resolve in production. Copying a snippet between stores requires re-copying the code from the raven in *that* store — this is the usual cause of `Raven ID is missing or raven can't be found`.

## Field rules

**Resource** must be one of: `article`, `blog`, `collection`, `customer`, `page`, `product`, `variant`, `shop`.

**Namespace** is required. `fields_raven` is the default, and the one the generated identifiers treat as unmarked.

**Key** must be at least 3 characters.

**Value types** are `single_line_text_field`, `multi_line_text_field`, `number_integer`, `number_decimal`, `json`, `product_reference`, `list.product_reference` and `metaobject_reference`.

{% hint style="warning" %}
**Reference types take a plain numeric id, never a `gid://` string** — FieldsRaven builds the gid itself. `product_reference` catches a mistake here at validation and returns **422**, but `metaobject_reference` is **not validated locally**: a full gid is accepted, doubled into `gid://shopify/Metaobject/gid://…`, rejected by Shopify, and the submission lands as `failed`. Send the bare numeric id.
{% endhint %}

### Spaces become underscores

Both key and namespace are normalized before they're saved: **every space becomes an underscore**.

That means `favourite colour` and `favourite_colour` are the *same* key. If a raven already exists with `favourite_colour`, trying to create `favourite colour` on the same resource and namespace is a duplicate, and the app will tell you so rather than creating a second raven you'd never be able to tell apart.

Worth knowing when a key you're sure is new gets rejected.


# Verify a submission

Confirm from your theme that an asynchronous metafield write actually landed, using the receipt returned by a storefront submission.

A storefront submission is accepted **before** the metafield is written — the response says "Sit tight the raven is on it!" and the write happens in the background moments later. Usually that is all you need. But if your theme wants to show a real confirmation ("your entry is saved") rather than an optimistic one, it needs a way to ask whether the write landed.

That is what the verify endpoint is for.

```
GET /apps/raven/verify_submission?receipt=<receipt>
```

Requires FieldsRaven 0.31.9 or later.

## Where the receipt comes from

Every successful submission response carries a `submission` object with a `receipt` and an `expires_at`:

```json
{
  "message": "Sit tight the raven is on it!",
  "submission": {
    "receipt": "frsr1...",
    "expires_at": "2026-08-27T00:00:00Z"
  }
}
```

The receipt is an opaque token. Hold on to it in the page's JavaScript — it is the **only** handle the verify endpoint accepts, it is bound to your shop, and it expires (default seven days). It cannot be reconstructed later, so capture it from the submission response or not at all.

## The response

```json
{ "state": "pending", "retry_after_seconds": 2 }
```

| `state`             | Meaning                                      | What your theme should do                                         |
| ------------------- | -------------------------------------------- | ----------------------------------------------------------------- |
| `pending`           | The write hasn't completed yet               | Wait `retry_after_seconds`, then poll again                       |
| `landed`            | The metafield write completed                | Show your confirmation                                            |
| `awaiting_approval` | The submission is held for merchant approval | Tell the shopper it's received and pending review                 |
| `rejected`          | The merchant rejected the submission         | Show your rejection copy                                          |
| `failed`            | The write failed                             | Show your failure copy; the merchant sees it on Failed Operations |

`retry_after_seconds` is only present while another poll is worth making — it carries a value on `pending` and is `null` on every other state.

The state reflects the **metafield write only**. Integrations the raven may also run (Klaviyo, Airtable, metaobject sync) never change the answer — a submission is `landed` when its metafield is written, whatever its syncs are still doing.

## What a 404 means

A bad receipt — malformed, expired, from another shop, or never issued — always returns the same thing:

```
404  { "state": "not_found" }
```

Deliberately, the response does not say which of those it was, so receipts cannot be used to probe for what exists. Note that an **expired** receipt also lands here: if a shopper leaves a tab open past the receipt's `expires_at` and your script polls again, treat `not_found` as "this confirmation is no longer available", not as an error worth alarming anyone about.

## Rules your polling should respect

* **Never cache the response.** Every response carries `Cache-Control: no-store`, and that is the point of the endpoint — it is the one storefront-reachable read that is guaranteed fresh. Don't wrap it in your own caching layer.
* **Back off between polls.** Follow `retry_after_seconds` when present. Most writes land within a couple of seconds; a poll loop tighter than that gains nothing.
* **Handle `429` and `503` as backpressure, not failure.** The endpoint is rate limited per shop and per visitor IP. Shoppers behind one shared IP (an office, a cafe) share a window, so a `429` can happen to a perfectly polite script. A `503` (empty body) means the limiter's backing store was briefly unavailable and the endpoint failed closed. Both carry a `Retry-After` header; wait a few seconds and resume polling — don't surface either as an error.
* **Treat any other status as transient and stop polling.** A `400` or `500` returns `{"error": ...}` with no `state`. Don't loop on it — fall back to your optimistic "submission received" copy.

## Example

```javascript
async function confirmSubmission(receipt) {
  for (let i = 0; i < 10; i++) {
    const response = await fetch(
      `/apps/raven/verify_submission?receipt=${encodeURIComponent(receipt)}`
    )
    if (response.status === 429 || response.status === 503) { await sleep(3000); continue }
    if (response.status === 404) return "not_found"
    if (!response.ok) return "pending" // 400/500 — stop polling, stay optimistic
    const body = await response.json()
    if (body.state !== "pending") return body.state
    await sleep((body.retry_after_seconds || 2) * 1000)
  }
  return "pending" // still not settled — treat as accepted, not failed
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
```

Submit with the pattern from the [Quick Start](/quick-start), pass the `receipt` from its response into `confirmSubmission`, and branch your UI on the returned state.


# App embeds

Ready-made theme blocks you enable in the theme customizer, no code required.

App embeds are blocks FieldsRaven adds to your theme customizer. You switch them on under **Online Store → Themes → Customize → App embeds**, fill in a setting or two, and save. No theme code to paste.

Three are available:

| Embed                        | What it does                                                                                                                  |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **Storefront Kit**           | Loads FieldsRaven's storefront JavaScript, and optionally AlpineJS. **The other two embeds depend on it.**                    |
| **Sitemap Manager**          | Adds an admin-only widget for hiding a page, product, collection, article or blog from search engines and your sitemap.       |
| **Birthday Popup (example)** | A worked example — collects a customer's birthday into a metafield. Built to show what's possible, not as a finished feature. |

## Start with the Storefront Kit

Both other embeds need it. Enable **Storefront Kit** first, and tick **Include AlpineJS** unless your theme already loads Alpine itself — the Sitemap Manager and Birthday Popup are both built with it.

If an embed looks enabled but nothing appears on the storefront, an unchecked Storefront Kit is the usual reason.

## These are examples you can replace

The embeds are deliberately narrow. They exist to show a complete working path from a storefront interaction to a Shopify metafield, using nothing but FieldsRaven.

Anything they do, you can build yourself with the code the **Get Code** panel generates — see [Quick Start](/quick-start). The Birthday Popup in particular is labelled *(example)* in the theme customizer for exactly that reason.


# Sitemap manager

Hide a resource from search engines and sitemaps in Shopify

[Shopify has a predefined metafield for all resources to hide a resource from search engines and sitemaps. ](https://shopify.dev/docs/apps/marketing/seo)This app embed will take advantage of this metafield to hide/show Shopify pages from store sitemap and search engines.

Before enabling the embed, create one raven per resource type you want to control. Each raven needs:

* **Resource** — the type it controls (`page`, `product`, `collection`, `article` or `blog`)
* **Namespace** `seo` and key `hidden` — Shopify's own predefined SEO metafield, which is what makes the setting take effect
* **Type** `number_integer` — the widget writes `1` to hide and `0` to show

You then paste each raven's id into the matching slot in the embed's settings. The embed has one slot per resource type, and a raven's resource must match the slot you put it in — a product raven in the Collection slot silently controls nothing.

## Settings

| Setting                                                 | Purpose                                                      |
| ------------------------------------------------------- | ------------------------------------------------------------ |
| Raven ID (Collection / Product / Article / Blog / Page) | One per resource type. Leave a slot empty to skip that type. |
| Admin email                                             | The only customer who sees the widget.                       |

## How it works

Shopify reads a predefined SEO metafield when building your sitemap and when telling search engines whether to index a resource. The embed renders a small widget on the storefront that writes to that metafield through FieldsRaven — so hiding a page is a click on the page itself rather than a trip through the admin.

{% hint style="info" %}
This feature is only suported for the following resource types: collection, product, article, blog, page
{% endhint %}

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2F1ihUEr14JFmb8daUUTXc%2FFieldsRaven%20Dev%20%C2%B7%20FieldsRaven%20%5BDEV%5D%20%C2%B7%20Shopify%202023-03-11%2009-07-48.png?alt=media&#x26;token=4acd0ff0-067e-4c3f-adad-6a83660eee90" alt=""><figcaption><p>Go to "Ravens" page and click on "Create a new Raven"</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FRooOfmv0U3WJWChs6QrO%2FFieldsRaven%20Dev%20%C2%B7%20FieldsRaven%20%5BDEV%5D%20%C2%B7%20Shopify%202023-03-11%2009-12-49.png?alt=media&#x26;token=01cc3b38-6a99-4693-a95c-6bee439e32c6" alt=""><figcaption><p>Create a raven</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2F0pjinAm6YUUGY92I3rKW%2FFieldsRaven%20Dev%20%C2%B7%20FieldsRaven%20%5BDEV%5D%20%C2%B7%20Shopify%202023-03-11%2009-16-35.png?alt=media&#x26;token=128fe35a-2421-41c1-87da-410779696bc4" alt=""><figcaption><p>Create a raven for each resource you want to hide/show</p></figcaption></figure>

To enable the "Sitemap Manager" app embed, follow steps below:

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FoyZCkvcWN9xtQzbsvoB3%2FFieldsRaven%20Dev%20%C2%B7%20Themes%20%C2%B7%20Shopify%202023-03-11%2008-58-12.png?alt=media&#x26;token=a0999dfa-81d4-42f7-b821-37a84c35233f" alt=""><figcaption><p>Go to theme customizer</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FcMtXJwqGivhgpjtY3s2K%2FFieldsRaven%20Dev%20%C2%B7%20Customize%20FieldsRaven%20%5BDev%5D%20%C2%B7%20Shopify%202023-03-11%2008-59-57.png?alt=media&#x26;token=8563cecd-131d-4b06-8f84-6a10ac1a01cc" alt=""><figcaption><p>Click on App embeds</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FXHLA5CIBzoa85UVoHcMX%2FFieldsRaven%20Dev%20%C2%B7%20Customize%20FieldsRaven%20%5BDev%5D%20%C2%B7%20Shopify%202023-03-11%2009-18-19.png?alt=media&#x26;token=341938a3-d1e1-4b3f-9aeb-9e7a1af30605" alt=""><figcaption><p>Make sure that FieldsRaven "Storefront Kit" is enabled and "Include AlpineJS" is checked</p></figcaption></figure>

{% hint style="warning" %}
Admin-only. The widget renders only for a customer logged in with the email you set in **Admin email** — ordinary shoppers never see it.
{% endhint %}

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FLnvHKH4z52nEgnXPDkc8%2FFieldsRaven%20Dev%20%C2%B7%20Customize%20FieldsRaven%20%5BDev%5D%20%C2%B7%20Shopify%202023-03-11%2009-20-34.png?alt=media&#x26;token=c923f7ba-bf5c-4730-95b3-5b39a32a74ba" alt=""><figcaption><p>1) Enable "Sitemap manager" app embed 2) Copy &#x26; paste raven ids, make sure that the raven resource type match the type in the settings 3) Add admin email 4) Save!</p></figcaption></figure>

After you login using the email you used in the settings as an admin email, you should be able to see this.

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FoamPCQY82Qp91TYbXsxj%2FTest%20page%20%E2%80%93%20FieldsRaven%20Dev%202023-03-11%2014-54-47.png?alt=media&#x26;token=bf4a5e66-7ec7-4426-8800-e41de2b3c4a2" alt=""><figcaption><p>FieldsRaven Hide/Show from sitemap widget</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FMlzyTM9jmh5QUSvyCZJM%2FTest%20page%20%E2%80%93%20FieldsRaven%20Dev%202023-03-11%2014-56-09.png?alt=media&#x26;token=eaee3ae2-f717-46cb-80e4-e18dc58b7831" alt=""><figcaption><p>FieldsRaven Hide/Show from sitemap widget when it's open</p></figcaption></figure>

{% hint style="info" %}
Note: when a resource is hidden from the sitemap, it won't appear in search results when customers use storefront search.
{% endhint %}


# Customer Birthday Popup

A worked example — collect a customer's birthday from the storefront into a customer metafield.

{% hint style="warning" %}
This embed is an **example**, not a finished feature — it's labelled *(example)* in the theme customizer for that reason. It exists to show a complete path from a storefront interaction to a Shopify metafield. Treat it as a reference implementation you'd replace with your own.
{% endhint %}

A logged-in customer sees a prompt for their date of birth. Submitting it writes to a customer metafield through FieldsRaven. On the customer's birthday, the embed shows them a greeting.

**Prerequisites:** the **Storefront Kit** embed enabled with **Include AlpineJS** ticked — this embed is built with Alpine and does nothing without it.

## Raven setup

Create a raven with:

* **Resource** `customer`
* **Namespace** `fields_raven` and key `birthday` — the embed reads `customer.metafields.fields_raven.birthday` directly, so both must match exactly
* **Type** `single_line_text_field`

Then copy the raven's id into the embed's **Raven ID** setting.

## Settings

| Setting              | Purpose                                                                                         |
| -------------------- | ----------------------------------------------------------------------------------------------- |
| Raven ID             | The raven the submission writes through.                                                        |
| Submit button text   | Label on the submit button.                                                                     |
| Customer logout text | Shown to visitors who aren't logged in — the embed needs a customer to attach the metafield to. |

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FqAQvIS0ZZITXGLyVs1O8%2FFieldsRaven%20Dev%20%C2%B7%20FieldsRaven%20%5BDEV%5D%20%C2%B7%20Shopify%202023-03-13%2008-53-22.png?alt=media&#x26;token=21e04a4c-0484-4dec-abfc-15b67d2ced2e" alt=""><figcaption><p>Create a raven, make sure to match the properties in the screenshot</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FZ1PvddHdc12G6kN22ZeG%2FFieldsRaven%20Dev%20%C2%B7%20FieldsRaven%20%5BDEV%5D%20%C2%B7%20Shopify%202023-03-13%2008-55-27.png?alt=media&#x26;token=18a74988-56d7-48ba-9bdd-0ebd7ded94a4" alt=""><figcaption><p>Copy raven ID</p></figcaption></figure>

## Theme customizer settings

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FDXdWF0j7F85N9FzU9hD7%2FFieldsRaven%20Dev%20%C2%B7%20Customize%20FieldsRaven%20%5BDev%5D%20%C2%B7%20Shopify%202023-03-11%2009-18-19.png?alt=media&#x26;token=b2fd6988-3082-4fb6-b9b8-77d4faa2d57f" alt=""><figcaption><p>Make sure that the Storefront Kit is enabled and AlpineJS in included</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FKHlm7ss0hCn7W8DiCawP%2FFieldsRaven%20Dev%20%C2%B7%20Customize%20FieldsRaven%20%5BDev%5D%20%C2%B7%20Shopify%202023-03-13%2008-56-50.png?alt=media&#x26;token=cfe82fe0-a62a-49a7-8a96-0aad6f0415fd" alt=""><figcaption><p>1) Go to App Embeds in theme customizer 2) Enable Birthday Popup 3) Paste raven ID from the previous step</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2F6JBxBO78JQLuyN41EeXU%2FFieldsRaven%20Dev%20%C2%B7%20Customize%20FieldsRaven%20%5BDev%5D%20%C2%B7%20Shopify%202023-03-13%2009-01-07.png?alt=media&#x26;token=54b473d3-6b54-44a6-bcb9-2f37abb7b11c" alt=""><figcaption><p>Adjust app embed settings</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FcoiodlSIvdYnNSdmoE7N%2FFieldsRaven%20Dev%20%C2%B7%20Customize%20FieldsRaven%20%5BDev%5D%20%C2%B7%20Shopify%202023-03-13%2009-04-53.png?alt=media&#x26;token=b6162f3f-1834-458e-bc33-ff970c86a92f" alt=""><figcaption><p>Save your changes!</p></figcaption></figure>

## Storefront

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2F8VvmTWNKiBa3FX2iuvvr%2FFieldsRaven%20Dev%202023-03-13%2009-03-10.png?alt=media&#x26;token=c7ee8e5f-87d3-4e10-a8ad-c895c5eb699d" alt=""><figcaption><p>Birthday popup trigger on the storefront</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2Fgkq1jLjKV7IixteYjZEg%2FFieldsRaven%20Dev%202023-03-13%2009-10-12.png?alt=media&#x26;token=c3dca8df-6f55-4858-86a5-4c4ae07a6bbb" alt=""><figcaption><p>Customer logged-out state</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FB8bMs82HPwfNCzMXnwzs%2FAccount%20%E2%80%93%20FieldsRaven%20Dev%202023-03-13%2009-11-13.png?alt=media&#x26;token=f420dd26-0412-4f0a-bc10-0bbfbf83867f" alt=""><figcaption><p>Customer logged-in, before adding a birthday state</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FgjAvyDskEskV5j2YNJR3%2FAccount%20%E2%80%93%20FieldsRaven%20Dev%202023-03-13%2009-12-50.png?alt=media&#x26;token=3831662c-f336-4eb6-b29a-be4dada69c8e" alt=""><figcaption><p>After customer submits their birthday</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2F3nuUSbP5uQQRK4Oyynyz%2FFieldsRaven%20Dev%202023-03-13%2009-13-56.png?alt=media&#x26;token=909e6f84-8dc2-43b6-a4ad-c749ef8d33d4" alt=""><figcaption><p>Birthday message</p></figcaption></figure>


# FAQ

## How does FieldsRaven differentiate from any other metafields app?

* Unlike other metafields apps, FieldsRaven is built for developers.
* FieldsRaven doesn't have an admin UI to create and manage metafields.
* As a developer you need to write a few lines of HTML, Liquid and JavaScript to take advantage of the app. (Check [quick start guide](https://github.com/BeSpark/fieldsraven-docs/tree/main/quick-start/README.md))
* Metafields should be created on a specific events on the storefront, based on customer actions or predetermined logic.
* Have a look at the example [features link](/example-features) to get a better idea of what you might be able to build on top of FieldsRaven.

## Why is there a delay between the AJAX call returning and the metafield appearing?

A submission is accepted and acknowledged immediately, then written to Shopify in the background. A few things add to the gap:

1. Each request is queued, so the wait depends on queue depth at that moment.
2. Requests are throttled per shop to stay inside Shopify's API rate limit. Your store's queue is isolated from every other store's, so a busy neighbour can't slow you down.
3. Shopify's own write takes roughly a second.
4. Shopify's storefront cache. An unpublished theme shows a new metafield sooner than a live one.

If Shopify throttles a write, FieldsRaven now retries at the delay Shopify specifies rather than giving up, so a throttled submission completes late instead of failing.

## Can I delete a metafield?

Yes. `DELETE /apps/raven/delete_metafield` takes `raven_id` and `resource_id`, and removes the metafield from Shopify.

Two things to know:

* A delete that Shopify rejects returns **422** with Shopify's own error message. Earlier versions reported success regardless — if you built against that, check your error handling.
* If Shopify throttles the delete, you get **429** with a `Retry-After` header. Wait that long and retry.

## FieldsRaven won't update a metafield to a blank value

Values are validated against the metafield's type before being sent, and an empty value fails that check for most types. Use a type-appropriate empty value instead — `0` for numbers, `{}` for JSON — and handle the display in Liquid. If you genuinely want the metafield gone, use the delete endpoint above.


# Code examples


# Create a single\_line\_text\_field metafield

Send a plain string value.

The metafield's type comes from the raven, not from the request. Text types accept any present value — there is no server-side type check for them, unlike `json`, `product_reference` or the number types, which are validated and rejected with **422**.

## Before you start

Paste the raven's **Get Code** output into your theme first — that Liquid computes the signature and defines `window.FR_<RESOURCE>_<KEY>`. See [Quick Start](/quick-start). The snippets below assume it is present, and use `cfg` for it.

## Sending a value

`value` is the string itself.

```javascript
async function submit(value) {
  var cfg = window.FR_CUSTOMER_MY_KEY;              // from the Get Code panel

  var res = await fetch('/apps/raven/create_metafield', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      raven: {
        raven_id:   cfg.ravenId,
        resource_id: cfg.resourceId,
        raven_mac:  cfg.ravenMac,
        value:      value
      }
    })
  });

  if (res.status === 429) return console.warn('Busy — retry shortly.');
  var data = await res.json().catch(function () { return {}; });
  if (!res.ok) return console.error(data.message || 'Rejected.');
  console.log(data.message);            // "Sit tight the raven is on it!"
}
```

```javascript
submit('Hello Raven!');
```


# Delete a metafield

Remove a metafield's value from Shopify.

`DELETE /apps/raven/delete_metafield`, taking `raven_id` and `resource_id`.

{% hint style="info" %}
This clears the metafield on the resource. It does **not** remove the metafield *definition* you created in Shopify admin.
{% endhint %}

## Sending the request

```javascript
async function remove() {
  var cfg = window.FR_CUSTOMER_MY_KEY;              // from the Get Code panel

  var res = await fetch('/apps/raven/delete_metafield', {
    method: 'DELETE',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ raven_id: cfg.ravenId, resource_id: cfg.resourceId })
  });

  if (res.status === 429) {
    return console.warn('Throttled — retry after', res.headers.get('Retry-After'), 'seconds.');
  }
  var data = await res.json().catch(function () { return {}; });
  if (!res.ok) return console.error(data.message || 'Delete rejected.');
  console.log('Deleted.');
}
```

{% hint style="warning" %}
**Sign your deletes.** The example above sends no `raven_mac`, which works today only because the delete endpoint's signature check is **log-only** — FieldsRaven records unsigned callers rather than rejecting them, because the app has never generated delete-side snippet code and existing integrations are hand-rolled.

That is explicitly temporary: enforcement follows once the logs show callers are signing. An integration built unsigned today will break then. Compute `raven_mac` exactly as the create path does — the HMAC of `raven_id + resource_id` — and send it.
{% endhint %}

## Responses worth handling

| Status  | Meaning                                                                                                                                                             |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **200** | The metafield was removed from Shopify.                                                                                                                             |
| **422** | Rejected. Either `raven_id`/`resource_id` were missing or did not resolve on this shop, or Shopify refused the delete — in which case the message is Shopify's own. |
| **429** | Shopify throttled it. `Retry-After` carries the delay in seconds.                                                                                                   |

{% hint style="warning" %}
**Older versions of FieldsRaven reported success even when the delete failed.** If your integration predates that fix, it may be treating failed deletes as successful — check that it distinguishes 200 from 422. See [Troubleshooting](/troubleshooting).
{% endhint %}


# Create a product\_reference metafield

Point a metafield at a product — send the numeric id, not a gid.

{% hint style="warning" %}
**Send the plain numeric product id, not a `gid://` string.** FieldsRaven builds `gid://shopify/Product/<id>` itself before writing to Shopify. A full gid is rejected at validation with **422 Invalid product id** — the value is checked with `Integer(value)`, which a gid string fails, so it never reaches Shopify at all.
{% endhint %}

## Before you start

Paste the raven's **Get Code** output into your theme first — that Liquid computes the signature and defines `window.FR_<RESOURCE>_<KEY>`. See [Quick Start](/quick-start). The snippets below assume it is present, and use `cfg` for it.

## Sending a value

```javascript
async function submit(value) {
  var cfg = window.FR_CUSTOMER_MY_KEY;              // from the Get Code panel

  var res = await fetch('/apps/raven/create_metafield', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      raven: {
        raven_id:   cfg.ravenId,
        resource_id: cfg.resourceId,
        raven_mac:  cfg.ravenMac,
        value:      value
      }
    })
  });

  if (res.status === 429) return console.warn('Busy — retry shortly.');
  var data = await res.json().catch(function () { return {}; });
  if (!res.ok) return console.error(data.message || 'Rejected.');
  console.log(data.message);            // "Sit tight the raven is on it!"
}
```

```javascript
submit('{{ product.id }}');      // e.g. "7418351251511"
```


# Create a list.product\_reference metafield

Point a metafield at several products at once.

{% hint style="warning" %}
Same rule as the single reference: **plain numeric ids**. FieldsRaven maps the array to `gid://shopify/Product/<id>` for each entry.
{% endhint %}

{% hint style="info" %}
Shopify caps a list metafield at **128 values**.
{% endhint %}

## Before you start

Paste the raven's **Get Code** output into your theme first — that Liquid computes the signature and defines `window.FR_<RESOURCE>_<KEY>`. See [Quick Start](/quick-start). The snippets below assume it is present, and use `cfg` for it.

## Sending a value

`value` is a **JSON string** containing an array of numeric product ids — not a JavaScript array.

{% hint style="warning" %}
Pass `JSON.stringify([...])`. The server parses `value` as a string before mapping the ids, so a bare array fails validation with **422**.
{% endhint %}

```javascript
async function submit(value) {
  var cfg = window.FR_CUSTOMER_MY_KEY;              // from the Get Code panel

  var res = await fetch('/apps/raven/create_metafield', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      raven: {
        raven_id:   cfg.ravenId,
        resource_id: cfg.resourceId,
        raven_mac:  cfg.ravenMac,
        value:      value
      }
    })
  });

  if (res.status === 429) return console.warn('Busy — retry shortly.');
  var data = await res.json().catch(function () { return {}; });
  if (!res.ok) return console.error(data.message || 'Rejected.');
  console.log(data.message);            // "Sit tight the raven is on it!"
}
```

```javascript
submit(JSON.stringify(['7418351251511', '7418351284279']));
```


# Create a json metafield

Send a structured object as a single JSON metafield.

## Before you start

Paste the raven's **Get Code** output into your theme first — that Liquid computes the signature and defines `window.FR_<RESOURCE>_<KEY>`. See [Quick Start](/quick-start). The snippets below assume it is present, and use `cfg` for it.

## Sending a value

`value` must be a **JSON string**, not a JSON object.

{% hint style="warning" %}
Pass `JSON.stringify(...)`. Sending a bare object fails validation with **422 Invalid JSON format** — the server parses `value` as a string, so an object never reaches the parser intact. The code the Get Code panel generates stringifies for exactly this reason.
{% endhint %}

```javascript
async function submit(value) {
  var cfg = window.FR_CUSTOMER_MY_KEY;              // from the Get Code panel

  var res = await fetch('/apps/raven/create_metafield', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      raven: {
        raven_id:   cfg.ravenId,
        resource_id: cfg.resourceId,
        raven_mac:  cfg.ravenMac,
        value:      value
      }
    })
  });

  if (res.status === 429) return console.warn('Busy — retry shortly.');
  var data = await res.json().catch(function () { return {}; });
  if (!res.ok) return console.error(data.message || 'Rejected.');
  console.log(data.message);            // "Sit tight the raven is on it!"
}
```

```javascript
submit(JSON.stringify({ colour: 'blue', size: 'M', updatedAt: Date.now() }));
```

A JSON raven is also the only type that can mirror submissions into a Shopify metaobject — see [Metaobject sync](/metaobject-sync).


# Create multiple metafields (aka flock)

Send several metafields in one request, all-or-nothing.

One request, several metafields. Each entry is signed independently, exactly as a single submission is.

{% hint style="warning" %}
**A flock is all-or-nothing.** The entries are saved inside a database transaction — if any one is rejected, none of them are recorded and the whole request returns **422** with that entry's error. Do not treat a failed flock as "some got through".
{% endhint %}

## Before you start

Paste each raven's **Get Code** output into your theme — every entry needs its own `raven_id`, `resource_id` and `raven_mac`, and each raven signs with its own id. See [Quick Start](/quick-start).

## Sending a flock

```javascript
async function submitFlock() {
  var colour = window.FR_CUSTOMER_FAVOURITE_COLOUR;   // from the Get Code panel
  var size   = window.FR_CUSTOMER_SHIRT_SIZE;
  var score  = window.FR_CUSTOMER_LOYALTY_SCORE;

  var entry = function (cfg, value) {
    return { raven_id: cfg.ravenId, resource_id: cfg.resourceId, raven_mac: cfg.ravenMac, value: value };
  };

  var res = await fetch('/apps/raven/create_multiple_metafields', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      flock: [
        entry(colour, 'blue'),
        entry(size,   'M'),
        entry(score,  42)
      ]
    })
  });

  if (res.status === 429) return console.warn('Busy — retry shortly.');
  var data = await res.json().catch(function () { return {}; });
  if (!res.ok) return console.error(data.message || 'Flock rejected.');
  console.log(data.message);            // "Sit tight the raven is on it!"
}
```

Each entry's `value` follows the rules for its own raven's type — a string for `single_line_text_field`, a **JSON string** for `json`, a plain numeric id for `product_reference`, and so on — see the individual pages. Mixed types in one flock are fine.


# Example features

These complete starting points use the current direct app-proxy request. Copy the recipe closest to your storefront feature, then replace the illustrative fields with the merchant's real product and data model.

* [Shopify quiz profiles](/example-features/shopify-quiz-profiles) — replace a structured customer profile with the shopper's latest quiz answers.
* [Shopify Klaviyo sync](/example-features/shopify-klaviyo-sync) — save preference properties in Shopify and optionally map them to Klaviyo.
* [Shopify customer wishlist](/example-features/wish-list) — add and remove stable product handles from a customer-owned list.
* [Saved product configurations](/example-features/saved-product-configurations) — save, update, remove, and reopen named product designs.
* [Shopify vehicle garage](/example-features/shopify-vehicle-garage) — add, select, and remove structured vehicles for fitment UI.
* [Shopify product registration](/example-features/customer-product-registration) — validate and append serial registrations to a cumulative array.

For a smaller request walkthrough, start with [Quick Start](/quick-start). Existing non-recipe examples remain available in this section's navigation.


# Shopify quiz profiles

Save a customer's structured quiz answers to one Shopify JSON metafield.

Use this recipe to replace a customer's current quiz profile in `custom.quiz_profile`. The values below are illustrative; use questions and option values that match your storefront.

Create a customer-owned Raven with namespace `custom`, key `quiz_profile`, and type `json`. Then copy its Liquid configuration from **Get Code**. It defines `window.FR_CUSTOM__CUSTOMER_QUIZ_PROFILE` with the Raven id, signed customer id, and HMAC.

Read [FieldsRaven's quiz-profile implementation story](https://fieldsraven.app/use-cases/shopify-quiz-profiles) for the product boundary, or review [Quick Start](/quick-start) before adapting the request.

## Complete direct recipe

Paste the Raven's generated Liquid configuration immediately before this code.

```liquid
{% if customer %}
  {% assign saved_quiz_profile = customer.metafields.custom.quiz_profile.value %}
  <script type="application/json" id="fr-quiz-profile-initial">
    {% if saved_quiz_profile != blank %}{{ saved_quiz_profile | json }}{% else %}{}{% endif %}
  </script>

  <form id="fr-quiz-profile-form">
    <fieldset>
      <legend>Your profile</legend>

      <label for="fr-quiz-skin-type">Skin type</label>
      <select id="fr-quiz-skin-type" name="skin_type" required>
        <option value="">Choose one</option>
        <option value="dry">Dry</option>
        <option value="balanced">Balanced</option>
        <option value="oily">Oily</option>
      </select>

      <fieldset>
        <legend>Goals</legend>
        <label for="fr-quiz-goal-hydration">
          <input id="fr-quiz-goal-hydration" type="checkbox" name="goals" value="hydration">
          Hydration
        </label>
        <label for="fr-quiz-goal-texture">
          <input id="fr-quiz-goal-texture" type="checkbox" name="goals" value="texture">
          Smoother texture
        </label>
      </fieldset>
    </fieldset>

    <button id="fr-quiz-submit" type="submit">Save profile</button>
    <p id="fr-quiz-status" aria-live="polite"></p>
  </form>

  <section aria-labelledby="fr-quiz-current-heading">
    <h2 id="fr-quiz-current-heading">Current saved profile</h2>
    <p id="fr-quiz-current"></p>
  </section>

  <script>
    const quizProfileForm = document.getElementById("fr-quiz-profile-form")
    const quizProfileButton = document.getElementById("fr-quiz-submit")
    const quizProfileStatus = document.getElementById("fr-quiz-status")
    const quizProfileOutput = document.getElementById("fr-quiz-current")
    let currentProfile = JSON.parse(document.getElementById("fr-quiz-profile-initial").textContent)

    function renderQuizProfile() {
      const goals = Array.isArray(currentProfile.goals) ? currentProfile.goals.join(", ") : "none yet"
      quizProfileOutput.textContent = currentProfile.skin_type
        ? `Skin type: ${currentProfile.skin_type}; goals: ${goals}.`
        : "No quiz profile has been saved yet."
    }

    async function readQuizResponse(response) {
      const contentType = response.headers.get("content-type") || ""
      if (!contentType.includes("application/json")) {
        throw new Error("FieldsRaven returned a non-JSON response. Please try again.")
      }

      try {
        return await response.json()
      } catch (error) {
        throw new Error("FieldsRaven returned invalid JSON. Please try again.")
      }
    }

    async function saveQuizProfile(profilePayload) {
      const config = window.FR_CUSTOM__CUSTOMER_QUIZ_PROFILE

      try {
        const response = await fetch("/apps/raven/create_metafield", {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ raven: {
            raven_id: config.ravenId,
            resource_id: config.resourceId,
            raven_mac: config.ravenMac,
            value: JSON.stringify(profilePayload)
          } })
        })

        if (response.status === 429) throw new Error("Too many requests. Wait a moment and try again.")
        const result = await readQuizResponse(response)
        if (!response.ok) {
          const message = typeof result?.message === "string" ? result.message : "FieldsRaven rejected the profile."
          throw new Error(message)
        }
        return result
      } catch (error) {
        if (error instanceof TypeError) throw new Error("Network error. Check your connection and try again.")
        throw error
      }
    }

    quizProfileForm.addEventListener("submit", async (event) => {
      event.preventDefault()
      quizProfileButton.disabled = true
      quizProfileButton.setAttribute("aria-busy", "true")
      quizProfileStatus.textContent = "Saving…"

      const formData = new FormData(quizProfileForm)
      const profilePayload = {
        skin_type: formData.get("skin_type"),
        goals: formData.getAll("goals")
      }

      try {
        await saveQuizProfile(profilePayload)
        currentProfile = profilePayload
        renderQuizProfile()
        quizProfileStatus.textContent = "Saved. FieldsRaven accepted and queued the profile update."
      } catch (error) {
        quizProfileStatus.textContent = error.message
      } finally {
        quizProfileButton.disabled = false
        quizProfileButton.setAttribute("aria-busy", "false")
      }
    })

    renderQuizProfile()
  </script>
{% else %}
  <p><a href="/account/login">Log in</a> to save your quiz profile.</p>
{% endif %}
```

## Behavior and boundaries

This recipe stores one JSON object. Each accepted save replaces the previous object, so it is a last-write-wins profile rather than an event history. If the merchant needs answer history, model each submission as a separate event instead of expanding this profile indefinitely.

A successful 200 response means FieldsRaven accepted and queued the metafield write. It does not prove that Shopify or an optional downstream integration has completed. The merchant theme owns the questions, validation, and presentation; FieldsRaven owns request validation and queueing.

The Storefront Kit is optional. This complete direct request does not depend on it.


# Shopify Klaviyo sync

Save customer marketing preferences to Shopify and optionally map them to Klaviyo profile properties.

This recipe saves a current preference object to `custom.marketing_preferences`. When Klaviyo sync is enabled on the Raven, FieldsRaven can map the object's keys to custom Klaviyo profile properties after the Shopify write is accepted.

Create a customer-owned Raven with namespace `custom`, key `marketing_preferences`, and type `json`. Copy its Liquid configuration from **Get Code** so the page defines `window.FR_CUSTOM__CUSTOMER_MARKETING_PREFERENCES`. Configure the Klaviyo mapping in FieldsRaven only after the Shopify shape is working.

See the [Shopify-to-Klaviyo implementation story](https://fieldsraven.app/use-cases/shopify-klaviyo-sync) and [Quick Start](/quick-start) for the request contract.

{% hint style="warning" %}
**Read** [**Klaviyo**](/klaviyo) **before enabling the mapping.** Several conditions make a sync silently not happen — the customer's Klaviyo profile must already exist and match by email, the submission must resolve a customer email, and a Raven with *needs approval* on does not sync until the submission is approved. None of them surface on the storefront.
{% endhint %}

## Complete direct recipe

The example preference names are illustrative. Match them to the consent and preference model approved for your store.

```liquid
{% if customer %}
  {% assign saved_marketing_preferences = customer.metafields.custom.marketing_preferences.value %}
  <script type="application/json" id="fr-preferences-initial">
    {% if saved_marketing_preferences != blank %}{{ saved_marketing_preferences | json }}{% else %}{}{% endif %}
  </script>

  <form id="fr-preferences-form">
    <fieldset>
      <legend>Marketing preferences</legend>

      <label for="fr-preferences-frequency">Email frequency</label>
      <select id="fr-preferences-frequency" name="email_frequency" required>
        <option value="weekly">Weekly</option>
        <option value="monthly">Monthly</option>
        <option value="product-launches">Product launches only</option>
      </select>

      <fieldset>
        <legend>Topics</legend>
        <label for="fr-preferences-topic-guides">
          <input id="fr-preferences-topic-guides" type="checkbox" name="topic_guides" value="true">
          Guides
        </label>
        <label for="fr-preferences-topic-new-products">
          <input id="fr-preferences-topic-new-products" type="checkbox" name="topic_new_products" value="true">
          New products
        </label>
      </fieldset>
    </fieldset>

    <button id="fr-preferences-submit" type="submit">Save preferences</button>
    <p id="fr-preferences-status" aria-live="polite"></p>
  </form>

  <section aria-labelledby="fr-preferences-current-heading">
    <h2 id="fr-preferences-current-heading">Current preferences</h2>
    <p id="fr-preferences-current"></p>
  </section>

  <script>
    const preferencesForm = document.getElementById("fr-preferences-form")
    const preferencesButton = document.getElementById("fr-preferences-submit")
    const preferencesStatus = document.getElementById("fr-preferences-status")
    const preferencesOutput = document.getElementById("fr-preferences-current")
    let savedPreferences = JSON.parse(document.getElementById("fr-preferences-initial").textContent)

    function renderPreferences() {
      const topics = []
      if (savedPreferences.topic_guides) topics.push("guides")
      if (savedPreferences.topic_new_products) topics.push("new products")
      preferencesOutput.textContent = savedPreferences.email_frequency
        ? `Email frequency: ${savedPreferences.email_frequency}; topics: ${topics.join(", ") || "none"}.`
        : "No marketing preferences have been saved yet."
    }

    async function readPreferencesResponse(response) {
      const contentType = response.headers.get("content-type") || ""
      if (!contentType.includes("application/json")) {
        throw new Error("FieldsRaven returned a non-JSON response. Please try again.")
      }

      try {
        return await response.json()
      } catch (error) {
        throw new Error("FieldsRaven returned invalid JSON. Please try again.")
      }
    }

    async function saveMarketingPreferences(preferencesPayload) {
      const config = window.FR_CUSTOM__CUSTOMER_MARKETING_PREFERENCES

      try {
        const response = await fetch("/apps/raven/create_metafield", {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ raven: {
            raven_id: config.ravenId,
            resource_id: config.resourceId,
            raven_mac: config.ravenMac,
            value: JSON.stringify(preferencesPayload)
          } })
        })

        if (response.status === 429) throw new Error("Too many requests. Wait a moment and try again.")
        const result = await readPreferencesResponse(response)
        if (!response.ok) {
          const message = typeof result?.message === "string" ? result.message : "FieldsRaven rejected the preferences."
          throw new Error(message)
        }
        return result
      } catch (error) {
        if (error instanceof TypeError) throw new Error("Network error. Check your connection and try again.")
        throw error
      }
    }

    preferencesForm.addEventListener("submit", async (event) => {
      event.preventDefault()
      preferencesButton.disabled = true
      preferencesButton.setAttribute("aria-busy", "true")
      preferencesStatus.textContent = "Saving…"

      const formData = new FormData(preferencesForm)
      const preferencesPayload = {
        email_frequency: formData.get("email_frequency"),
        topic_guides: formData.get("topic_guides") === "true",
        topic_new_products: formData.get("topic_new_products") === "true"
      }

      try {
        await saveMarketingPreferences(preferencesPayload)
        savedPreferences = preferencesPayload
        renderPreferences()
        preferencesStatus.textContent = "Saved. FieldsRaven accepted and queued the preference update."
      } catch (error) {
        preferencesStatus.textContent = error.message
      } finally {
        preferencesButton.disabled = false
        preferencesButton.setAttribute("aria-busy", "false")
      }
    })

    renderPreferences()
  </script>
{% else %}
  <p><a href="/account/login">Log in</a> to manage your marketing preferences.</p>
{% endif %}
```

## Shopify and Klaviyo boundaries

The JSON object is the Shopify source shape. If Klaviyo sync is enabled, configure FieldsRaven so scalar keys such as `email_frequency`, `topic_guides`, and `topic_new_products` become the intended custom profile properties. FieldsRaven preserves those scalar names; arrays would instead be flattened into numbered properties such as `topics_1` and `topics_2`, which makes them a poor fit for stable preference flags. Changing these keys later also changes the mapping contract.

A successful 200 response means FieldsRaven accepted and queued the metafield write. It does not prove that Shopify or Klaviyo synchronization has completed. Do not show a “synced to Klaviyo” confirmation from this response alone. The merchant owns consent language and the storefront UI; FieldsRaven owns request validation, queueing, and the configured optional integration.

The Storefront Kit is optional. This complete direct request does not depend on it.


# Shopify customer wishlist

Add and remove Shopify products in a customer-owned wishlist metafield.

This recipe stores an array of objects in `custom.wishlist`; each object contains a stable `product_handle`. Create a customer-owned Raven with type `json`, then paste the **Get Code** Liquid that defines `window.FR_CUSTOM__CUSTOMER_WISHLIST`.

See the [customer-wishlist implementation story](https://fieldsraven.app/use-cases/shopify-customer-wishlist) and [Quick Start](/quick-start) before adapting the request.

## Complete direct recipe

The form works on a product template, where `product.handle` is available. Both the server-rendered and enhanced lists link directly to Shopify's canonical `/products/<handle>` route, so the recipe does not depend on Liquid's 20-handle `all_products` lookup limit.

```liquid
{% if customer %}
  {% assign saved_wishlist = customer.metafields.custom.wishlist.value %}
  <script type="application/json" id="fr-wishlist-initial">
    {% if saved_wishlist != blank %}{{ saved_wishlist | json }}{% else %}[]{% endif %}
  </script>

  <section id="fr-wishlist-region" aria-labelledby="fr-wishlist-heading" aria-busy="false">
    <h2 id="fr-wishlist-heading">Saved products</h2>

    <form id="fr-wishlist-form">
      <label for="fr-wishlist-handle">Product handle</label>
      <input id="fr-wishlist-handle" name="product_handle" type="text" value="{{ product.handle | escape }}" required>
      <button id="fr-wishlist-submit" type="submit">Add to wishlist</button>
      <p id="fr-wishlist-status" aria-live="polite"></p>
    </form>

    <p id="fr-wishlist-empty"{% if saved_wishlist != blank %} hidden{% endif %}>Your wishlist is empty.</p>
    <ul id="fr-wishlist-list">
      {% if saved_wishlist != blank %}
        {% for saved_item in saved_wishlist %}
          <li data-product-handle="{{ saved_item.product_handle | escape }}">
            <a href="/products/{{ saved_item.product_handle | url_encode }}">{{ saved_item.product_handle | escape }}</a>
          </li>
        {% endfor %}
      {% endif %}
    </ul>
  </section>

  <script>
    const wishlistForm = document.getElementById("fr-wishlist-form")
    const wishlistButton = document.getElementById("fr-wishlist-submit")
    const wishlistStatus = document.getElementById("fr-wishlist-status")
    const wishlistList = document.getElementById("fr-wishlist-list")
    const wishlistEmpty = document.getElementById("fr-wishlist-empty")
    const wishlistRegion = document.getElementById("fr-wishlist-region")
    let wishlist = JSON.parse(document.getElementById("fr-wishlist-initial").textContent)
    let wishlistSaving = false

    function renderWishlist() {
      wishlistList.replaceChildren()
      wishlistEmpty.hidden = wishlist.length > 0

      for (const item of wishlist) {
        const row = document.createElement("li")
        const link = document.createElement("a")
        const remove = document.createElement("button")
        link.href = `/products/${encodeURIComponent(item.product_handle)}`
        link.textContent = item.product_handle
        remove.type = "button"
        remove.textContent = `Remove ${item.product_handle}`
        remove.disabled = wishlistSaving
        remove.addEventListener("click", () => removeWishlistItem(item.product_handle))
        row.append(link, " ", remove)
        wishlistList.append(row)
      }
    }

    function setWishlistBusy(busy) {
      wishlistSaving = busy
      wishlistRegion.setAttribute("aria-busy", String(busy))
      wishlistButton.disabled = busy
      for (const button of wishlistList.querySelectorAll("button")) button.disabled = busy
    }

    async function readWishlistResponse(response) {
      const contentType = response.headers.get("content-type") || ""
      if (!contentType.includes("application/json")) {
        throw new Error("FieldsRaven returned a non-JSON response. Please try again.")
      }

      try {
        return await response.json()
      } catch (error) {
        throw new Error("FieldsRaven returned invalid JSON. Please try again.")
      }
    }

    async function saveWishlist(nextWishlist) {
      const config = window.FR_CUSTOM__CUSTOMER_WISHLIST

      try {
        const response = await fetch("/apps/raven/create_metafield", {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ raven: {
            raven_id: config.ravenId,
            resource_id: config.resourceId,
            raven_mac: config.ravenMac,
            value: JSON.stringify(nextWishlist)
          } })
        })

        if (response.status === 429) throw new Error("Too many requests. Wait a moment and try again.")
        const result = await readWishlistResponse(response)
        if (!response.ok) {
          const message = typeof result?.message === "string" ? result.message : "FieldsRaven rejected the wishlist update."
          throw new Error(message)
        }
        return result
      } catch (error) {
        if (error instanceof TypeError) throw new Error("Network error. Check your connection and try again.")
        throw error
      }
    }

    async function persistWishlist(nextWishlist, successMessage) {
      if (wishlistSaving) return false

      setWishlistBusy(true)
      wishlistStatus.textContent = "Saving…"

      try {
        await saveWishlist(nextWishlist)
        wishlist = nextWishlist
        renderWishlist()
        wishlistStatus.textContent = successMessage
        return true
      } catch (error) {
        wishlistStatus.textContent = error.message
        return false
      } finally {
        setWishlistBusy(false)
      }
    }

    async function removeWishlistItem(productHandle) {
      const nextWishlist = wishlist.filter((item) => item.product_handle !== productHandle)
      await persistWishlist(nextWishlist, "Removed. FieldsRaven accepted and queued the wishlist update.")
    }

    wishlistForm.addEventListener("submit", async (event) => {
      event.preventDefault()
      const productHandle = new FormData(wishlistForm).get("product_handle").trim()
      if (!productHandle) return

      if (wishlist.some((item) => item.product_handle === productHandle)) {
        wishlistStatus.textContent = "That product is already in your wishlist."
        return
      }

      const nextWishlist = [...wishlist, { product_handle: productHandle }]
      await persistWishlist(nextWishlist, "Added. FieldsRaven accepted and queued the wishlist update.")
    })

    renderWishlist()
  </script>
{% else %}
  <p><a href="/account/login">Log in</a> to use your wishlist.</p>
{% endif %}
```

## Replacement and rendering behavior

Each add or remove sends the complete next array. While that request is in flight, the recipe marks the wishlist region busy and disables every mutation control so a second action cannot submit stale local state. Duplicate handles are ignored, and the in-memory array and DOM change only after FieldsRaven accepts the request. A rejected, non-JSON, rate-limited, or network response leaves the current list untouched and restores the control state.

The server-rendered Liquid list is based on the metafield value that existed when Shopify rendered the page. After an accepted save, that Liquid is stale until the shopper navigates or reloads; the JavaScript rendering rebuilds the same encoded product destinations for the immediate local view. Both paths treat stored handles as text rather than HTML. Full-array updates are last-write-wins, so two stale tabs can overwrite each other.

A successful 200 response means FieldsRaven accepted and queued the metafield write. It does not prove that Shopify or optional downstream processing has completed.

The Storefront Kit is optional. This complete direct request does not depend on it.


# Saved product configurations

Save, update, remove, and reopen customer product configurations from one JSON metafield.

This recipe stores customer-named designs in `custom.saved_configurations`. Every entry has a stable id, name, product handle, and merchant-defined options object. Create a customer-owned JSON Raven and paste **Get Code** so the page defines `window.FR_CUSTOM__CUSTOMER_SAVED_CONFIGURATIONS`.

Read the [saved-product-configuration implementation story](https://fieldsraven.app/use-cases/saved-product-configurations) and [Quick Start](/quick-start) before adapting the code to a configurator.

## Complete direct recipe

The single `color` option is illustrative. Replace it with the fields your configurator owns.

```liquid
{% if customer %}
  {% assign saved_configurations_metafield = customer.metafields.custom.saved_configurations.value %}
  <script type="application/json" id="fr-configurations-initial">
    {% if saved_configurations_metafield != blank %}{{ saved_configurations_metafield | json }}{% else %}[]{% endif %}
  </script>

  <section id="fr-configuration-region" aria-labelledby="fr-configuration-list-heading" aria-busy="false">
    <h2 id="fr-configuration-list-heading">Saved configurations</h2>

    <form id="fr-configuration-form">
      <input id="fr-configuration-id" name="configuration_id" type="hidden">

      <label for="fr-configuration-name">Configuration name</label>
      <input id="fr-configuration-name" name="name" type="text" required>

      <label for="fr-configuration-product">Product handle</label>
      <input id="fr-configuration-product" name="product_handle" type="text" required>

      <label for="fr-configuration-color">Color option</label>
      <input id="fr-configuration-color" name="color" type="text" required>

      <button id="fr-configuration-submit" type="submit">Save configuration</button>
      <p id="fr-configuration-status" aria-live="polite"></p>
    </form>

    <p id="fr-configuration-empty">No configurations are saved yet.</p>
    <ul id="fr-configuration-list"></ul>
  </section>

  <script>
    const configurationForm = document.getElementById("fr-configuration-form")
    const configurationButton = document.getElementById("fr-configuration-submit")
    const configurationStatus = document.getElementById("fr-configuration-status")
    const configurationList = document.getElementById("fr-configuration-list")
    const configurationEmpty = document.getElementById("fr-configuration-empty")
    const configurationRegion = document.getElementById("fr-configuration-region")
    let configurations = JSON.parse(document.getElementById("fr-configurations-initial").textContent)
    let configurationSaving = false

    function renderConfigurations() {
      configurationList.replaceChildren()
      configurationEmpty.hidden = configurations.length > 0

      for (const configuration of configurations) {
        const row = document.createElement("li")
        const summary = document.createElement("span")
        const open = document.createElement("button")
        const edit = document.createElement("button")
        const remove = document.createElement("button")

        summary.textContent = `${configuration.name} — ${configuration.product_handle} — ${configuration.options.color}`
        open.type = "button"
        open.textContent = `Open ${configuration.name}`
        open.disabled = configurationSaving
        open.addEventListener("click", () => {
          window.dispatchEvent(new CustomEvent("fieldsraven:open-configuration", {
            detail: { configuration }
          }))
        })
        edit.type = "button"
        edit.textContent = `Edit ${configuration.name}`
        edit.disabled = configurationSaving
        edit.addEventListener("click", () => editConfiguration(configuration))
        remove.type = "button"
        remove.textContent = `Remove ${configuration.name}`
        remove.disabled = configurationSaving
        remove.addEventListener("click", () => removeConfiguration(configuration.id))
        row.append(summary, " ", open, " ", edit, " ", remove)
        configurationList.append(row)
      }
    }

    function setConfigurationBusy(busy) {
      configurationSaving = busy
      configurationRegion.setAttribute("aria-busy", String(busy))
      configurationButton.disabled = busy
      for (const button of configurationList.querySelectorAll("button")) button.disabled = busy
    }

    function editConfiguration(configuration) {
      configurationForm.elements.configuration_id.value = configuration.id
      configurationForm.elements.name.value = configuration.name
      configurationForm.elements.product_handle.value = configuration.product_handle
      configurationForm.elements.color.value = configuration.options.color
      configurationForm.elements.name.focus()
    }

    async function readConfigurationResponse(response) {
      const contentType = response.headers.get("content-type") || ""
      if (!contentType.includes("application/json")) {
        throw new Error("FieldsRaven returned a non-JSON response. Please try again.")
      }

      try {
        return await response.json()
      } catch (error) {
        throw new Error("FieldsRaven returned invalid JSON. Please try again.")
      }
    }

    async function saveConfigurations(nextConfigurations) {
      const config = window.FR_CUSTOM__CUSTOMER_SAVED_CONFIGURATIONS

      try {
        const response = await fetch("/apps/raven/create_metafield", {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ raven: {
            raven_id: config.ravenId,
            resource_id: config.resourceId,
            raven_mac: config.ravenMac,
            value: JSON.stringify(nextConfigurations)
          } })
        })

        if (response.status === 429) throw new Error("Too many requests. Wait a moment and try again.")
        const result = await readConfigurationResponse(response)
        if (!response.ok) {
          const message = typeof result?.message === "string" ? result.message : "FieldsRaven rejected the configuration update."
          throw new Error(message)
        }
        return result
      } catch (error) {
        if (error instanceof TypeError) throw new Error("Network error. Check your connection and try again.")
        throw error
      }
    }

    async function persistConfigurations(nextConfigurations, successMessage) {
      if (configurationSaving) return false

      setConfigurationBusy(true)
      configurationStatus.textContent = "Saving…"

      try {
        await saveConfigurations(nextConfigurations)
        configurations = nextConfigurations
        renderConfigurations()
        configurationForm.reset()
        configurationStatus.textContent = successMessage
        return true
      } catch (error) {
        configurationStatus.textContent = error.message
        return false
      } finally {
        setConfigurationBusy(false)
      }
    }

    async function removeConfiguration(configurationId) {
      const nextConfigurations = configurations.filter((configuration) => configuration.id !== configurationId)
      await persistConfigurations(nextConfigurations, "Removed. FieldsRaven accepted and queued the configuration update.")
    }

    configurationForm.addEventListener("submit", async (event) => {
      event.preventDefault()
      const formData = new FormData(configurationForm)
      const configurationId = formData.get("configuration_id") || crypto.randomUUID()
      const nextConfiguration = {
        id: configurationId,
        name: formData.get("name").trim(),
        product_handle: formData.get("product_handle").trim(),
        options: { color: formData.get("color").trim() }
      }
      const existingIndex = configurations.findIndex((configuration) => configuration.id === configurationId)
      const nextConfigurations = existingIndex >= 0
        ? configurations.map((configuration) => configuration.id === configurationId ? nextConfiguration : configuration)
        : [...configurations, nextConfiguration]

      await persistConfigurations(nextConfigurations, "Saved. FieldsRaven accepted and queued the configuration update.")
    })

    renderConfigurations()
  </script>
{% else %}
  <p><a href="/account/login">Log in</a> to save product configurations.</p>
{% endif %}
```

## Configurator ownership and concurrency

The `fieldsraven:open-configuration` event is a handoff point. The merchant's configurator owns the event listener, product UI, validation, and the logic that applies `event.detail.configuration.options`; FieldsRaven does not reopen or render the product.

Every save, update, or removal sends a copied full array and swaps local state only after acceptance. The recipe marks the configuration region busy and disables its action buttons while a request is in flight, preventing overlapping writes from the same page. Rejected requests leave the current array and DOM unchanged. Full-array writes are still last-write-wins across stale tabs, so decide how your theme handles that separate concurrency boundary.

A successful 200 response means FieldsRaven accepted and queued the metafield write. It does not prove that Shopify or optional downstream processing has completed.

The Storefront Kit is optional. This complete direct request does not depend on it.


# Shopify vehicle garage

Add, select, and remove customer vehicles in one Shopify JSON metafield.

This recipe stores one object in `custom.vehicle_garage`: a `vehicles` array plus `selected_vehicle_id`. Create a customer-owned Raven with type `json`, then paste **Get Code** so the page defines `window.FR_CUSTOM__CUSTOMER_VEHICLE_GARAGE`.

The sample vehicle details are illustrative. Read the [vehicle-garage implementation story](https://fieldsraven.app/use-cases/shopify-vehicle-garage) and [Quick Start](/quick-start) for the shared request boundary.

## Complete direct recipe

```liquid
{% if customer %}
  {% assign saved_vehicle_garage = customer.metafields.custom.vehicle_garage.value %}
  <script type="application/json" id="fr-garage-initial">
    {% if saved_vehicle_garage != blank %}{{ saved_vehicle_garage | json }}{% else %}{"vehicles":[],"selected_vehicle_id":null}{% endif %}
  </script>

  <section id="fr-garage-region" aria-labelledby="fr-garage-list-heading" aria-busy="false">
    <h2 id="fr-garage-list-heading">Your garage</h2>

    <form id="fr-garage-form">
      <fieldset>
        <legend>Add a vehicle</legend>

        <label for="fr-garage-year">Year</label>
        <input id="fr-garage-year" name="year" type="number" inputmode="numeric" min="1900" max="2100" required>

        <label for="fr-garage-make">Make</label>
        <input id="fr-garage-make" name="make" type="text" autocomplete="off" required>

        <label for="fr-garage-model">Model</label>
        <input id="fr-garage-model" name="model" type="text" autocomplete="off" required>
      </fieldset>

      <button id="fr-garage-submit" type="submit">Add vehicle</button>
      <p id="fr-garage-status" aria-live="polite"></p>
    </form>

    <p id="fr-garage-empty">No vehicles are saved yet.</p>
    <ul id="fr-garage-list"></ul>
  </section>

  <script>
    const garageForm = document.getElementById("fr-garage-form")
    const garageButton = document.getElementById("fr-garage-submit")
    const garageStatus = document.getElementById("fr-garage-status")
    const garageList = document.getElementById("fr-garage-list")
    const garageEmpty = document.getElementById("fr-garage-empty")
    const garageRegion = document.getElementById("fr-garage-region")
    let garage = JSON.parse(document.getElementById("fr-garage-initial").textContent)
    let garageSaving = false

    function renderGarage() {
      garageList.replaceChildren()
      garageEmpty.hidden = garage.vehicles.length > 0

      for (const vehicle of garage.vehicles) {
        const row = document.createElement("li")
        const summary = document.createElement("span")
        const select = document.createElement("button")
        const remove = document.createElement("button")
        const selected = garage.selected_vehicle_id === vehicle.id

        summary.textContent = `${vehicle.year} ${vehicle.make} ${vehicle.model}${selected ? " — selected" : ""}`
        select.type = "button"
        select.textContent = selected ? `${vehicle.make} ${vehicle.model} is selected` : `Select ${vehicle.make} ${vehicle.model}`
        select.disabled = selected || garageSaving
        select.addEventListener("click", () => selectVehicle(vehicle.id))
        remove.type = "button"
        remove.textContent = `Remove ${vehicle.make} ${vehicle.model}`
        remove.disabled = garageSaving
        remove.addEventListener("click", () => removeVehicle(vehicle.id))
        row.append(summary, " ", select, " ", remove)
        garageList.append(row)
      }
    }

    function setGarageBusy(busy) {
      garageSaving = busy
      garageRegion.setAttribute("aria-busy", String(busy))
      garageButton.disabled = busy
      renderGarage()
    }

    async function readGarageResponse(response) {
      const contentType = response.headers.get("content-type") || ""
      if (!contentType.includes("application/json")) {
        throw new Error("FieldsRaven returned a non-JSON response. Please try again.")
      }

      try {
        return await response.json()
      } catch (error) {
        throw new Error("FieldsRaven returned invalid JSON. Please try again.")
      }
    }

    async function saveGarage(nextGarage) {
      const config = window.FR_CUSTOM__CUSTOMER_VEHICLE_GARAGE

      try {
        const response = await fetch("/apps/raven/create_metafield", {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ raven: {
            raven_id: config.ravenId,
            resource_id: config.resourceId,
            raven_mac: config.ravenMac,
            value: JSON.stringify(nextGarage)
          } })
        })

        if (response.status === 429) throw new Error("Too many requests. Wait a moment and try again.")
        const result = await readGarageResponse(response)
        if (!response.ok) {
          const message = typeof result?.message === "string" ? result.message : "FieldsRaven rejected the garage update."
          throw new Error(message)
        }
        return result
      } catch (error) {
        if (error instanceof TypeError) throw new Error("Network error. Check your connection and try again.")
        throw error
      }
    }

    async function persistGarage(nextGarage, successMessage) {
      if (garageSaving) return false

      setGarageBusy(true)
      garageStatus.textContent = "Saving…"

      try {
        await saveGarage(nextGarage)
        garage = nextGarage
        renderGarage()
        garageStatus.textContent = successMessage
        return true
      } catch (error) {
        garageStatus.textContent = error.message
        return false
      } finally {
        setGarageBusy(false)
      }
    }

    async function selectVehicle(vehicleId) {
      const nextGarage = { ...garage, selected_vehicle_id: vehicleId }
      await persistGarage(nextGarage, "Selected. FieldsRaven accepted and queued the garage update.")
    }

    async function removeVehicle(vehicleId) {
      const nextVehicles = garage.vehicles.filter((vehicle) => vehicle.id !== vehicleId)
      const nextGarage = {
        vehicles: nextVehicles,
        selected_vehicle_id: garage.selected_vehicle_id === vehicleId ? null : garage.selected_vehicle_id
      }
      await persistGarage(nextGarage, "Removed. FieldsRaven accepted and queued the garage update.")
    }

    garageForm.addEventListener("submit", async (event) => {
      event.preventDefault()
      const formData = new FormData(garageForm)
      const vehicle = {
        id: crypto.randomUUID(),
        year: Number(formData.get("year")),
        make: formData.get("make").trim(),
        model: formData.get("model").trim()
      }
      const nextGarage = {
        vehicles: [...garage.vehicles, vehicle],
        selected_vehicle_id: garage.selected_vehicle_id || vehicle.id
      }

      await persistGarage(nextGarage, "Added. FieldsRaven accepted and queued the garage update.")
      if (garage === nextGarage) garageForm.reset()
    })

    renderGarage()
  </script>
{% else %}
  <p><a href="/account/login">Log in</a> to manage your garage.</p>
{% endif %}
```

## Fitment and replacement behavior

FieldsRaven stores and queues the object. Compatibility rules, fitment data, and product filtering belong to the merchant theme or its fitment service. The selected id lives in the same object, so selection persists with the vehicle list.

Every action sends a copied next object and changes local state only after acceptance. The recipe marks the garage region busy and disables add, select, and remove controls while persistence is in flight, so same-page actions cannot race. Rejected requests leave the current garage and DOM untouched. Full-object writes remain last-write-wins across stale tabs.

A successful 200 response means FieldsRaven accepted and queued the metafield write. It does not prove that Shopify or optional downstream processing has completed.

The Storefront Kit is optional. This complete direct request does not depend on it.


# Shopify product registration

Append validated product registrations to a customer-owned Shopify JSON metafield.

This bare-bones recipe keeps a cumulative array of registrations in `custom.product_registrations`. Create a customer-owned Raven with type `json` and **metaobject sync disabled**, then paste its **Get Code** Liquid so the page defines `window.FR_CUSTOM__CUSTOMER_PRODUCT_REGISTRATIONS`.

The product handles, serials, and dates below are illustrative. See the [multi-region product-registration implementation story](https://fieldsraven.app/use-cases/shopify-product-registration) and [Quick Start](/quick-start) for the shared request contract.

## Complete direct recipe

```liquid
{% if customer %}
  {% assign saved_product_registrations = customer.metafields.custom.product_registrations.value %}
  <script type="application/json" id="fr-registrations-initial">
    {% if saved_product_registrations != blank %}{{ saved_product_registrations | json }}{% else %}[]{% endif %}
  </script>

  <form id="fr-registration-form">
    <fieldset>
      <legend>Register a product</legend>

      <label for="fr-registration-product">Product handle</label>
      <input id="fr-registration-product" name="product_handle" type="text" autocomplete="off" required>

      <label for="fr-registration-serial">Serial number</label>
      <input id="fr-registration-serial" name="serial" type="text" autocomplete="off" required>

      <label for="fr-registration-date">Purchase date</label>
      <input id="fr-registration-date" name="purchase_date" type="date" required>
    </fieldset>

    <button id="fr-registration-submit" type="submit">Register product</button>
    <p id="fr-registration-status" aria-live="polite"></p>
  </form>

  <section aria-labelledby="fr-registration-list-heading">
    <h2 id="fr-registration-list-heading">Registered products</h2>
    <p id="fr-registration-empty">No products are registered yet.</p>
    <ul id="fr-registration-list"></ul>
  </section>

  <script>
    const registrationForm = document.getElementById("fr-registration-form")
    const registrationButton = document.getElementById("fr-registration-submit")
    const registrationStatus = document.getElementById("fr-registration-status")
    const registrationList = document.getElementById("fr-registration-list")
    const registrationEmpty = document.getElementById("fr-registration-empty")
    let registrations = JSON.parse(document.getElementById("fr-registrations-initial").textContent)

    function renderRegistrations() {
      registrationList.replaceChildren()
      registrationEmpty.hidden = registrations.length > 0

      for (const registration of registrations) {
        const item = document.createElement("li")
        item.textContent = `${registration.product_handle} — ${registration.serial} — purchased ${registration.purchase_date}`
        registrationList.append(item)
      }
    }

    async function readRegistrationResponse(response) {
      const contentType = response.headers.get("content-type") || ""
      if (!contentType.includes("application/json")) {
        throw new Error("FieldsRaven returned a non-JSON response. Please try again.")
      }

      try {
        return await response.json()
      } catch (error) {
        throw new Error("FieldsRaven returned invalid JSON. Please try again.")
      }
    }

    async function saveRegistrations(nextRegistrations) {
      const config = window.FR_CUSTOM__CUSTOMER_PRODUCT_REGISTRATIONS

      try {
        const response = await fetch("/apps/raven/create_metafield", {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ raven: {
            raven_id: config.ravenId,
            resource_id: config.resourceId,
            raven_mac: config.ravenMac,
            value: JSON.stringify(nextRegistrations)
          } })
        })

        if (response.status === 429) throw new Error("Too many requests. Wait a moment and try again.")
        const result = await readRegistrationResponse(response)
        if (!response.ok) {
          const message = typeof result?.message === "string" ? result.message : "FieldsRaven rejected the registration."
          throw new Error(message)
        }
        return result
      } catch (error) {
        if (error instanceof TypeError) throw new Error("Network error. Check your connection and try again.")
        throw error
      }
    }

    registrationForm.addEventListener("submit", async (event) => {
      event.preventDefault()
      const formData = new FormData(registrationForm)
      const serial = formData.get("serial").trim()
      const productHandle = formData.get("product_handle").trim()
      const purchaseDate = formData.get("purchase_date")

      if (!productHandle || !serial || !purchaseDate) {
        registrationStatus.textContent = "Complete every field before submitting."
        return
      }
      if (registrations.some((registration) => registration.serial.toLowerCase() === serial.toLowerCase())) {
        registrationStatus.textContent = "That serial number is already registered."
        return
      }

      const nextRegistrations = [...registrations, {
        product_handle: productHandle,
        serial,
        purchase_date: purchaseDate,
        registered_at: new Date().toISOString()
      }]

      registrationButton.disabled = true
      registrationButton.setAttribute("aria-busy", "true")
      registrationStatus.textContent = "Saving…"

      try {
        await saveRegistrations(nextRegistrations)
        registrations = nextRegistrations
        renderRegistrations()
        registrationForm.reset()
        registrationStatus.textContent = "Saved. FieldsRaven accepted and queued the registration update."
      } catch (error) {
        registrationStatus.textContent = error.message
      } finally {
        registrationButton.disabled = false
        registrationButton.setAttribute("aria-busy", "false")
      }
    })

    renderRegistrations()
  </script>
{% else %}
  <p><a href="/account/login">Log in</a> to register a product.</p>
{% endif %}
```

## Data shape and metaobjects

The cumulative array is written back in full, so concurrent stale tabs are last-write-wins. The example prevents a duplicate serial in the current browser state, but a merchant with stronger uniqueness requirements should validate serials in a system designed for that rule.

Keep metaobject sync disabled for this cumulative-array Raven. If every registration must become a metaobject, use a separate event-shaped Raven that submits one registration object and map that object to a configured metaobject definition. Do not enable metaobject sync on the cumulative array.

A successful 200 response means FieldsRaven accepted and queued the metafield write. It does not prove that Shopify or optional downstream processing has completed. The browser updates its local list only after acceptance.

The Storefront Kit is optional. This complete direct request does not depend on it.


# Collect custom customer attributes

Build a simple form to collect custom customer attributes, store them into a customer metafield and optionally sync them with Klaviyo.

{% hint style="warning" %}
**Updated for the current API.** Earlier versions of this page used `Raven.send(ravenObj, valueObj)`, which posts to the legacy `/apps/raven/create_update_metafield` endpoint with an unwrapped payload. The Storefront Kit still ships it, but `FieldsRaven.send()` is the current call — it targets `/apps/raven/create_metafield` and wraps the payload itself.

Ids come from the **Get Code** panel's Liquid, which defines `window.FR_<...>` and computes the signature inline. There is no `raven-mac-gen` snippet to create any more — see [Quick Start](/quick-start).

This example's raven uses namespace `klaviyo_sync` and key `lets_be_friends`, matching the metafield the page reads back further down. A non-default namespace shows up in the global's name — see [Raven identity](/raven-identity).
{% endhint %}

[Demo](https://monosnap.com/file/XMzfPmLg7boIRoyWqVyjzwB3xbMEPH) (screen recording)

{% hint style="info" %}
Syncing with Klaviyo is only available for `customer` type resource
{% endhint %}

## Steps

1. Create a Raven to carry customer attributes payload, if you want to sync with Klaviyo, select "Sync with Klaviyo" and add Klaviyo private API key ([demo](https://monosnap.com/file/NfDsnAjQcTieL4yTZiiwucsaBDb7pr)).
2. Paste the raven's **Get Code** output into the theme — it defines `window.FR_<RESOURCE>_<KEY>` and computes the signature inline
3. Write a little bit of Liquid, HTML, CSS, and Javascript.
4. 🎉

{% hint style="info" %}
I used Tailwind CSS to style the form and AlpineJS to manage the state of the form and UI updates.
{% endhint %}

## Code

![](https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FdLp9Nibnc6jOjShVXcGB%2FLet's%20be%20friends%20%E2%80%93%20FieldsRaven%20Demo%202022-03-25%2010-39-14.png?alt=media\&token=ac924d00-4815-4bc2-8d7f-ee0f40218959)

```liquid
{% if customer %}
  <div class="form-state-wrapper"
    x-data="{
      test_field: null,
      customerAttrs: {
        sidedish: 'None',
        birthDay: null,
        birthMonth: null
      },
      hasError: false,
      isSubmitted: false,
      isValid() {
        return !Object.values(this.customerAttrs).some(value => value === null || value === '')
      },
      submitData() {
        console.log('this.isValid() ->', this.isValid());
        console.log('JSON.stringify(this.customerAttrs) ->', JSON.stringify(this.customerAttrs));
        if (this.isValid()) {
          const cfg = window.FR_KLAVIYO_SYNC__CUSTOMER_LETS_BE_FRIENDS;   // raven: namespace klaviyo_sync, key lets_be_friends
          const valueObj = { value: JSON.stringify(this.customerAttrs) };
          const response = FieldsRaven.send(Object.assign(
            { raven_id: cfg.ravenId, resource_id: cfg.resourceId, raven_mac: cfg.ravenMac },
            valueObj
          ));
          response.then(res => {
            if (res.status === 200) {
              console.log('🎉', res.json)
              this.hasError = false;
              this.isSubmitted = true;
              console.log('let us be friends 🎉👬');
            } else {
              console.error('😞', res)
            }
          })
          .catch(e => console.error(e));
        } else {
          this.isSubmitted = false;
          this.hasError = true;
        }
      }
    }"
  >
  <template x-if="isSubmitted">
    <div class="tw-rounded-md tw-bg-green-50 tw-p-4 tw-mb-8">
      <div class="tw-flex">
        <div class="tw-flex-shrink-0">
          <svg class="tw-h-5 tw-w-5 tw-text-green-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
          </svg>
        </div>
        <div class="tw-ml-3">
          <h3 class="tw-text-xl tw-font-medium tw-text-green-800">Thank you, we truly appreciate your friendship 🙌🙏 We'll be in touch!</h3>
        </div>
      </div>
    </div>
  </template>

  <template x-if="hasError">
    <div class="tw-rounded-md tw-bg-red-50 tw-p-4 tw-mb-8">
      <div class="tw-flex">
        <div class="tw-flex-shrink-0">
          <svg class="tw-h-5 tw-w-5 tw-text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
          </svg>
        </div>
        <div class="tw-ml-3">
          <h3 class="tw-text-xl tw-font-medium tw-text-red-800">Please select proper values so we can get this friendship started! 🎉👬</h3>
        </div>
      </div>
    </div>
  </template>
    
  <template x-if="!isSubmitted">
    <div id="form-wrapper">
      <code>
        {{ customer.metafields.klaviyo_sync.lets_be_friends.value }}
      </code><br>
      <div id="birthdate-wrapper">
        <fieldset class="tw-mb-8 tw-bg-white tw-max-w-xs">
          <legend class="tw-block tw-text-xl tw-font-medium tw-text-gray-700 tw-mb-4">Care to share your birthday?</legend>
          <div class="tw-mt-1 tw-rounded-md tw-shadow-sm tw-space-y-px">
            <div>
              <label for="birth-day" class="tw-sr-only">Day</label>
              <select x-model="customerAttrs.birthDay" id="birth-day" name="birth-day" class="focus:tw-ring-indigo-500 focus:tw-border-indigo-500 tw-relative tw-block tw-w-full tw-rounded-none tw-rounded-t-md tw-bg-transparent focus:tw-z-10 sm:tw-text-xl tw-border-gray-300">
                <option value="" selected>Select day</option>
                {% for day in (1..31) %}
                <option value="{{ day }}">{{ day }}</option>
                {% endfor %}
              </select>
            </div>
            <div>
              {% assign months = "January, February, March, April, May, June, July, August, September, October, November, Decembe" | split: ',' %}
              <label for="birth-month" class="tw-sr-only">Month</label>
              <select x-model="customerAttrs.birthMonth" id="birth-month" name="birth-month" class="focus:tw-ring-indigo-500 focus:tw-border-indigo-500 tw-relative tw-block tw-w-full tw-rounded-none tw-rounded-b-md tw-bg-transparent focus:tw-z-10 sm:tw-text-xl tw-border-gray-300">
                <option value="" selected>Select month</option>
                {% for month_raw in months %}
                {% assign month = month_raw | strip %}
                <option value="{{ month }}">{{ month }}</option>
                {% endfor %}
              </select>
            </div>
          </div>
        </fieldset>
      </div>

      <div id="sidedish-wrapper">
        <fieldset class="mt-4">
          <legend class="tw-block tw-text-xl tw-font-medium tw-text-gray-700 tw-mb-4">What's your favorite side dish?</legend>
          <div class="tw-space-y-4 sm:tw-flex sm:tw-items-center sm:tw-space-y-0 sm:tw-space-x-10">
            <div class="tw-flex tw-items-center">
              <input x-model="customerAttrs.sidedish" value="None" id="sidedish-none" name="favorite-sidedish" type="radio" checked class="focus:tw-ring-indigo-500 tw-h-4 tw-w-4 tw-text-indigo-600 border-gray-300">
              <label for="sidedish-none" class="tw-ml-3 tw-block tw-text-xl tw-font-medium tw-text-gray-700"> None </label>
            </div>

            <div class="tw-flex tw-items-center">
              <input x-model="customerAttrs.sidedish" value="Baked beans" id="sidedish-baked-beans" name="favorite-sidedish" type="radio" class="focus:tw-ring-indigo-500 tw-h-4 tw-w-4 tw-text-indigo-600 border-gray-300">
              <label for="sidedish-baked-beans" class="tw-ml-3 tw-block tw-text-xl tw-font-medium tw-text-gray-700"> Baked beans </label>
            </div>

            <div class="tw-flex tw-items-center">
              <input x-model="customerAttrs.sidedish" value="Coleslaw" id="sidedish-coleslaw" name="favorite-sidedish" type="radio" class="focus:tw-ring-indigo-500 tw-h-4 tw-w-4 tw-text-indigo-600 border-gray-300">
              <label for="sidedish-coleslaw" class="tw-ml-3 tw-block tw-text-xl tw-font-medium tw-text-gray-700"> Coleslaw </label>
            </div>

            <div class="tw-flex tw-items-center">
              <input x-model="customerAttrs.sidedish" value="French fries" id="sidedish-frenchfries" name="favorite-sidedish" type="radio" class="focus:tw-ring-indigo-500 tw-h-4 tw-w-4 tw-text-indigo-600 border-gray-300">
              <label for="sidedish-frenchfries" class="tw-ml-3 tw-block tw-text-xl tw-font-medium tw-text-gray-700"> French fries </label>
            </div>

            <div class="tw-flex tw-items-center">
              <input x-model="customerAttrs.sidedish" value="Garden salad" id="sidedish-gardensalad" name="favorite-sidedish" type="radio" class="focus:tw-ring-indigo-500 tw-h-4 tw-w-4 tw-text-indigo-600 border-gray-300">
              <label for="sidedish-gardensalad" class="tw-ml-3 tw-block tw-text-xl tw-font-medium tw-text-gray-700"> Garden salad </label>
            </div>

            <div class="tw-flex tw-items-center">
              <input x-model="customerAttrs.sidedish" value="Mashed potatoes" id="sidedish-mashed-potatoes" name="favorite-sidedish" type="radio" class="focus:tw-ring-indigo-500 tw-h-4 tw-w-4 tw-text-indigo-600 border-gray-300">
              <label for="sidedish-mashed-potatoes" class="tw-ml-3 tw-block tw-text-xl tw-font-medium tw-text-gray-700"> Mashed potatoes </label>
            </div>
          </div>
        </fieldset>
      </div>
      <button @click="submitData()" type="button" class="tw-mt-8 tw-inline-flex tw-items-center tw-px-4 tw-py-2 tw-border tw-border-transparent tw-text-xl tw-font-medium tw-rounded-md tw-shadow-sm tw-text-white tw-bg-indigo-600 hover:tw-bg-indigo-700 focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-offset-2 focus:tw-ring-indigo-500">Let's be friends!</button>
    </div>
  </template>
{% else %}
  <div class="tw-bg-white tw-border-gray-200 tw-shadow-sm tw-rounded-lg tw-border tw-p-4">
    <p>Friends share emails together 👬 Please <a class="tw-text-indigo-600 tw-whitespace-nowrap hover:tw-text-indigo-500" href="/account/login">log in</a> or <a class="tw-text-indigo-600 tw-whitespace-nowrap hover:tw-text-indigo-500" href="/account/register">register</a> so we can be better friends 🙌</p>
  </div>
{% endif %}

```


# Customer registration with custom attributes (legacy)

Legacy — works only on stores using classic customer accounts.

{% hint style="danger" %}
**This example only works on stores using classic customer accounts.**

It hooks the theme's own `/account/register` form. Under Shopify's newer **customer accounts**, signup is hosted by Shopify and managed separately from your theme — the theme's registration form is not part of the flow, so there is nowhere to inject a custom field. No amount of theme code changes that.

Kept here because stores on classic customer accounts still work exactly as described.
{% endhint %}

{% hint style="success" %}
**On new customer accounts, collect the attribute&#x20;*****after*****&#x20;signup instead.** A customer is logged in by then, which is all a customer-owned raven needs — put the field on an account or preferences page and submit it normally. See [Collect custom customer attributes](/example-features/collect-custom-customer-attributes), which needs no registration hook at all.
{% endhint %}

{% hint style="info" %}
I’m using AlpineJS to manage the state of the fields on the registration form, AlpineJS is already included with FieldsRaven theme extension, make sure to remove it if you'd rather go VanillaJS or use SomethingElseJS. ([How to remove AlpineJS](https://monosnap.com/file/bLMikikJaht4sOIT2K79o5y3tz8VI4))
{% endhint %}

[Demo](https://monosnap.com/file/FwZJeuhlBsbNoWmWoH5s3OE6eCApYI) (screen recording)

### Steps:

1. Create raven ([screen recording demo](https://monosnap.com/file/mrXL3nE2zVZ1zMl1Ovz1DMfjzCE7W3))
2. Hack customer registration form to store customer attributes in localStorage if they exists (example code below)
3. After customer registration is success and customer is logged-in check if localStorage key exists
4. If custom attributes exists in localStorage send a raven with it to create a customer metafield and optionally sync fields with Klaviyo
5. If the raven is successful delete the localStorage

![](https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FXAx17fBf7iZA6hC16ecs%2FCreate%20Account%20%E2%80%93%20FieldsRaven%20Demo%202022-03-25%2011-31-06.png?alt=media\&token=917ae152-8761-4e09-97a7-d2a6c558a36b)

## Code

```liquid
{% comment %}
register.liquid
{% endcomment %}

{{ 'customer.css' | asset_url | stylesheet_tag }}

<div class="customer register"
  x-data="{
    isValid() {
      let invalid = Object.values(this.customerAttrs).some(v => v === null || v === '' );
      console.log('🛑 invalid: ', invalid);
      return !invalid;
    },
    customerAttrs: {
      mostImportantDate: null,
      intrest: null,
      acceptsEmail: false,
      acceptsSMS: false
    }
  }"
  x-init="
    $watch('customerAttrs', value => {
      if (isValid()) {
        console.log('💾💾 saving customer_registration_custom_attributes to local storage 💾💾');
        localStorage.setItem('customer_registration_custom_attributes', JSON.stringify(value));
      }
    })
  "
>
  <svg style="display: none">
    <symbol id="icon-error" viewBox="0 0 13 13">
      <circle cx="6.5" cy="6.50049" r="5.5" stroke="white" stroke-width="2"/>
      <circle cx="6.5" cy="6.5" r="5.5" fill="#EB001B" stroke="#EB001B" stroke-width="0.7"/>
      <path d="M5.87413 3.52832L5.97439 7.57216H7.02713L7.12739 3.52832H5.87413ZM6.50076 9.66091C6.88091 9.66091 7.18169 9.37267 7.18169 9.00504C7.18169 8.63742 6.88091 8.34917 6.50076 8.34917C6.12061 8.34917 5.81982 8.63742 5.81982 9.00504C5.81982 9.37267 6.12061 9.66091 6.50076 9.66091Z" fill="white"/>
      <path d="M5.87413 3.17832H5.51535L5.52424 3.537L5.6245 7.58083L5.63296 7.92216H5.97439H7.02713H7.36856L7.37702 7.58083L7.47728 3.537L7.48617 3.17832H7.12739H5.87413ZM6.50076 10.0109C7.06121 10.0109 7.5317 9.57872 7.5317 9.00504C7.5317 8.43137 7.06121 7.99918 6.50076 7.99918C5.94031 7.99918 5.46982 8.43137 5.46982 9.00504C5.46982 9.57872 5.94031 10.0109 6.50076 10.0109Z" fill="white" stroke="#EB001B" stroke-width="0.7">
    </symbol>
  </svg>
  <h1>
    {{ 'customer.register.title' | t }}
  </h1>
  {%- form 'create_customer', novalidate: 'novalidate' -%}
    {%- if form.errors -%}
      <h2 class="form__message" tabindex="-1" autofocus>
        <svg aria-hidden="true" focusable="false" role="presentation">
          <use href="#icon-error" />
        </svg>
        {{ 'templates.contact.form.error_heading' | t }}
      </h2>
      <ul> 
        {%- for field in form.errors -%}
          <li>
            {%- if field == 'form' -%}
              {{ form.errors.messages[field] }}
            {%- else -%}
              <a href="#RegisterForm-{{ field }}">
                {{ form.errors.translated_fields[field] | capitalize }}
                {{ form.errors.messages[field] }}
              </a>
            {%- endif -%}
          </li>
        {%- endfor -%}
      </ul>
    {%- endif -%}
    <div class="field">      
      <input
        type="text"
        name="customer[first_name]"
        id="RegisterForm-FirstName"
        {% if form.first_name %}value="{{ form.first_name }}"{% endif %}
        autocomplete="given-name"
        placeholder="{{ 'customer.register.first_name' | t }}"
      >
      <label for="RegisterForm-FirstName">
        {{ 'customer.register.first_name' | t }}
      </label>
    </div>
    <div class="field">
      <input
        type="text"
        name="customer[last_name]"
        id="RegisterForm-LastName"
        {% if form.last_name %}value="{{ form.last_name }}"{% endif %}
        autocomplete="family-name"
        placeholder="{{ 'customer.register.last_name' | t }}"
      >
      <label for="RegisterForm-LastName">
        {{ 'customer.register.last_name' | t }}
      </label>
    </div>
    <div class="field">      
      <input
        type="email"
        name="customer[email]"
        id="RegisterForm-email"
        {% if form.email %} value="{{ form.email }}"{% endif %}
        spellcheck="false"
        autocapitalize="off"
        autocomplete="email"
        aria-required="true"
        {% if form.errors contains 'email' %}
          aria-invalid="true"
          aria-describedby="RegisterForm-email-error"
        {% endif %}
        placeholder="{{ 'customer.register.email' | t }}"
      >
      <label for="RegisterForm-email">
        {{ 'customer.register.email' | t }}
      </label>
    </div>
    {%- if form.errors contains 'email' -%}
      <span id="RegisterForm-email-error" class="form__message">
        <svg aria-hidden="true" focusable="false" role="presentation">
          <use href="#icon-error" />
        </svg>
        {{ form.errors.translated_fields['email'] | capitalize }} {{ form.errors.messages['email'] }}.
      </span>
    {%- endif -%}
    <div class="field">     
      <input
        type="password"
        name="customer[password]"
        id="RegisterForm-password"
        aria-required="true"
        {% if form.errors contains 'password' %}
          aria-invalid="true"
          aria-describedby="RegisterForm-password-error"
        {% endif %}
        placeholder="{{ 'customer.register.password' | t }}"
      >
      <label for="RegisterForm-password">
        {{ 'customer.register.password' | t }}
      </label>
    </div>
    {%- if form.errors contains 'password' -%}
      <span id="RegisterForm-password-error" class="form__message">
        <svg aria-hidden="true" focusable="false" role="presentation">
          <use href="#icon-error" />
        </svg>
        {{ form.errors.translated_fields['password'] | capitalize }} {{ form.errors.messages['password'] }}.
      </span>
    {%- endif -%}
    {% comment %}
    {% endcomment %}
    {% render 'include-registration-extra-attributes' %}

    <button x-show="isValid()">
      {{ 'customer.register.submit' | t }}
    </button>
  {%- endform -%}
</div>

```

```liquid
{% comment %}
include-registration-extra-attributes.liquid
{% endcomment %}

<fieldset class="tw-mt-8">
  <legend class="tw-block tw-text-2xl tw-text-left tw-font-medium tw-text-gray-900 tw-mb-4">What are you interested in?</legend>
  <div class="tw-space-y-4 sm:tw-flex sm:tw-items-center sm:tw-space-y-0 sm:tw-space-x-10">
    <input type="date" id="start" name="customer-dob" x-model="customerAttrs.mostImportantDate" class="focus:tw-ring-indigo-500 focus:tw-border-indigo-500 tw-relative tw-block tw-w-full tw-rounded tw-rounded-md tw-bg-transparent focus:tw-z-10 sm:tw-text-xl tw-border-gray-300">
  </div>
</fieldset>

<fieldset class="tw-mt-8">
  <legend class="tw-block tw-text-2xl tw-text-left tw-font-medium tw-text-gray-900 tw-mb-4">What are you interested in?</legend>
  <div class="tw-space-y-4 sm:tw-flex sm:tw-items-center sm:tw-space-y-0 sm:tw-space-x-10">
    <div class="tw-flex tw-items-center">
      <input value="HTML" id="html" name="customer-intrest" type="radio" checked class="focus:tw-ring-indigo-500 tw-h-4 tw-w-4 tw-text-indigo-600 border-gray-300" x-model="customerAttrs.intrest">
      <label for="sidedish-none" class="tw-ml-3 tw-block tw-text-xl tw-font-medium tw-text-gray-700"> HTML </label>
    </div>

    <div class="tw-flex tw-items-center">
      <input value="CSS" id="css" name="customer-intrest" type="radio" checked class="focus:tw-ring-indigo-500 tw-h-4 tw-w-4 tw-text-indigo-600 border-gray-300" x-model="customerAttrs.intrest">
      <label for="sidedish-none" class="tw-ml-3 tw-block tw-text-xl tw-font-medium tw-text-gray-700"> CSS </label>
    </div>

    <div class="tw-flex tw-items-center">
      <input value="JavaScript" id="javascript" name="customer-intrest" type="radio" checked class="focus:tw-ring-indigo-500 tw-h-4 tw-w-4 tw-text-indigo-600 border-gray-300" x-model="customerAttrs.intrest">
      <label for="sidedish-none" class="tw-ml-3 tw-block tw-text-xl tw-font-medium tw-text-gray-700"> JavaScript </label>
    </div>
  </div>
</fieldset>

<fieldset class="tw-mt-8">
  <legend class="tw-block tw-text-2xl tw-text-left tw-font-medium tw-text-gray-900 tw-mb-4">Would you like to receive updates?</legend>
  <div class="tw-relative tw-flex tw-items-start">
    <div class="tw-flex tw-items-center tw-h-6">
      <input id="updates_email" name="updates_email" type="checkbox" class="focus:tw-ring-indigo-500 tw-h-6 tw-w-6 tw-text-indigo-600 tw-border-gray-300 tw-rounded" x-model="customerAttrs.acceptsEmail" style="width: 1.5rem !important">
    </div>
    <div class="tw-ml-3 tw-text-xl">
      <label for="updates_email" class="tw-font-medium tw-text-gray-700">Email</label>
    </div>
  </div>
  <div class="tw-relative tw-flex tw-items-start">
    <div class="tw-flex tw-items-center tw-h-6">
      <input id="updates_sms" name="updates_sms" type="checkbox" class="focus:tw-ring-indigo-500 tw-h-6 tw-w-6 tw-text-indigo-600 tw-border-gray-300 tw-rounded" x-model="customerAttrs.acceptsSMS" style="width: 1.5rem !important">
    </div>
    <div class="tw-ml-3 tw-text-xl">
      <label for="updates_sms" class="tw-font-medium tw-text-gray-700">SMS</label>
    </div>
  </div>
</fieldset>

```

```liquid
{% comment %}
include-initial-customer-attributes.liquid
Add this snippet at the bottom of theme.liquid
{% endcomment %}

{% if customer %}
  <script type="text/javascript">
    ravenCustomerCustomAttrsSubmit = (value) => {
      const ravenObj = {%- render 'raven-mac-gen', resource_id: customer.id, raven_id: 'TBD' -%};
      const valueObj = { value: value };
      const response = Raven.send(ravenObj, valueObj);
      response.then(res => {
        if (res.status === 200) {
          console.log('🎉', res.json)
          localStorage.removeItem('customer_registration_custom_attributes');
        } else {
          console.error('😞', res.json)
        }
      })
      .catch(e => console.error(e));
    }

    window.addEventListener('DOMContentLoaded', (event) => {
      let customer_registration_custom_attributes  = localStorage.getItem('customer_registration_custom_attributes');
      if (customer_registration_custom_attributes) {
        console.log('💾💾 customer_registration_custom_attributes 💾💾', customer_registration_custom_attributes);
        ravenCustomerCustomAttrsSubmit(customer_registration_custom_attributes)
      }
    });
  </script>
{% endif %}

```


# Troubleshooting

## Finding pages in Shopify Admin

FieldsRaven uses Shopify Admin's app sidebar for navigation. The FieldsRaven app name or icon opens the **Dashboard**; the visible rows are **Failed Ops**, **Ravens**, **Settings**, and **Help & Support**.

Fields do not have a separate sidebar row. Open a field's **Review** link from Dashboard activity or **Failed Ops**. Field review pages use stable top-level `/fields/<id>` addresses; older numeric `/shops/<shop-id>/...` bookmarks redirect temporarily to their shopless destination. If a copied link opens outside the embedded app or cannot restore the shop session, reopen FieldsRaven from **Shopify Admin → Apps** and navigate from the sidebar.

Successful actions appear as a neutral Shopify toast and dismiss after about five seconds. Errors and alerts stay in a red message at the top of the page until you navigate away, so you have time to read and resolve them.

## Rejections — `422`

A rejected submission returns **422** with the reason in `message`. The messages below come straight from the app, so you can match on them.

### Raven not found

`Raven ID is missing or raven can't be found`

The `raven_id` you sent doesn't resolve to a raven **on this shop**. Usual causes: a typo, a raven that was deleted, or an id copied from a different store — a raven id is scoped to one shop, so a dev-store id will not work in production.

### Raven is switched off

`Raven is inactive, activate it to be able to send messages`

The raven resolves fine — it's just not active. Switch it on in the app; nothing in your storefront code needs to change.

### Signature rejected

A 422 whose `message` is an **object**, not a string:

```json
{ "message": { "valid_params": true, "valid_auth_code": false } }
```

Match on `message.valid_auth_code === false`, not on any text. The signature didn't match. `raven_mac` is an HMAC over `raven_id + resource_id`, keyed by your shop's `fields_raven.api_secret` metafield. It fails when:

* **The secret is missing from the shop.** It's written at install, but occasionally that write fails. Check Settings — the secret is shown behind the eye icon. If it's absent, [reach out](mailto:karim@fieldsraven.app).
* **The digest was built from different values than the ones sent.** The signed `resource_id` must be byte-identical to the `resource_id` in the payload. Signing `customer.id` and sending a product id fails, and so does signing on one page and posting from another.
* **The MAC was generated for a different raven.** Each raven signs with its own id.

`valid_params` in the same object separates "your parameters were malformed" from "your signature was wrong" — check which one is `false` before hunting the signature.

{% hint style="info" %}
This is the shape returned by `/apps/raven/create_metafield`. The deprecated `/apps/raven/create_update_metafield` answers a missing raven or a bad signature with **400**, not 422 — if you are debugging an older integration, check the status code first.
{% endhint %}

### Customer email rejected

`Invalid customer email`

Only the email-as-identifier variant produces this: the address submitted isn't valid. For ordinary customer-owned ravens the customer id comes from the signed `resource_id`, so a logged-out visitor fails the signature check above rather than reaching this one — requests should be sent by a logged-in customer either way.

## Rate limiting — `429`

There are **two different 429s**, and they mean different things.

### Shopify throttled the write

A `429` **with** a `Retry-After` header.

The header carries Shopify's own suggested delay, in seconds. Wait that long, then retry — the submission was not recorded as failed, and for ordinary writes FieldsRaven retries in the background on your behalf.

### Your queue is too deep

A `429` **without** a `Retry-After` header.

Backpressure from FieldsRaven itself, not a Shopify limit. It clears as the queue drains. If it happens constantly rather than in bursts, you're submitting faster than Shopify will accept writes for your store; batch your submissions instead.

Generated code already handles the common case. If you hand-rolled your integration, treat a 429 as "retry shortly", never as a failed submission.

## Deletes

`DELETE /apps/raven/delete_metafield` has two responses worth handling explicitly:

* **422** — Shopify refused the delete, and the message is Shopify's own. **Older versions of FieldsRaven reported success regardless**, so if your integration predates that fix, it may be treating failed deletes as successful. Worth re-checking.
* **429** with `Retry-After` — as above.

## The metafield saves, but the value is wrong or empty

* **Blank values are rejected for most types.** Values are validated against the metafield's type before being sent. Use a type-appropriate empty value — `0` for numbers, `{}` for JSON — and handle the display in Liquid. To remove a metafield entirely, use the delete endpoint.
* **A definition you created in Shopify admin may have validations of its own.** If the metafield was defined there with rules — a maximum length, an allowed-values list, a numeric range — Shopify enforces them on write, and the failure surfaces as an error from Shopify rather than from FieldsRaven. Make sure the value you send satisfies the definition, and that its type matches.

## The metafield doesn't appear immediately

Expected. A submission is accepted and acknowledged straight away, then written to Shopify in the background — so a `200` means *queued*, not *stored*. Add Shopify's own write time and its storefront cache on top. An unpublished theme reflects a new metafield sooner than a live one.

## Two forms on one page and only the first works

The second form never bound. Form ids and the JavaScript global are derived from the raven's **resource, namespace and key together**; if you hand-wrote identifiers from the key alone, two ravens sharing a key produce duplicate ids, and `getElementById` returns only the first.

Regenerate both snippets from the **Get Code** panel, which derives collision-safe identifiers for you. See [Quick Start](/quick-start).

## Raven settings conflicts and partial saves

FieldsRaven protects Raven settings with a revision check so that one browser tab cannot silently overwrite changes saved from another tab or process.

* **“This Raven changed in another session. Review the latest settings and try again.”** FieldsRaven did not apply your stale edit. The form now shows the latest saved settings; review them and submit your change again.
* **“Some remote changes may have completed, but this Raven was not saved…”** A Shopify definition, pin, or customer-link action may have completed before the local Raven save encountered a conflict. Inspect the refreshed Raven settings and the related Shopify definition before retrying so you do not assume nothing happened.
* **“This Raven was saved, but some remote follow-up work may be incomplete…”** The local Raven settings were saved. Review the Raven's current status and Shopify setup before retrying the unfinished remote work.

Do not repeatedly submit the stale form. Start from the refreshed state FieldsRaven displays, confirm what Shopify already applied, set the Raven to your intended final configuration, and submit it once.

## Metaobject sync issues

A metaobject failure never blocks the metafield write, so the submission's own status stays *success* and these are tracked separately — look at **Failed Operations** and the dashboard status badges rather than the submission list. See [Metaobject sync](/metaobject-sync).


# Metaobject sync

Mirror each storefront submission into a Shopify metaobject entry, alongside the metafield FieldsRaven already writes.

A JSON-type raven can mirror every storefront submission into a **Shopify metaobject entry**, in addition to the metafield it already writes.

This is a post-processing step bolted onto the existing pipeline. The metafield write is untouched, and a metaobject failure — bad data, a Shopify error, a dropped connection — never changes the submission's status and never blocks the metafield from saving. If metaobject sync breaks, your storefront keeps working exactly as before.

## Why you'd want it

A JSON metafield is one opaque blob. A metaobject entry is a typed record with named fields, which means Shopify itself can filter, reference, and render it — and other apps and Flow can read it as structured data rather than parsing your JSON.

## You own the definition, not FieldsRaven

FieldsRaven asks for three scopes:

* `read_metaobject_definitions`
* `read_metaobjects`
* `write_metaobjects`

`write_metaobject_definitions` is deliberately **not** among them. FieldsRaven never creates or edits a metaobject definition. You build and own the definition in Shopify admin; the app only reads it and writes entries against it.

Existing merchants keep working exactly as before and opt in when they choose. If your store hasn't granted these scopes yet, **Settings** shows a "Permissions update needed" section with a re-authorization button, and metaobject sync stays dormant until you approve it. Nothing is forced, and declining costs you nothing else.

## Setting it up

### 1. Get the definition brief

Before a matching definition exists, FieldsRaven drafts one for you as a checklist — inferring field names and types either from a real prior submission or from a sample payload you paste in.

The pasted sample is sent as form data, never in a URL, and is never echoed back into the brief's output or into any log. Query strings leak into proxy logs, browser history, and referrer headers; a sample payload has no business in any of them.

### 2. Build the definition in Shopify admin

Follow the brief. Create the metaobject definition with the field names and types it lists.

### 3. Attach it to the raven

In the raven's settings, enable metaobject sync and select your definition. From the next submission on, each one writes both a metafield and a metaobject entry.

## How values are converted

Coercion is **lenient in, canonical out**: FieldsRaven accepts the forms a real HTML form actually submits, then normalizes them to what Shopify wants.

Numbers are parsed against HTML's own "valid floating-point number" grammar and canonicalized, bounded by Shopify's numeric limits:

| You submit | Stored as |
| ---------- | --------- |
| `007`      | `7`       |
| `1e3`      | `1000`    |
| `.5`       | `0.5`     |

Booleans accept `yes`, `no`, `on`, `off`, `1`, and `0` in any case — `on` in particular is what a bare HTML checkbox submits.

This matters because `<input type="number">` legitimately permits leading zeros and exponent form. Earlier versions accepted only the exact canonical spelling, which meant FieldsRaven manufactured failures from values emitted by the very forms it generates.

Values that genuinely don't fit the field's type are still rejected, and surface as described below.

## Retries and idempotency

Each field's entry upserts against a **handle derived from the raven and field**, so a retry after a lost response converges on the same entry rather than creating a duplicate. `SyncMetaobjectJob` retries up to 8 times.

**Nothing already synced to Shopify is ever deleted by this app.** Disabling sync, switching definitions, or deleting the raven all leave existing entries in place. If you want an entry gone, remove it in Shopify admin.

## Linking entries to a customer

Optional, and **off by default**. For customer-owned ravens, FieldsRaven can maintain a `list.metaobject_reference` customer metafield listing that customer's entries, so you can render a customer's submission history directly in Liquid.

Entries are appended with compare-and-set, so two near-simultaneous submissions can't overwrite each other's link.

The list is capped at **256 entries per customer**. Past that, new entries still sync — they just stop being added to the link list, and the raven's dashboard shows a warning.

## Keeping an eye on it

**Status badges** appear across the dashboard views: *synced*, *pending*, *needs attention*, and *out of sync*.

**Failed Operations** lists metaobject sync failures that you can actually act on. A failed sync leaves the submission's own status at *success* — the JSON metafield write did succeed — so these are tracked separately rather than being reported as broken submissions.

Failures still being retried are deliberately excluded from that list. A delivery that hasn't finished isn't a failure yet, and listing it would report a submission as broken while it's still on its way.

**Drift detection** runs daily: FieldsRaven re-reads each linked definition and flags the raven if the definition changed or was deleted. A transient connection failure is never read as "the definition was deleted" — that distinction matters, because collapsing it would falsely flag every synced raven during a Shopify outage.


# Klaviyo

## To sync Shopify metafields with Klaviyo you need to create a "Private API Key" in Klaviyo

<div><figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FD1xfvnXdGtkRWUOfHKbc%2FKlaviyo%202024-03-01%2010-19-57.png?alt=media&#x26;token=af3b8ab0-4f41-4181-a4c0-00a73a112d6c" alt=""><figcaption></figcaption></figure> <figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FUWpQkjUMIE3Ovnmxd5Ap%2FKlaviyo%202024-03-01%2010-21-39.png?alt=media&#x26;token=3301d45c-414e-4e40-bd0b-50174ebcd389" alt=""><figcaption></figcaption></figure> <figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FpO5kJNEfEb3CAtEb5umc%2FKlaviyo%202024-03-01%2010-22-33.png?alt=media&#x26;token=766bdf3b-ac76-4894-9023-e305ed2ccc14" alt=""><figcaption></figcaption></figure></div>

## Setup Klaviyo sync in FieldsRaven

{% hint style="warning" %}
**Klaviyo sync only runs when all of these hold.** If a submission never appears in Klaviyo, this list is where to look:

* The raven's resource is **`customer`**.
* Klaviyo sync is enabled on the raven **and** a private API key is available from Settings or the raven's legacy credential.
* The submission resolved a **customer email** — that is what FieldsRaven matches on in Klaviyo.
* The submission reached status **success** or **approved**. A raven with *needs approval* turned on does not sync until the submission is approved.
  {% endhint %}

{% hint style="danger" %}
**The profile must already exist in Klaviyo.** FieldsRaven looks the customer up by email and requires an exact match, ignoring case. If no profile matches, the sync fails rather than creating one — so a customer who has never been added to Klaviyo will not appear because of a FieldsRaven submission.
{% endhint %}

### Save the Klaviyo credential

1. Open **Settings** in FieldsRaven.
2. Under **Integration credentials**, enter the Klaviyo private API key.
3. Select **Update integration credentials**.

FieldsRaven never displays a stored integration credential. Leave the field blank on a later visit to preserve the current key; enter a nonblank value only when you want to replace it.

{% hint style="warning" %}
An older Raven that already stores its own Klaviyo key continues to use that key. The legacy Raven credential overrides the credential in Settings, and replacing the Settings credential does not rotate the legacy value.
{% endhint %}

### Enable Klaviyo on a Raven

When creating a customer Raven, toggle **Sync with Klaviyo**. Leave the Raven's API key field blank to use the shop credential from Settings.

Demo -> <https://monosnap.com/file/DB1bRi7qixXvlVdVws37f4grQdJo1J>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FdoAy4vcQ6xg6p680VsNu%2FFieldsRaven%20Demo%20%C2%B7%20FieldsRaven%20%C2%B7%20Shopify%202024-09-24%2008-54-48.png?alt=media&#x26;token=015c18b7-61a7-4b45-809d-3837f6555b0c" alt=""><figcaption></figcaption></figure>

{% hint style="info" %}
The sync job is queued **15 seconds** after the metafield write, then runs subject to your shop's queue.
{% endhint %}

## Sync Shopify metafield into Klaviyo customer profile

When you sync a metafield into Klaviyo's customer profiles, property name in Klaviyo will be the metafield's key in Shopify. For example if you're syncing this metafield `customer.metafields.social_media_profiles.facebook` then property name in Klaviyo will be `facebook`

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2Fd8wkiibB9e8ACVgc9uHC%2FKarim%20Tarek%20%7C%20Klaviyo%202023-04-15%2008-37-39.png?alt=media&#x26;token=52f8e045-e074-4df4-b453-bed2db09fea3" alt="Klaviyo customer property name screenshot"><figcaption><p>Klaviyo customer property name example</p></figcaption></figure>

## Sync Shopify JSON metafield into Klaviyo customer profile

When you sync a JSON type metafield, each property of the JSON object becomes a customer property in Klaviyo. Nested objects are flattened with an underscore (`address_city`), and arrays are numbered from 1 (`tags_1`, `tags_2`).

{% hint style="warning" %}
**A JSON object with exactly one top-level key is sent unflattened.** Flattening is skipped in that case, so `{"address": {"city": "Vancouver"}}` arrives in Klaviyo as a nested `address` property rather than as `address_city`. Add a second top-level key, or flatten it yourself before submitting, if you need flat properties.
{% endhint %}

For example, this object:

```json
{
  "facebook": "Share on Facebook",
  "twitter": "Tweet on Twitter",
  "pinterest": "Pin on Pinterest",
  "timestamp": `${Date.now()}`
}
```

Or this:

```json
{
  "Serial Number": "000123456789",
  "Country of Residence": "Canada",
  "Where did you purchase your meter?": "Amazon"
}
```

Becomes as follows in Klaviyo:

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FL4O0FhWZwDMfcNUdBNMk%2FKarim%20Tarek%20%7C%20Klaviyo%202023-04-15%2008-43-34.png?alt=media&#x26;token=a19105b5-9482-478a-9fcd-06bd4fdd3113" alt="Sync Shopify JSON metafield into Klaviyo customer profile"><figcaption><p>Sync Shopify JSON metafield into Klaviyo customer profile</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FolspvS7tBj76Y0HUmwpK%2FKarim%20Tarek%20%7C%20Klaviyo%202023-04-15%2009-01-15.png?alt=media&#x26;token=7ce321fe-5164-4d42-abd6-226e139cf8fb" alt=""><figcaption></figcaption></figure>

### Nested JSON metafield

TBC

```json
{
  "name": "Ram",
  "age": 27,
  "vehicles": {
    "car": "limousine",
    "bike": "ktm-duke",
    "plane": "lufthansa"
  }
}
```

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FYStxqHOr4nW2JQ8wXXyz%2FKarim%20Tarek%20%7C%20Klaviyo%202023-04-15%2009-35-47.png?alt=media&#x26;token=0bbbad1f-294d-459b-93fd-034469c47689" alt="Syncing nested JSON metafield with Klaviyo"><figcaption><p>Syncing nested JSON metafield with Klaviyo</p></figcaption></figure>

```json
{
  "name": "Ram",
  "age": 27,
  "vehicles": ["limousine", "ktm-duke", "lufthansa"]
}
```

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FJ5B2Neu9kc8PSj1iUla0%2FKarim%20Tarek%20%7C%20Klaviyo%202023-04-15%2009-48-57.png?alt=media&#x26;token=834c2703-4336-4e29-a071-e6286ce31bb5" alt="Syncing nested JSON with nested array with Klaviyo"><figcaption><p>Syncing nested JSON with nested array with Klaviyo</p></figcaption></figure>

### Array of JSON metafields

```json
[
  {
    "name": "Martha",
    "age": 37,
    "other": {
      "Favorite color": "Blue",
      "Vehicle": "2009 SSC Aero",
      "GUID": "890d954a-4a57-4659-b10d-e16427358b99"
    }
  },
  {
    "name": "Bonnie",
    "age": 29,
    "other": {
      "Favorite color": "Orange",
      "Vehicle": "1998 Daewoo Prince",
      "GUID": "b902c384-2e1c-44de-a8d3-59b92a2afddd"
    }
  },
  {
    "name": "Bridgette",
    "age": 41,
    "other": {
      "Favorite color": "Purple",
      "Vehicle": "1997 Suzuki Cappuccino",
      "GUID": "aad8ceb9-566e-4b9f-a64a-23af8671eb96"
    }
  }
]
```

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FGXYCVzkWiGvRX1YgySA2%2FKarim%20Tarek%20%7C%20Klaviyo%202023-04-15%2010-27-06.png?alt=media&#x26;token=ece9d6b4-7f61-490a-8b5a-6b9286c90b8f" alt="Syncing an array of objects with Klaviyo"><figcaption><p>Syncing an array of objects with Klaviyo</p></figcaption></figure>


# Airtable

{% hint style="info" %}
Airtable sync is only available on a raven with the **customer** resource and the **json** value type.
{% endhint %}

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FUtS8JfZhZYLVnk8eEejB%2FFieldsRaven%20Stage%20%C2%B7%20FieldsRaven%20%5BSTAGE%5D%20%C2%B7%20Shopify%202023-05-13%2010-30-42.png?alt=media&#x26;token=96ba2b05-37fc-48c0-92af-327d8890c91f" alt=""><figcaption><p>FieldRAven: Airtable sync is only available on a raven with the **customer** resource and the **json** value type.</p></figcaption></figure>

### 1. If you don't already have an account, create an account and then:

1. Create an empty base
2. Rename first table column/field to match your incoming data first key
3. Delete the fields/columns that Airtable created by default and create your own fields/columns
4. Make sure the type of the field/column matches the types of your incoming data values

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FLdbq7W4s2SIcOQMcJB7N%2FAirtable%202023-05-13%2009-40-52.png?alt=media&#x26;token=57826346-e1c5-4350-afe8-a3495d29573e" alt="FieldsRaven: Create an empty Airtable base"><figcaption><p>1.Create an empty base</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FLC3JgXIWKnBgl3ZVF1F8%2FFieldsRaven%20Stage%3A%20Table%201%20-%20Airtable%202023-05-13%2009-42-57.png?alt=media&#x26;token=bff5be48-570f-4121-93b8-b140f7492697" alt=""><figcaption><p>2.rename first column to match your incoming data first key</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FyakPe5dYohXZUEEr8aYs%2FFieldsRaven%20Stage%3A%20Table%201%20-%20Airtable%202023-05-13%2009-46-03.png?alt=media&#x26;token=cc36f7dc-c4f5-46a5-a25e-fc5ea88616ab" alt=""><figcaption><p>3.Delete the fields/columns that Airtable created by default and create your own fields/columns</p></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FfaQyXLZRKHCnGIWenxOy%2F%5BStage%5D%20FieldsRaven%20Products%20Registrations%3A%20Table%201%20-%20Airtable%202023-05-13%2010-20-48.png?alt=media&#x26;token=c6effdfa-f073-404f-8963-564973ced4b5" alt=""><figcaption><p>Final table headers</p></figcaption></figure>

### Create personal access token, make sure that the scope includes `data.records` read/write and `schema.bases` read/write

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FqQwYG2lX8PVrrk0mGsG1%2FAirtable%20Developers%202023-04-16%2009-07-54.png?alt=media&#x26;token=2d8129fc-2637-4ef1-9a56-6d6f4716cfac" alt="Airtable personal access token settings"><figcaption><p>Airtable personal access token settings</p></figcaption></figure>

{% hint style="warning" %}
Make sure the PAT you are creating has access to the base/app you want FieldsRaven to sync with
{% endhint %}

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FW8SfICeAKPHBInDvJ6vN%2FAirtable%20Developers%202023-05-13%2010-41-07.png?alt=media&#x26;token=4ed59585-49ec-46d8-8d42-5d0dcb27a1f1" alt=""><figcaption></figcaption></figure>

## Save the Airtable credential

1. Open **Settings** in FieldsRaven.
2. Under **Integration credentials**, enter the Airtable personal access token.
3. Select **Update integration credentials**.

FieldsRaven never displays a stored integration credential. Leave the field blank on a later visit to preserve the current token; enter a nonblank value only when you want to replace it.

{% hint style="warning" %}
An older Raven that already stores its own Airtable token continues to use that token. The legacy Raven credential overrides the credential in Settings, and replacing the Settings credential does not rotate the legacy value.
{% endhint %}

## Raven setup

Enable Airtable sync on the customer JSON Raven and leave its token field blank to use the shop credential from Settings. The Airtable app ID, table ID, and header mapping remain Raven-specific.

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2Fe3Ao9f9CPbEbRtOXpuJt%2FFieldsRaven%20Stage%20%C2%B7%20FieldsRaven%20%5BSTAGE%5D%20%C2%B7%20Shopify%202023-05-13%2010-22-02.png?alt=media&#x26;token=30a7b544-2490-4536-a905-0c61a11e0ace" alt=""><figcaption><p>Raven: Airtable setup</p></figcaption></figure>

### Grab Airtable app ID and table ID from the url

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FZGSRMFXEe4LhHavZuySw%2F%5BStage%5D%20FieldsRaven%20Products%20Registrations%3A%20Table%201%20-%20Airtable%202023-05-13%2009-52-48.png?alt=media&#x26;token=5a62f255-af70-4f89-aefa-0a36b71c3e87" alt=""><figcaption></figcaption></figure>

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FKLNPyeRUPCwvwd6PoZFw%2FFieldsRaven%20Stage%20%C2%B7%20FieldsRaven%20%5BSTAGE%5D%20%C2%B7%20Shopify%202023-05-13%2010-45-32.png?alt=media&#x26;token=4d892a71-2e4d-4c16-a75e-e6aa518000d7" alt=""><figcaption></figcaption></figure>

### Airtable table header fields

Add your Airtable header fields into the raven, separate each field by a comma

{% hint style="danger" %}
**The submitted JSON's keys must match the raven's header fields exactly** — every key, no extras, none missing. The comparison lowercases and trims both sides and ignores order, so `First Name` matches `first name`, but not `first_name`. One extra or missing key means **the record is never sent to Airtable**. Nothing surfaces on the storefront: the metafield saves normally and the Airtable row simply never appears.

This is a common reason an Airtable sync "does nothing". Add a field to your form, add it to the raven's header fields too.
{% endhint %}

## Validation

FieldsRaven will validate all Airtable settings before creating the Raven, if any of the settings is invalid, you'll get an error message

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FVGw0ibs8yZbSn9yshFqv%2FFieldsRaven%20Stage%20%C2%B7%20FieldsRaven%20%5BSTAGE%5D%20%C2%B7%20Shopify%202023-05-13%2010-26-33.png?alt=media&#x26;token=654bd33e-93c6-42e0-9510-782869a8ec24" alt=""><figcaption></figcaption></figure>

## Airtable automations

Syncing your metafields with Airtable gives you access to all of the automations – depending on your Airtable plan – that Airtable has to offer, it's pretty powerful.

<figure><img src="https://1211303336-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FNP07jPPCyBlsAnUAqYNM%2Fuploads%2FjzevzXPC82v2NYLaJxE8%2F%5BStage%5D%20FieldsRaven%20Products%20Registrations%3A%20Table%201%20-%20Airtable%202023-05-13%2010-51-43.png?alt=media&#x26;token=bd73065f-2475-42bd-b64f-fde7d1f4c6bc" alt=""><figcaption></figcaption></figure>


