# 🏷️ Metafields & Tags

div
h3
Shopify Metafields & Tags Reference
p
Appstle Memberships uses Shopify metafields and tags to store membership data, control access via customer tags, and enable storefront gating logic. This reference covers every metafield and tag — what it contains, when it's set, and how to use it in your integration.
## 📦 Metafields Overview

All metafields use the namespace **`appstle_membership`** and are managed through the Shopify GraphQL Admin API (`MetafieldsSetMutation`).

Metafields are set on three Shopify resource types:

| Resource | Count | Visibility | Description |
|  --- | --- | --- | --- |
| **Shop** | 6 keys | Public (readable by any app/theme) | Membership settings, selling plans, access rules, checkout validation, widget labels |
| **Customer** | 2 keys | Public | Membership contracts, trial/dunning state |
| **Order** | 1 key | Public | Subscription context for each order |


> **Namespace visibility:** All membership metafields use the `appstle_membership` namespace (no `$app:` prefix), which means they are **readable by other apps, themes, and Liquid templates**.


## 🏪 Shop Metafields

Shop metafields store the membership program configuration and are used by the storefront for access gating, checkout validation, and widget rendering.

**When updated:** On every settings save in the Appstle admin. Updates are **synchronous** (immediate).

### Core Settings

#### `appstle_membership` / `setting`

| Property | Value |
|  --- | --- |
| **Type** | `json` |
| **Resource** | Shop |
| **Purpose** | Shop-level membership settings snapshot for storefront access |
| **Set by** | `SubscribeItScriptUtils.updateShopMetafieldsForSettings()` |


#### `appstle_membership` / `all_selling_plans`

| Property | Value |
|  --- | --- |
| **Type** | `json` |
| **Resource** | Shop |
| **Purpose** | All selling plans configured for this shop's membership program |
| **Set by** | `SubscribeItScriptUtils.updateShopMetafieldsForSettings()` |


```json
[
  {
    "id": "gid://shopify/SellingPlan/111",
    "name": "Basic Monthly Membership",
    "billingPolicy": {
      "interval": "MONTH",
      "intervalCount": 1
    },
    "customerTag": "basic-member",
    "orderTag": "membership-order"
  },
  {
    "id": "gid://shopify/SellingPlan/222",
    "name": "Premium Annual Membership",
    "billingPolicy": {
      "interval": "YEAR",
      "intervalCount": 1
    },
    "customerTag": "premium-member",
    "orderTag": "premium-membership-order"
  }
]
```

### Access Control

#### `appstle_membership` / `rules_by_customer_tag`

| Property | Value |
|  --- | --- |
| **Type** | `json` |
| **Resource** | Shop |
| **Purpose** | Rules keyed by customer tag — used for storefront gating logic (controlling which products, collections, or pages are accessible to which membership tiers) |
| **Set by** | `SubscribeItScriptUtils.updateShopMetafieldsForSettings()` |


```json
{
  "basic-member": {
    "accessibleCollections": ["gid://shopify/Collection/111"],
    "accessibleProducts": [],
    "gatingType": "COLLECTION"
  },
  "premium-member": {
    "accessibleCollections": ["gid://shopify/Collection/111", "gid://shopify/Collection/222"],
    "accessibleProducts": ["gid://shopify/Product/333"],
    "gatingType": "COLLECTION_AND_PRODUCT"
  }
}
```

> **How gating works:** The storefront reads this metafield and the customer's tags to determine what content is visible. If a customer has the `premium-member` tag, they see all products and collections mapped to that tag. Non-members or lower tiers see gated content as locked/hidden.


#### `appstle_membership` / `checkout_validation`

| Property | Value |
|  --- | --- |
| **Type** | `json` |
| **Resource** | Shop |
| **Purpose** | Checkout validation configuration — enforces member-only product purchase rules at checkout |
| **Set by** | `SubscribeItScriptUtils.updateShopMetafieldsForSettings()` |


### Widget Labels

