bankr-shopify

$npx mdskill add BankrBot/skills/bankr-shopify

Integrate Shopify stores with onchain primitives using GraphQL APIs and Bankr bridges

  • Manage Shopify products, orders, and customers via Admin and Storefront APIs
  • Uses Shopify GraphQL, curl, and Bankr-native bridges for metafield and x402 operations
  • Maps customer data to onchain handles and triggers agent jobs on ORDER_PAID events
  • Delivers results through metafields, draft orders, and loyalty drop automation
SKILL.md
.github/skills/bankr-shopifyView on GitHub ↗
---
name: bankr-shopify
description: >
  Shopify Admin & Storefront GraphQL APIs via curl, with Bankr-native bridges.
  Manage products, orders, customers, inventory, metafields, webhooks, and bulk
  ops, then wire merchant data to onchain primitives — store a Bankr-resolvable
  handle (ENS, Twitter, Farcaster, wallet) on each customer as a metafield,
  expose Shopify draft orders behind x402 endpoints Bankr settles in USDC, and
  turn ORDERS_PAID webhooks into Bankr agent jobs for loyalty drops or royalty
  splits. Triggers: "Shopify products", "Shopify orders", "create draft order",
  "loyalty drop on order", "x402 checkout", "tokengate Shopify". Core Shopify
  content adapted with attribution from NousResearch/hermes-agent (MIT).
emoji: 🛍️
tags: [shopify, ecommerce, commerce, graphql, x402, usdc, loyalty, base]
visibility: public
---

# Shopify — Admin & Storefront GraphQL APIs (Bankr Edition)

> The core Shopify sections of this skill are adapted from the upstream [`NousResearch/hermes-agent`](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/productivity/shopify/SKILL.md) skill (MIT, `author: community`). The four "Bankr Bridges" sections at the end are new and specific to the Bankr ecosystem.

Work with Shopify stores directly through `curl`: list products, manage inventory, pull orders, update customers, read metafields. No SDK, no app framework — just the GraphQL endpoint and a custom-app access token. Then bridge the merchant data to onchain primitives via Bankr.

The REST Admin API is legacy since 2024-04 and only receives security fixes. **Use GraphQL Admin** for all admin work. Use **Storefront GraphQL** for read-only customer-facing queries (products, collections, cart).

## Prerequisites

1. In Shopify admin: **Settings → Apps and sales channels → Develop apps → Create an app**.
2. Click **Configure Admin API scopes**, select what you need (examples below), save.
3. **Install app** → the Admin API access token appears ONCE. Copy it immediately — Shopify will never show it again. Tokens start with `shpat_`.
4. Set these in your Bankr settings (gear icon → Env Vars):
   - `SHOPIFY_ACCESS_TOKEN` — admin token (starts with `shpat_`)
   - `SHOPIFY_STORE_DOMAIN` — `my-store.myshopify.com` (the permanent myshopify domain, not your custom one)
   - `SHOPIFY_API_VERSION` — default `2026-01`
   - `BANKR_API_KEY` — for the Bankr bridge sections; generate at https://bankr.bot/api

> **Heads up:** As of January 1, 2026, new "legacy custom apps" created in the Shopify admin are gone. New setups should use the **Dev Dashboard** (`shopify.dev/docs/apps/build/dev-dashboard`). Existing admin-created apps keep working. If the user's shop has no existing custom app and it's after 2026-01-01, direct them to Dev Dashboard instead of the admin flow.

Common scopes by task:
- Products / collections: `read_products`, `write_products`
- Inventory: `read_inventory`, `write_inventory`, `read_locations`
- Orders: `read_orders`, `write_orders` (30 most recent without `read_all_orders`)
- Customers: `read_customers`, `write_customers`
- Draft orders: `read_draft_orders`, `write_draft_orders`
- Fulfillments: `read_fulfillments`, `write_fulfillments`
- Metafields / metaobjects: covered by the matching resource scopes

## API Basics

