> ## Documentation Index
> Fetch the complete documentation index at: https://help.the-meridian.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom field values from your code

A custom field is your own attribute on every store in your CRM: a customer score, an onboarding stage, an account owner. You define the field once in the Meridian dashboard, then set its value per store, either by hand on the store page or by pushing it from your own backend.

This page covers both directions:

* **Writing**, with `POST /api/public/custom-fields`. Server-side only: it takes your secret API key.
* **Reading**, with `GET /api/public/me`. Available anywhere your app holds a shop token, including the browser.

[Define the fields](/custom-fields) first. The write endpoint sets values against definitions that already exist, and a handle Meridian does not recognize fails the whole request.

## Authentication

The endpoint is authenticated by your app's secret API key, sent as a bearer token:

```
Authorization: Bearer mrd_sk_...
```

Create the key in the Meridian dashboard. It is shown once and never returned again. The key is a server-side credential: it can write your app's CRM data, so it must never reach a browser, an embedded app bundle, or a Shopify theme.

This is the same credential the SDK uses for `identify`, and it is unrelated to the shop token your embedded app holds. A shop token cannot write custom fields.

## Request

```bash theme={null}
curl -X POST https://api.the-meridian.ai/api/public/custom-fields \
  -H "Authorization: Bearer $MERIDIAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "appId": "6c1a8f2e-6f5a-4d0e-9a3e-1f2b3c4d5e6f",
    "shopDomain": "acme.myshopify.com",
    "values": {
      "customer_score": 87.5,
      "onboarding_stage": "activated",
      "is_vip": true,
      "churn_risk": null
    }
  }'
```

| Field | Type | Notes |
| :- | :- | :- |
| `appId` | string | Your app's Meridian ID. Cross-checked against the API key; a mismatch is a 401. |
| `shopDomain` | string | The store's myshopify domain. Case-insensitive, stored lowercased. |
| `values` | object | Values keyed by field handle. At most 20 keys per request. |

The `values` keys are field **handles**, not names. A handle is set when the field is created and never changes afterwards, so renaming a field in the dashboard does not break your integration.

Only the handles you send are touched. Every other field on that store keeps its value.

The 20-key cap matches the 20 custom fields an app can define, so a single request can always cover every field you have.

## Response

```json theme={null}
{
  "success": true,
  "data": {
    "shopDomain": "acme.myshopify.com",
    "values": {
      "customer_score": 87.5,
      "onboarding_stage": "activated",
      "is_vip": true
    }
  },
  "meta": { "written": 3, "cleared": 1 },
  "error": null
}
```

`data.values` is the store's **full** set of values after the write, not just the handles you sent, so you can confirm what landed without a second request. Fields the store has no value for are omitted.

`meta.written` counts the values set, `meta.cleared` counts the values removed.

## How each type coerces a value

| Type | Accepts | Rules |
| :- | :- | :- |
| `text` | string, number | Trimmed. Maximum 1,000 characters. |
| `number` | number, numeric string | Rounded to 6 decimal places. At most 14 digits before the decimal point, rejected at 15 or more. |
| `boolean` | `true`, `false`, `1`, `0`, and the strings `"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"no"`, `"on"`, `"off"` | Anything else is rejected. |
| `select` | string | Must match one of the field's options exactly, after trimming. |
| `date` | string | ISO `YYYY-MM-DD` only. No time, no timezone. A day that does not exist, such as `2026-02-30`, is rejected rather than rolled forward. |
| `json` | object, array, or a JSON string | Stored serialized. Maximum 8,000 characters once serialized. |

Extra precision on a `number` is rounded rather than rejected, because a score computed as `3.14159265` should not fail on its seventh decimal. Magnitude is different: a number too large for the column cannot be stored without changing it, so it comes back as a 422.

A `date` is a calendar day, not an instant. Send the day you mean, already resolved in whatever timezone your business uses. Meridian stores and returns exactly that string.

A `json` field cannot be filtered in segments or reports. A structure has no single comparable value.

## Clearing a value

Send `null`, or an empty string, to remove a value:

```json theme={null}
{ "values": { "churn_risk": null, "account_owner": "" } }
```

Clearing deletes the stored row rather than blanking it, so "no value" has exactly one representation. A cleared field reads back as absent from `data.values` and shows as empty on the store page.

One consequence is worth stating plainly: **an empty string is never stored as a text value.** A blank `text` field and an unset one are the same thing. If you need to distinguish "known to be empty" from "unknown", model it with a `select` field carrying an explicit option.

## Stores Meridian has not seen yet

Your CRM store list is rebuilt from Shopify Partner events, which Meridian mirrors hourly. A store that installed your app minutes ago is not in that list yet.

The endpoint accepts writes for any valid myshopify domain anyway, including one it has never seen. This is deliberate: an install hook that pushes an acquisition source or a signup cohort would otherwise be rejected exactly when it has the most useful thing to say. The value waits, and the store page shows it as soon as the mirror catches up.

## Errors

Every error uses the standard envelope with a string code.

**401, missing or invalid key**

```json theme={null}
{
  "success": false,
  "data": null,
  "meta": {},
  "error": { "code": "UNAUTHORIZED", "message": "Invalid API key.", "details": {} }
}
```