#### `appstle_membership` / `widget_label`

| Property | Value |
|  --- | --- |
| **Type** | `json` |
| **Resource** | Shop |
| **Purpose** | Storefront widget label translations (default locale) |
| **Set by** | `LabelTranslationsServiceImpl.syncTranslationToShopify()` |


#### `appstle_membership` / `customer_portal_label`

| Property | Value |
|  --- | --- |
| **Type** | `json` |
| **Resource** | Shop |
| **Purpose** | Customer portal label translations (default locale) |
| **Set by** | `LabelTranslationsServiceImpl.syncTranslationToShopify()` |


> **Multi-language support:** For non-default locales, labels are NOT stored as separate metafields. Instead, the app fetches the existing default-locale metafield ID and registers a **Shopify Translation** on it (using `TranslationsRegisterMutation`). This leverages Shopify's built-in translation system.


## 📦 Order Metafields

#### `appstle_membership` / `details`

| Property | Value |
|  --- | --- |
| **Type** | `json` |
| **Resource** | Order |
| **Purpose** | Full membership contract context for this order |
| **Set by** | `MiscellaneousResource.updateOrderMetafields()` (membership) and `AbstractSubscriptionService.updateOrderMetafieldsFromQueue()` (membership-async) |
| **Updated when** | Order is created via membership (initial purchase or recurring billing) |
| **Update mechanism** | Queued via SQS (`membership-update-order-metafields.fifo`), processed asynchronously |


This metafield contains a complete snapshot of the membership context at the time the order was created:

```json
{
  "customer": {
    "id": "gid://shopify/Customer/1234567890",
    "name": "Jane Smith",
    "email": "jane@example.com"
  },
  "subscriptionContract": {
    "id": "gid://shopify/SubscriptionContract/9876543210",
    "status": "ACTIVE",
    "sellingPlanIds": ["gid://shopify/SellingPlan/111"],
    "sellingPlanNames": ["Premium Monthly Membership"],
    "variantIds": ["gid://shopify/ProductVariant/222"],
    "variantNames": ["Premium Membership"]
  },
  "firstOrder": {
    "id": "gid://shopify/Order/444",
    "createdAt": "2025-01-15T10:30:00Z"
  }
}
```

## 👤 Customer Metafields

### Membership Contracts

#### `appstle_membership` / `subscriptions`

| Property | Value |
|  --- | --- |
| **Type** | `json` |
| **Resource** | Customer |
| **Purpose** | All membership contracts for this customer with full details |
| **Set by** | `SubscriptionContractDetailsServiceImpl.maybeUpdateCustomerMetaFields()` (membership) and `AbstractSubscriptionService.maybeUpdateCustomerMetaFieldsFromQueue()` (membership-async) |
| **Updated when** | Any membership contract changes (created, updated, paused, cancelled, billing attempt) |
| **Update mechanism** | Queued via SQS (`membership-update-customer-metafields.fifo`), processed asynchronously |


```json
[
  {
    "id": "gid://shopify/SubscriptionContract/9876543210",
    "status": "ACTIVE",
    "sellingPlanIds": ["gid://shopify/SellingPlan/111"],
    "sellingPlanNames": ["Premium Monthly Membership"],
    "variantIds": ["gid://shopify/ProductVariant/222"],
    "variantNames": ["Premium Membership"],
    "nextBillingDate": "2025-04-15T10:30:00Z"
  }
]
```

### Trial & Dunning State

#### `appstle_membership` / `setting`

| Property | Value |
|  --- | --- |
| **Type** | `json` |
| **Resource** | Customer |
| **Purpose** | Customer-level membership settings: tracks trial tags and dunning tags for this customer's active plans |
| **Set by** | Same as `subscriptions` metafield (updated together) |
| **Updated when** | Membership state changes (trial start/end, billing failure/success) |


```json
{
  "trialTags": "basic-member,premium-member",
  "dunningTags": "premium-member"
}
```