- **Endpoint:** `https://$SHOPIFY_STORE_DOMAIN/admin/api/$SHOPIFY_API_VERSION/graphql.json`
- **Auth header:** `X-Shopify-Access-Token: $SHOPIFY_ACCESS_TOKEN` (NOT `Authorization: Bearer`)
- **Method:** always `POST`, always `Content-Type: application/json`, body is `{"query": "...", "variables": {...}}`
- **HTTP 200 does not mean success.** GraphQL returns errors in a top-level `errors` array and per-field `userErrors`. Always check both.
- **IDs are GID strings:** `gid://shopify/Product/10079467700516`, `gid://shopify/Variant/...`, `gid://shopify/Order/...`. Pass these verbatim — don't strip the prefix.
- **Rate limit:** calculated via query cost (leaky bucket). Each response has `extensions.cost` with `requestedQueryCost`, `actualQueryCost`, `throttleStatus.{currentlyAvailable, maximumAvailable, restoreRate}`. Back off when `currentlyAvailable` drops below your next query's cost. Standard shops = 100 points bucket, 50/s restore; Plus = 1000/100.

Base curl pattern (reusable):

```bash
shop_gql() {
  local query="$1"
  local variables="${2:-{}}"
  curl -sS -X POST \
    "https://${SHOPIFY_STORE_DOMAIN}/admin/api/${SHOPIFY_API_VERSION:-2026-01}/graphql.json" \
    -H "Content-Type: application/json" \
    -H "X-Shopify-Access-Token: ${SHOPIFY_ACCESS_TOKEN}" \
    --data "$(jq -nc --arg q "$query" --argjson v "$variables" '{query: $q, variables: $v}')"
}
```

Pipe through `jq` for readable output. `-sS` keeps errors visible but hides the progress bar.

## Discovery

### Shop info + current API version
```bash
shop_gql '{ shop { name myshopifyDomain primaryDomain { url } currencyCode plan { displayName } } }' | jq
```

### List all supported API versions
```bash
shop_gql '{ publicApiVersions { handle supported } }' | jq '.data.publicApiVersions[] | select(.supported)'
```

## Products

### Search products (first 20 matching query)
```bash
shop_gql '
query($q: String!) {
  products(first: 20, query: $q) {
    edges { node { id title handle status totalInventory variants(first: 5) { edges { node { id sku price inventoryQuantity } } } } }
    pageInfo { hasNextPage endCursor }
  }
}' '{"q":"hoodie status:active"}' | jq
```

Query syntax supports `title:`, `sku:`, `vendor:`, `product_type:`, `status:active`, `tag:`, `created_at:>2025-01-01`. Full grammar: https://shopify.dev/docs/api/usage/search-syntax

### Paginate products (cursor)
```bash
shop_gql '
query($cursor: String) {
  products(first: 100, after: $cursor) {
    edges { cursor node { id handle } }
    pageInfo { hasNextPage endCursor }
  }
}' '{"cursor":null}'
# subsequent calls: pass the previous endCursor
```

### Get a product with variants + metafields
```bash
shop_gql '
query($id: ID!) {
  product(id: $id) {
    id title handle descriptionHtml tags status
    variants(first: 20) { edges { node { id sku price compareAtPrice inventoryQuantity selectedOptions { name value } } } }
    metafields(first: 20) { edges { node { namespace key type value } } }
  }
}' '{"id":"gid://shopify/Product/10079467700516"}' | jq
```

### Create a product with one variant
```bash
shop_gql '
mutation($input: ProductCreateInput!) {
  productCreate(product: $input) {
    product { id handle }
    userErrors { field message }
  }
}' '{"input":{"title":"Test Hoodie","status":"DRAFT","vendor":"Bankr","productType":"Apparel","tags":["test"]}}'
```

Variants now have their own mutations in recent versions:

```bash
# Add variants after creating the product
shop_gql '
mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkCreate(productId: $productId, variants: $variants) {
    productVariants { id sku price }
    userErrors { field message }
  }
}' '{"productId":"gid://shopify/Product/...","variants":[{"optionValues":[{"optionName":"Size","name":"M"}],"price":"49.00","inventoryItem":{"sku":"HD-M","tracked":true}}]}'
```

### Update price / SKU
```bash
shop_gql '
mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkUpdate(productId: $productId, variants: $variants) {
    productVariants { id sku price }
    userErrors { field message }
  }
}' '{"productId":"gid://shopify/Product/...","variants":[{"id":"gid://shopify/ProductVariant/...","price":"55.00"}]}'
```