**401, valid key for a different app**

```json theme={null}
{
  "success": false,
  "data": null,
  "meta": {},
  "error": { "code": "INVALID_API_KEY", "message": "Invalid API key for the provided appId", "details": {} }
}
```

**422, a value that does not fit its field**

```json theme={null}
{
  "success": false,
  "data": null,
  "meta": {},
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": {
      "fields": {
        "values.customer_score": ["Value must be a number."],
        "values.onboarding_stage": ["Value must be one of: trial, activated, churned."]
      }
    }
  }
}
```

Errors are reported per handle, under `details.fields`, keyed `values.<handle>`. Every bad value in the payload is reported at once, so a batch import learns about all of its problems in one round trip.

**Nothing is partially applied.** One rejected value fails the whole request and no value in it is written. Retry with the payload corrected.

An unknown handle is reported under `details.fields` against `values` rather than against a handle, because there is no field to attribute it to:

```json theme={null}
{ "fields": { "values": ["Unknown custom field handle(s) for this app: custmer_score."] } }
```

**429** means you hit the public API rate limit. Back off and retry.

## Using the SDK instead

If your backend already uses `@the-meridian/sdk`, its server entry wraps this endpoint:

```ts theme={null}
import { createMeridianAdminClient } from "@the-meridian/sdk/server"

const meridian = createMeridianAdminClient({
  appId: process.env.MERIDIAN_APP_ID!,
  apiKey: process.env.MERIDIAN_API_KEY!,
})

const { values } = await meridian.setCustomFields({
  shopDomain: "acme.myshopify.com",
  values: { customer_score: 87.5, churn_risk: null },
})
```

`createMeridianAdminClient` is exported from `@the-meridian/sdk/server` only. It takes your secret API key, so it must never be imported from the React entry or from any code that ships to the browser.

The SDK also **de-duplicates** for you, which the raw endpoint does not: writing the same values for the same store twice within 30 seconds sends one request, and identical concurrent writes are coalesced into one. Only exact duplicates are skipped; different value maps for the same store are always sent, in order. A skipped write still resolves, with the value map its twin got back, so that map can be up to 30 seconds stale if a field was edited from the dashboard in between. Read current values from `/me` when that matters.

De-duplication is per client instance, so build the client once at module scope rather than per request, the same rule as [`createMeridianApp`](/sdk-app).

## Reading values back

Your app reads a store's values from `GET /api/public/me`, the same call the SDK provider already makes on mount. They arrive under `customer.customFields`:

```json theme={null}
{
  "success": true,
  "data": {
    "customer": {
      "shopDomain": "acme.myshopify.com",
      "appId": "6c1a8f2e-6f5a-4d0e-9a3e-1f2b3c4d5e6f",
      "name": "Acme",
      "email": "owner@acme.example",
      "customFields": {
        "customer_score": 87.5,
        "onboarding_stage": "activated",
        "is_vip": true
      }
    },
    "entitlements": { "...": "..." },
    "plans": []
  },
  "meta": {},
  "error": null
}
```

Reads are authenticated by the **shop token**, not by your API key. That is the whole point: the shop token is already in your embedded app, so a value your backend pushed last night is readable from the browser on the next render, with no second credential and no endpoint of your own.

The map holds only the fields this store has a value for. A field you never set, or one you cleared, is **absent** rather than `null`, the same rule as the write response. A store with no values at all reads `{}`, and so does an app with no custom fields defined.

Values keep their type: a `number` field reads as a number, a `boolean` as a boolean, a `json` field as the parsed structure, and `date` as its `YYYY-MM-DD` string.

### From React

`useMeridian()` exposes the customer, so no extra call is needed:

```tsx theme={null}
import { useMeridian } from "@the-meridian/sdk"

export const OnboardingBanner = () => {
  const { customer, loading } = useMeridian()
  if (loading) return null

  const stage = customer?.customFields["onboarding_stage"]
  if (stage === "activated") return null

  return <s-banner heading="Finish setting up">…</s-banner>
}
```

Values refresh with the rest of the provider state when you call `refresh()`.

### From your server

The server client reads the same payload:

```ts theme={null}
import { createMeridianServerClient } from "@the-meridian/sdk/server"

const meridian = createMeridianServerClient(shopToken)
const customer = await meridian.getCustomer()

if (customer.customFields["is_vip"] === true) {
  // …
}
```

Two things worth knowing:

* **Custom fields are readable by the merchant's browser.** Anything you push is visible to anyone who can open your embedded app on that store, so do not store a secret, an internal cost, or a note you would not want the merchant to read.
* **`identify` does not return them.** The handshake answers "who is this store", not "what does its CRM record say", so its `customer` object has no `customFields`. Read them from `/me`.

## What a write triggers

Setting a value fires the `custom_field.changed` automation trigger, once per field whose stored value actually changed. Writing the same value again changes nothing and fires nothing, so a nightly job that recomputes every score does not flood your automations.

Values are also filterable in segments for every type except `json`, which means a pushed score can drive an audience, an email, or an automation without any further wiring.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.