| Field | Purpose |
|  --- | --- |
| `trialTags` | Comma-separated list of plan customer tags where the customer is currently in a free trial period (no successful billing yet, within trial window) |
| `dunningTags` | Comma-separated list of plan customer tags where the customer has a recent failed billing attempt requiring retry |


> **Note:** The same key `setting` is used for both Shop-level and Customer-level metafields, but they contain different data structures. The resource type (Shop vs Customer) distinguishes them.


## 🏷️ Tags Overview

Appstle Memberships applies tags to both **Customer** and **Order** resources. Customer tags are the primary mechanism for **membership access control** — they determine which content, products, and collections a member can access.

All tags are applied using the Shopify GraphQL Admin API (`TagsAddMutation` / `TagsRemoveMutation`).

## 👤 Customer Tags

### Plan-Based Customer Tags (Primary Access Control)

This is the core tag mechanism for Appstle Memberships. Each selling plan (membership tier) has a **merchant-configured `customerTag`** that controls access.

| Property | Value |
|  --- | --- |
| **Resource** | Customer |
| **Format** | Free-form string configured per plan (e.g., `basic-member`, `premium-member`, `vip-member`) |
| **Configuration** | Set per selling plan in the Appstle admin under each membership plan's settings |
| **Source field** | `SubscriptionGroupPlan.customerTag` |


#### Tag Lifecycle

| Event | Tag Action |
|  --- | --- |
| Membership contract **ACTIVE** | Plan's `customerTag` is **added** to the customer |
| Membership contract **CANCELLED** | Plan's `customerTag` is **removed** (immediately if `immediateTagRemoveOnCancel=true`, otherwise at `nextBillingDate`) |
| Membership contract **PAUSED** | Plan's `customerTag` is **removed** (immediately if `immediateTagRemoveOnPause=true`, otherwise at `nextBillingDate`) |
| Membership contract in **DUNNING** (failed payment) | Plan's `customerTag` is **removed** |
| Membership contract **RESUMED** | Plan's `customerTag` is **re-added** |


#### Delayed vs Immediate Tag Removal

Merchants can configure when tags are removed on cancellation or pause:

| Setting | Default | Behavior |
|  --- | --- | --- |
| `immediateTagRemoveOnCancel` | `false` | If `false`, tag stays until `nextBillingDate` (member keeps access until their paid period ends). If `true`, tag is removed immediately. |
| `immediateTagRemoveOnPause` | `false` | Same behavior for paused memberships. |


> **Best practice:** Keep the defaults (`false`) to ensure members retain access for the period they've already paid for. Only set to `true` if your membership grants access to ongoing services (not prepaid periods).


#### Cross-Membership Protection

When removing a customer tag on cancel/pause, the app checks **all other active memberships** for the same customer. If another active contract uses the same `customerTag`, the tag is **not removed**.

**Method:** `getCustomerTagsForOtherActiveMembership()` — ensures a customer who holds the same membership tag from two different contracts doesn't lose access when one is cancelled.

#### Example Scenario

A customer has two contracts:

1. "Basic Monthly" → `customerTag: basic-member` (ACTIVE)
2. "Basic Annual" → `customerTag: basic-member` (ACTIVE)


If the customer cancels "Basic Monthly", the `basic-member` tag is **not removed** because "Basic Annual" is still active.

### Trial-State Tags

| Property | Value |
|  --- | --- |
| **Resource** | Customer |
| **Format** | Same `customerTag` as the plan — no separate trial tag |
| **Active during** | Free trial period (no successful billing yet, within `freeTrialCount` + `freeTrialInterval` window) |


During a free trial:

- The plan's `customerTag` **is applied** (member gets access during trial)
- The tag is tracked in the customer's `setting` metafield under `trialTags`
- When the trial ends (expires without payment), the tag is **removed**


> **Key point:** Trial members get the **same tag** as paying members. There is no separate "trial" tag — access is identical. The `trialTags` field in the customer metafield is for internal tracking only.