## Orders

### List recent orders (last 30 by default without `read_all_orders`)
```bash
shop_gql '
{
  orders(first: 20, reverse: true, query: "financial_status:paid") {
    edges { node {
      id name createdAt displayFinancialStatus displayFulfillmentStatus
      totalPriceSet { shopMoney { amount currencyCode } }
      customer { id displayName email }
      lineItems(first: 10) { edges { node { title quantity sku } } }
    } }
  }
}' | jq
```

Useful order query filters: `financial_status:paid|pending|refunded`, `fulfillment_status:unfulfilled|fulfilled`, `created_at:>2025-01-01`, `tag:gift`, `email:foo@example.com`.

### Fetch a single order with shipping address
```bash
shop_gql '
query($id: ID!) {
  order(id: $id) {
    id name email
    shippingAddress { name address1 address2 city province country zip phone }
    lineItems(first: 50) { edges { node { title quantity variant { sku } originalUnitPriceSet { shopMoney { amount currencyCode } } } } }
    transactions { id kind status amountSet { shopMoney { amount currencyCode } } }
  }
}' '{"id":"gid://shopify/Order/...."}' | jq
```

## Customers

```bash
# Search
shop_gql '
{
  customers(first: 10, query: "email:*@example.com") {
    edges { node { id email displayName numberOfOrders amountSpent { amount currencyCode } } }
  }
}'

# Create
shop_gql '
mutation($input: CustomerInput!) {
  customerCreate(input: $input) {
    customer { id email }
    userErrors { field message }
  }
}' '{"input":{"email":"test@example.com","firstName":"Test","lastName":"User","tags":["api-created"]}}'
```

## Inventory

Inventory lives on **inventory items** tied to variants, quantities tracked per **location**.

```bash
# Get inventory for a variant across all locations
shop_gql '
query($id: ID!) {
  productVariant(id: $id) {
    id sku
    inventoryItem {
      id tracked
      inventoryLevels(first: 10) {
        edges { node { location { id name } quantities(names: ["available","on_hand","committed"]) { name quantity } } }
      }
    }
  }
}' '{"id":"gid://shopify/ProductVariant/..."}'
```

Adjust stock (delta) — uses `inventoryAdjustQuantities`:

```bash
shop_gql '
mutation($input: InventoryAdjustQuantitiesInput!) {
  inventoryAdjustQuantities(input: $input) {
    inventoryAdjustmentGroup { reason changes { name delta } }
    userErrors { field message }
  }
}' '{
  "input": {
    "reason": "correction",
    "name": "available",
    "changes": [{"delta": 5, "inventoryItemId": "gid://shopify/InventoryItem/...", "locationId": "gid://shopify/Location/..."}]
  }
}'
```

Set absolute stock (not delta) — `inventorySetQuantities`:

```bash
shop_gql '
mutation($input: InventorySetQuantitiesInput!) {
  inventorySetQuantities(input: $input) {
    inventoryAdjustmentGroup { id }
    userErrors { field message }
  }
}' '{"input":{"reason":"correction","name":"available","ignoreCompareQuantity":true,"quantities":[{"inventoryItemId":"gid://shopify/InventoryItem/...","locationId":"gid://shopify/Location/...","quantity":100}]}}'
```

## Metafields & Metaobjects

Metafields attach custom data to resources (products, customers, orders, shop).

```bash
# Read
shop_gql '
query($id: ID!) {
  product(id: $id) {
    metafields(first: 10, namespace: "custom") {
      edges { node { key type value } }
    }
  }
}' '{"id":"gid://shopify/Product/..."}'

# Write (works for any owner type)
shop_gql '
mutation($metafields: [MetafieldsSetInput!]!) {
  metafieldsSet(metafields: $metafields) {
    metafields { id key namespace }
    userErrors { field message code }
  }
}' '{"metafields":[{"ownerId":"gid://shopify/Product/...","namespace":"custom","key":"care_instructions","type":"multi_line_text_field","value":"Wash cold. Tumble dry low."}]}'
```

## Storefront API (public read-only)