### Dunning-State Tags

| Property | Value |
|  --- | --- |
| **Resource** | Customer |
| **Format** | Same `customerTag` as the plan |
| **Active during** | Period when a billing attempt has failed and retries are pending |


When a billing attempt fails:

- The plan's `customerTag` may be **removed** (member loses access during dunning)
- The tag is tracked in the customer's `setting` metafield under `dunningTags`
- If payment succeeds on retry, the tag is **re-added**


### Plan Upgrade/Downgrade Tag Swap

When a member changes plans (variant swap), tags are swapped in a single operation:

| Step | Action |
|  --- | --- |
| 1 | Old plan's `customerTag` is **removed** |
| 2 | New plan's `customerTag` is **added** |
| 3 | If the upgrade requires payment and that payment fails, **rollback**: new tag removed, old tag restored |


**Rollback method:** `SubscriptionBillingAttemptService.rollbackCustomerTags()` (membership-async)

## 📦 Order Tags

### Plan-Based Order Tags

| Property | Value |
|  --- | --- |
| **Resource** | Order |
| **Format** | Free-form string configured per plan |
| **Configuration** | Set per selling plan in the Appstle admin |
| **Source field** | `SubscriptionGroupPlan.orderTag` |
| **Applied when** | On initial membership purchase and on each renewal billing (unless `skipRecurringOrderTag` is enabled) |
| **Removed** | Never (order tags are permanent) |


### First-Time Order Tag (Liquid Template)

| Property | Value |
|  --- | --- |
| **Resource** | Order |
| **Config field** | `ShopInfo.firstTimeOrderTag` |
| **Format** | Liquid template string |
| **Applied when** | Initial membership contract creation only (not on renewals) |
| **Condition** | Only applied if `firstTimeOrderTag` is non-empty AND the event is `SUBSCRIPTION_CONTRACT_CREATE` |
| **Removed** | Never |


### Recurring Order Tag (Liquid Template)

| Property | Value |
|  --- | --- |
| **Resource** | Order |
| **Config field** | `ShopInfo.recurringOrderTag` |
| **Format** | Liquid template string |
| **Applied when** | Each recurring billing attempt that creates an order |
| **Removed** | Never |


### Liquid Template Variables for Order Tags

Both `firstTimeOrderTag` and `recurringOrderTag` support Liquid template syntax using the `TagModel`:

| Variable | Type | Description | Example |
|  --- | --- | --- | --- |
| `{{customer.id}}` | String | Shopify customer GID | `gid://shopify/Customer/1234567890` |
| `{{subscriptionContract.id}}` | String | Subscription contract GID | `gid://shopify/SubscriptionContract/9876` |
| `{{firstOrder.id}}` | String | First order GID | `gid://shopify/Order/444` |
| `{{firstOrder.createdAt}}` | String | First order creation date (ISO 8601) | `2025-01-15T10:30:00Z` |


#### Example Liquid Templates

**Static tag:**

```
membership_first_order
```

**Dynamic tag with contract info:**

```
membership_{{subscriptionContract.id}}
```

Result: `membership_gid://shopify/SubscriptionContract/9876`

### Variant Swap Order Tags

| Property | Value |
|  --- | --- |
| **Resource** | Order |
| **Applied when** | A plan upgrade/downgrade (variant swap) is processed |
| **Tag value** | New plan's `orderTag` value |
| **Set by** | `VariantSwapPostProcessService` (membership-async) and `SubscriptionContractDetailsServiceImpl` |


## 🔗 Order Note Attributes

When `transferOrderNoteAttributesToSubscription` is enabled (default: `true`), the app transfers the original order's `customAttributes` (note attributes) to each renewal order. These are not tags but Shopify's native key-value pairs on orders.

| Setting | Default | Description |
|  --- | --- | --- |
| `transferOrderNoteAttributesToSubscription` | `true` | Transfer original order's custom attributes to renewal orders |


## 📊 Summary Tables

### All Metafields at a Glance

| Resource | Namespace | Key | Type | Visibility |
|  --- | --- | --- | --- | --- |
| Shop | `appstle_membership` | `setting` | json | Public |
| Shop | `appstle_membership` | `all_selling_plans` | json | Public |
| Shop | `appstle_membership` | `rules_by_customer_tag` | json | Public |
| Shop | `appstle_membership` | `checkout_validation` | json | Public |
| Shop | `appstle_membership` | `widget_label` | json | Public |
| Shop | `appstle_membership` | `customer_portal_label` | json | Public |
| Customer | `appstle_membership` | `subscriptions` | json | Public |
| Customer | `appstle_membership` | `setting` | json | Public |
| Order | `appstle_membership` | `details` | json | Public |


### All Tags at a Glance

| Resource | Tag Source | When Applied | When Removed | Permanent? |
|  --- | --- | --- | --- | --- |
| Customer | `SubscriptionGroupPlan.customerTag` | Contract ACTIVE or TRIAL | Contract CANCELLED, PAUSED, DUNNING, or EXPIRED | No |
| Customer | (same tag on upgrade) | Plan upgrade | Plan downgrade rollback | No |
| Order | `SubscriptionGroupPlan.orderTag` | Order created (first or renewal) | Never | Yes |
| Order | `ShopInfo.firstTimeOrderTag` (Liquid) | First order only | Never | Yes |
| Order | `ShopInfo.recurringOrderTag` (Liquid) | Each renewal | Never | Yes |
| Order | New plan's `orderTag` | Variant swap | Never | Yes |


### GraphQL Mutations Used

| Mutation | Purpose |
|  --- | --- |
| `MetafieldsSetMutation` | Create or update metafields on any resource |
| `TagsAddMutation` | Add tags to orders or customers |
| `TagsRemoveMutation` | Remove tags from customers |
| `TranslationsRegisterMutation` | Register translations for label metafields |


## ❓ FAQ

details
summary
strong
Can I read membership metafields from my Liquid theme?
Yes. All membership metafields use the `appstle_membership` namespace (no `$app:` prefix), so they are accessible in Liquid templates via `{{ shop.metafields.appstle_membership.setting }}`, `{{ customer.metafields.appstle_membership.subscriptions }}`, etc.

details
summary
strong
How does membership access gating work with tags?
Each membership plan has a `customerTag` (e.g., `premium-member`). When a customer's contract is active, this tag is added to their Shopify customer profile. The `rules_by_customer_tag` shop metafield maps each tag to the collections/products that tag grants access to. Your theme reads these metafields and the customer's tags to show or hide gated content.

details
summary
strong
What happens when a member cancels mid-billing-cycle?
By default (`immediateTagRemoveOnCancel=false`), the customer keeps their membership tag until `nextBillingDate`. This means they retain access for the period they've already paid for. Set `immediateTagRemoveOnCancel=true` to remove access immediately on cancellation.

details
summary
strong
What if a customer has the same tag from two different memberships?
The app has cross-membership protection. When removing a tag due to cancellation/pause, it checks if any other active contract for this customer uses the same tag. If so, the tag is **not removed**. The customer retains access.

details
summary
strong
How are customer metafields updated — in real-time or batched?
Customer metafield updates are queued via SQS (`membership-update-customer-metafields.fifo`) and processed asynchronously by membership-async. Typical latency is a few seconds, but during high-traffic periods it may take longer.

details
summary
strong
Do trial members get the same tag as paying members?
Yes. During a free trial, the plan's `customerTag` is applied — there is no separate trial tag. Trial members get the same access as paying members. The `trialTags` field in the customer `setting` metafield tracks which tags are in trial for internal use only.

details
summary
strong
What happens during dunning (failed payment)?
When a billing attempt fails, the plan's `customerTag` may be removed (member loses access). The `dunningTags` field in the customer `setting` metafield tracks which tags are in dunning. If a retry succeeds, the tag is re-added and access is restored.