Different endpoint, different token, used for customer-facing apps/hydrogen-style headless setups. Headers differ:

- **Endpoint:** `https://$SHOPIFY_STORE_DOMAIN/api/$SHOPIFY_API_VERSION/graphql.json`
- **Auth header (public):** `X-Shopify-Storefront-Access-Token: <public token>` — embeddable in browser
- **Auth header (private):** `Shopify-Storefront-Private-Token: <private token>` — server-only

```bash
curl -sS -X POST \
  "https://${SHOPIFY_STORE_DOMAIN}/api/${SHOPIFY_API_VERSION:-2026-01}/graphql.json" \
  -H "Content-Type: application/json" \
  -H "X-Shopify-Storefront-Access-Token: ${SHOPIFY_STOREFRONT_TOKEN}" \
  -d '{"query":"{ shop { name } products(first: 5) { edges { node { id title handle } } } }"}' | jq
```

## Bulk Operations

For dumps larger than rate limits allow (full product catalog, all orders for a year):

```bash
# 1. Start bulk query
shop_gql '
mutation {
  bulkOperationRunQuery(query: """
    { products { edges { node { id title handle variants { edges { node { sku price } } } } } } }
  """) {
    bulkOperation { id status }
    userErrors { field message }
  }
}'

# 2. Poll status
shop_gql '{ currentBulkOperation { id status errorCode objectCount fileSize url partialDataUrl } }'

# 3. When status=COMPLETED, download the JSONL file
curl -sS "$URL" > products.jsonl
```

Each JSONL line is a node, and nested connections are emitted as separate lines with `__parentId`. Reassemble client-side if needed.

## Webhooks

Subscribe to events so you don't have to poll:

```bash
shop_gql '
mutation($topic: WebhookSubscriptionTopic!, $sub: WebhookSubscriptionInput!) {
  webhookSubscriptionCreate(topic: $topic, webhookSubscription: $sub) {
    webhookSubscription { id topic endpoint { __typename ... on WebhookHttpEndpoint { callbackUrl } } }
    userErrors { field message }
  }
}' '{"topic":"ORDERS_CREATE","sub":{"callbackUrl":"https://example.com/webhook","format":"JSON"}}'
```

Verify incoming webhook HMAC using the app's client secret (not the access token):

```bash
echo -n "$REQUEST_BODY" | openssl dgst -sha256 -hmac "$APP_SECRET" -binary | base64
# Compare to X-Shopify-Hmac-Sha256 header
```

## Pitfalls

- **REST endpoints still exist but are frozen.** Don't write new integrations against `/admin/api/.../products.json`. Use GraphQL.
- **Token format check.** Admin tokens start with `shpat_`. Storefront public tokens with `shpua_`. If you have one and the wrong header, every request returns 401 without a useful error body.
- **403 with a valid token = missing scope.** Shopify returns `{"errors":[{"message":"Access denied for ..."}]}`. Re-configure Admin API scopes on the app, then reinstall to regenerate the token.
- **`userErrors` is empty != success.** Also check `data.<mutation>.<resource>` is non-null. Some failures populate neither — inspect the whole response.
- **GID vs numeric ID.** Legacy REST gave numeric IDs; GraphQL wants full GID strings. To convert: `gid://shopify/Product/<numeric>`.
- **Rate limit surprise.** A single `products(first: 250)` with deep nesting can cost 1000+ points and throttle immediately on a standard-plan shop. Start narrow, read `extensions.cost`, adjust.
- **Pagination order.** `products(first: N, reverse: true)` sorts by `id DESC`, not `created_at`. Use `sortKey: CREATED_AT, reverse: true` for "newest first."
- **`read_all_orders` for historical data.** Without it, `orders(...)` silently caps at the 60-day window. You won't get an error, just fewer results than expected. For Shopify Plus merchants with many orders, request this scope via the app's protected-data settings.
- **Currencies are strings.** Amounts come back as `"49.00"` not `49.0`. Don't `jq tonumber` blindly if you care about zero-padding.
- **Multi-currency Money fields** have `shopMoney` (store's currency) AND `presentmentMoney` (customer's). Pick one consistently.

## Safety

Mutations in Shopify are real — they create products, charge refunds, cancel orders, ship fulfillments. Before running `productDelete`, `orderCancel`, `refundCreate`, or any bulk mutation: state clearly what the change is, on which shop, and confirm with the user. There is no staging clone of production data unless the user has a separate dev store.

---

# Bankr Bridges

The sections below are not part of the upstream Hermes skill. They show how to wire Shopify resources to Bankr's onchain primitives — handle resolution, x402 settlement, and webhook-driven token flows — using only what Bankr already exposes.

Per the [Bankr docs](https://docs.bankr.bot/llms-full.txt):
- Bankr's agent natively resolves **ENS, Twitter @handle, Farcaster handle, and wallet addresses** for transfers and fee recipients. Email is **not** resolvable.
- Bankr can **call, host, and settle x402 endpoints**; USDC is the unit.
- Job pattern is **submit → poll**: `POST /agent/prompt` then `GET /agent/job/{id}`. There are no Bankr-side webhooks.
- MCP tools: `bankr_agent_submit_prompt`, `bankr_agent_get_job_status`, `bankr_agent_cancel_job`.

## Bridge 1 — Identity (Shopify customer ↔ Bankr handle)

Shopify's primary customer key is **email**, which Bankr cannot resolve. The bridge: store a Bankr-resolvable handle on the customer as a metafield, then pass the string verbatim to Bankr.

Recommended metafield definition: `namespace: custom`, `key: handle`, `type: single_line_text_field`. Acceptable values: `vitalik.eth`, `@dwr.eth`, `@username` (Twitter), or a `0x…` wallet address.

```bash
# Write a handle onto a customer at checkout / signup
shop_gql '
mutation($metafields: [MetafieldsSetInput!]!) {
  metafieldsSet(metafields: $metafields) {
    metafields { id key value }
    userErrors { field message code }
  }
}' '{"metafields":[{"ownerId":"gid://shopify/Customer/123","namespace":"custom","key":"handle","type":"single_line_text_field","value":"vitalik.eth"}]}'
```

Read it back when you need to pay or drop tokens:

```bash
HANDLE=$(shop_gql '
query($id: ID!) {
  customer(id: $id) { metafield(namespace:"custom", key:"handle") { value } }
}' '{"id":"gid://shopify/Customer/123"}' | jq -r '.data.customer.metafield.value')

# Hand off to Bankr — no extra resolution needed.
curl -sS -X POST https://api.bankr.bot/agent/prompt \
  -H "Authorization: Bearer ${BANKR_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "{\"prompt\":\"send 10 USDC to ${HANDLE} on base\"}"
```

If the metafield is empty, prompt the customer for one of {ENS, Twitter, Farcaster, wallet} before any onchain action — never guess from email.

## Bridge 2 — x402 checkout for a Shopify draft order

Bankr settles x402 endpoints natively. To accept USDC for a Shopify cart, mint a draft order, expose its total behind an x402-priced endpoint, and let Bankr pay it. On 200, mark the draft order as paid (or call `draftOrderComplete`).

```bash
# 1. Create a draft order from a cart
DRAFT=$(shop_gql '
mutation($input: DraftOrderInput!) {
  draftOrderCreate(input: $input) {
    draftOrder { id totalPriceSet { shopMoney { amount currencyCode } } invoiceUrl }
    userErrors { field message }
  }
}' '{"input":{"lineItems":[{"variantId":"gid://shopify/ProductVariant/...","quantity":1}],"email":"buyer@example.com"}}')

DRAFT_ID=$(echo "$DRAFT" | jq -r '.data.draftOrderCreate.draftOrder.id')
TOTAL=$(echo "$DRAFT" | jq -r '.data.draftOrderCreate.draftOrder.totalPriceSet.shopMoney.amount')
```

Your service then exposes an HTTP 402 endpoint pricing this draft (USDC on Base) — see the upstream x402 spec. Bankr-side, the agent settles it:

```bash
# Bankr-side (agent): pay the x402 endpoint that fronts the draft order
curl -sS -X POST https://api.bankr.bot/agent/prompt \
  -H "Authorization: Bearer ${BANKR_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "{\"prompt\":\"call x402 endpoint https://shop.example.com/x402/draft/${DRAFT_ID} and settle in USDC on base\"}"
```

When your x402 server confirms settlement, complete the draft on the Shopify side:

```bash
shop_gql '
mutation($id: ID!) {
  draftOrderComplete(id: $id, paymentPending: false) {
    draftOrder { order { id name } }
    userErrors { field message }
  }
}' "{\"id\":\"${DRAFT_ID}\"}"
```

Notes:
- Use **`shopMoney`** consistently for x402 pricing so currency conversion stays on your side, not Shopify's.
- Treat the x402 settlement tx hash as the source of truth — do not complete the draft until you've verified the hash.

## Bridge 3 — Shopify webhook → Bankr Submit (loyalty drop on `ORDERS_PAID`)

Bankr has no inbound webhooks. The pattern is: Shopify webhook → your server → HMAC verify → read customer handle metafield → submit a Bankr job → poll.

```bash
# Subscribe once
shop_gql '
mutation($topic: WebhookSubscriptionTopic!, $sub: WebhookSubscriptionInput!) {
  webhookSubscriptionCreate(topic: $topic, webhookSubscription: $sub) {
    webhookSubscription { id topic }
    userErrors { field message }
  }
}' '{"topic":"ORDERS_PAID","sub":{"callbackUrl":"https://your.server/shopify/orders-paid","format":"JSON"}}'
```

Webhook handler pseudocode:

```bash
# 1. Verify HMAC (reject if it doesn't match) — use a constant-time comparison in real code
EXPECTED=$(echo -n "$REQUEST_BODY" | openssl dgst -sha256 -hmac "$APP_SECRET" -binary | base64)
[ "$EXPECTED" = "$X_SHOPIFY_HMAC_SHA256" ] || exit 1

# 2. Pull the buyer's Bankr handle from the order's customer metafield
CUSTOMER_ID=$(echo "$REQUEST_BODY" | jq -r '.customer.admin_graphql_api_id')
HANDLE=$(shop_gql '
query($id: ID!) {
  customer(id: $id) { metafield(namespace:"custom", key:"handle") { value } }
}' "{\"id\":\"${CUSTOMER_ID}\"}" | jq -r '.data.customer.metafield.value')
[ -z "$HANDLE" ] || [ "$HANDLE" = "null" ] && exit 0   # silently skip, no handle on file

# 3. Submit the loyalty drop to Bankr
JOB=$(curl -sS -X POST https://api.bankr.bot/agent/prompt \
  -H "Authorization: Bearer ${BANKR_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "{\"prompt\":\"send 100 LOYALTY to ${HANDLE} on base\"}" | jq -r '.job_id')

# 4. Poll until terminal
while :; do
  STATUS=$(curl -sS "https://api.bankr.bot/agent/job/${JOB}" \
    -H "Authorization: Bearer ${BANKR_API_KEY}" | jq -r '.status')
  case "$STATUS" in
    completed|failed|cancelled) break ;;
  esac
  sleep 2
done
```

Same shape works for: royalty splits on digital goods, Clanker token airdrops to repeat buyers, treasury sweeps when a sales threshold is hit. Add an idempotency key on the Shopify order id so a replayed webhook cannot trigger a duplicate Bankr submission.

## Bridge 4 — Scope cheatsheet for the Bankr flows

Minimum Admin API scopes per bridge:

| Flow | Required scopes |
| --- | --- |
| Identity bridge (read/write handle metafield) | `read_customers`, `write_customers` |
| x402 checkout via draft orders | `read_customers`, `write_draft_orders`, `read_orders` |
| Webhook → Bankr loyalty drop | `read_orders`, `read_customers`, plus webhook subscription on `ORDERS_PAID` |
| Bulk export for an offline reconciliation against onchain tx hashes | `read_products`, `read_orders`, `read_customers` |

Token only needs what the agent will actually use. Don't grant `write_*` scopes to a read-only reconciliation agent.

---

## Credit

Core Shopify content (everything above the "Bankr Bridges" header) is adapted, with attribution, from `NousResearch/hermes-agent` (`optional-skills/productivity/shopify/SKILL.md`), MIT licensed. The Bankr bridge sections are new and contributed under the same MIT license.
More from BankrBot/skills