---
name: Certyo + Shopify Plus Integration
version: 1.0.0
description: Generate Shopify Plus → Certyo integration code with field mappings, auth, and working examples
api_base: https://www.certyos.com
auth: X-API-Key header
last_updated: 2026-04-14
---

# Certyo + Shopify Plus Integration Skill

This skill generates production-ready Node.js code to integrate Shopify Plus with Certyo's blockchain-backed authenticity platform. It covers webhook ingestion, HMAC verification, GraphQL metafield write-back, and Liquid storefront rendering of certificate badges.

## Certyo API Reference

All requests require the `X-API-Key` header for authentication.

### Endpoints

| Method | Path | Description | Response |
|--------|------|-------------|----------|
| `POST` | `/api/v1/records` | Ingest a single record | `202 Accepted` |
| `POST` | `/api/v1/records/bulk` | Ingest up to 1000 records | `202 Accepted` |
| `POST` | `/api/v1/verify/record` | Verify blockchain anchoring | `200 OK` |
| `GET` | `/api/v1/records` | Query records | `200 OK` |

### Record Payload

```json
{
  "tenantId": "string (required)",
  "database": "string (required)",
  "collection": "string (required)",
  "recordId": "string (required)",
  "recordPayload": { "...any JSON (required)" },
  "clientId": "string (optional)",
  "recordVersion": "string (optional, default '1')",
  "operationType": "upsert|insert|update|delete (optional)",
  "sourceTimestamp": "ISO 8601 (optional)",
  "idempotencyKey": "string (optional)"
}
```

### Ingestion Response (202 Accepted)

```json
{
  "recordId": "SKU-LUXBAG-001",
  "recordHash": "sha256:ab12cd34...",
  "tenantId": "acme-corp",
  "acceptedAt": "2026-04-14T10:30:00Z",
  "idempotencyReplayed": false
}
```

### Pipeline Timing

Records flow through: Kafka -> Accumulate (1000 records or 60s) -> Merkle tree -> IPFS pin -> Polygon anchor. Total anchoring latency is approximately 60-90 seconds after accumulation flush.

## Integration Pattern

```
Shopify orders/fulfilled webhook
  -> Express.js middleware (HMAC-SHA256 verification)
    -> Parse webhook body
      -> For each line_item with SKU:
        -> POST /api/v1/records (Certyo ingestion)
          -> Store recordHash in memory/Redis
            -> POST /api/v1/verify/record (poll after 90s)
              -> GraphQL productUpdate mutation (metafield write-back)
                -> Liquid storefront renders certificate badge
```

One Certyo record is created per line item SKU (not per order), because authenticity certificates are tied to individual products.

## Authentication

### Shopify Admin API

Shopify uses an access token for Admin API calls. For custom apps on Shopify Plus, this is generated during app installation via OAuth or set as a custom app token.

```javascript
// Environment variables (never hardcode)
const SHOPIFY_API_SECRET = process.env.SHOPIFY_API_SECRET;    // For HMAC verification
const SHOPIFY_ACCESS_TOKEN = process.env.SHOPIFY_ACCESS_TOKEN; // For Admin API / GraphQL
const SHOPIFY_STORE_DOMAIN = process.env.SHOPIFY_STORE_DOMAIN; // e.g. my-store.myshopify.com
```

### Certyo API

```javascript
const CERTYO_API_KEY = process.env.CERTYO_API_KEY;
const CERTYO_TENANT_ID = process.env.CERTYO_TENANT_ID;
const CERTYO_BASE_URL = 'https://www.certyos.com';
```

### HMAC-SHA256 Webhook Verification

Shopify signs every webhook with HMAC-SHA256 using the app's API secret. You **must** verify this signature before processing any webhook payload.

```javascript
const crypto = require('crypto');

function verifyShopifyHmac(rawBody, hmacHeader) {
  const digest = crypto
    .createHmac('sha256', SHOPIFY_API_SECRET)
    .update(rawBody, 'utf8')
    .digest('base64');
  return crypto.timingSafeEqual(
    Buffer.from(digest),
    Buffer.from(hmacHeader)
  );
}
```

## Field Mapping

| Shopify Field | Certyo Field | Notes |
|--------------|-------------|-------|
| `line_items[].sku` | `recordId` | One record per SKU per fulfilled order |
| `order.order_number` | `recordPayload.orderNumber` | Human-readable order number |
| `fulfillment.created_at` | `sourceTimestamp` | ISO 8601 fulfillment timestamp |
| `fulfillment.tracking_number` | `recordPayload.trackingNumber` | Shipping tracking reference |
| Hardcoded `"shopify"` | `database` | Identifies source system |
| Hardcoded `"orders"` | `collection` | Identifies event type |
| `order.id` + `line_item.sku` | `idempotencyKey` | Prevents duplicates on webhook retries |
| `line_items[].product_id` | `recordPayload.productId` | Used for metafield write-back |
| `line_items[].title` | `recordPayload.productTitle` | Product display name |
| `line_items[].quantity` | `recordPayload.quantity` | Fulfilled quantity |

## Code Examples

### 1. Full Express.js Webhook Handler

```javascript
const express = require('express');
const crypto = require('crypto');
const axios = require('axios');

const app = express();

// --- Configuration ---
const {
  SHOPIFY_API_SECRET,
  SHOPIFY_ACCESS_TOKEN,
  SHOPIFY_STORE_DOMAIN,
  CERTYO_API_KEY,
  CERTYO_TENANT_ID,
  PORT = 3000,
} = process.env;

const CERTYO_BASE_URL = 'https://www.certyos.com';

// --- Raw body parser for HMAC verification ---
app.post(
  '/webhooks/orders/fulfilled',
  express.raw({ type: 'application/json' }),
  handleFulfilledWebhook
);

// --- HMAC Verification ---
function verifyShopifyHmac(rawBody, hmacHeader) {
  if (!hmacHeader) return false;
  const digest = crypto
    .createHmac('sha256', SHOPIFY_API_SECRET)
    .update(rawBody)
    .digest('base64');
  try {
    return crypto.timingSafeEqual(
      Buffer.from(digest),
      Buffer.from(hmacHeader)
    );
  } catch {
    return false;
  }
}

// --- Webhook Handler ---
async function handleFulfilledWebhook(req, res) {
  const hmac = req.get('X-Shopify-Hmac-Sha256');

  // Step 1: Verify HMAC immediately
  if (!verifyShopifyHmac(req.body, hmac)) {
    console.error('HMAC verification failed');
    return res.status(401).send('Unauthorized');
  }

  // Step 2: Respond 200 immediately (Shopify expects <5s response)
  res.status(200).send('OK');

  // Step 3: Process asynchronously
  try {
    const order = JSON.parse(req.body.toString());
    await processOrder(order);
  } catch (err) {
    console.error('Error processing fulfilled order:', err.message);
  }
}

// --- Process Order: Ingest each line item SKU ---
async function processOrder(order) {
  const fulfillment = order.fulfillments?.[0];
  const sourceTimestamp = fulfillment?.created_at || new Date().toISOString();
  const trackingNumber = fulfillment?.tracking_number || null;

  const results = [];

  for (const item of order.line_items) {
    if (!item.sku) {
      console.warn(`Skipping line item ${item.id}: no SKU`);
      continue;
    }

    const record = {
      tenantId: CERTYO_TENANT_ID,
      database: 'shopify',
      collection: 'orders',
      recordId: item.sku,
      recordPayload: {
        orderNumber: String(order.order_number),
        orderId: String(order.id),
        productId: String(item.product_id),
        productTitle: item.title,
        variantId: String(item.variant_id),
        quantity: item.quantity,
        price: item.price,
        trackingNumber: trackingNumber,
        customerEmail: order.email,
        fulfilledAt: sourceTimestamp,
      },
      clientId: SHOPIFY_STORE_DOMAIN,
      operationType: 'insert',
      sourceTimestamp: sourceTimestamp,
      idempotencyKey: `${order.id}-${item.sku}`,
    };

    try {
      const response = await axios.post(
        `${CERTYO_BASE_URL}/api/v1/records`,
        record,
        {
          headers: {
            'Content-Type': 'application/json',
            'X-API-Key': CERTYO_API_KEY,
          },
          timeout: 10000,
        }
      );

      console.log(`Ingested SKU ${item.sku}: hash=${response.data.recordHash}`);

      results.push({
        sku: item.sku,
        productId: item.product_id,
        recordHash: response.data.recordHash,
        recordId: response.data.recordId,
      });
    } catch (err) {
      console.error(`Failed to ingest SKU ${item.sku}:`, err.message);
    }
  }

  // Schedule verification and metafield write-back after pipeline completes
  if (results.length > 0) {
    setTimeout(() => verifyAndWriteBack(results), 90000); // 90 seconds
  }
}

// --- Verify and Write Metafields ---
async function verifyAndWriteBack(records) {
  for (const rec of records) {
    try {
      const verifyRes = await axios.post(
        `${CERTYO_BASE_URL}/api/v1/verify/record`,
        {
          tenantId: CERTYO_TENANT_ID,
          recordId: rec.recordId,
          recordHash: rec.recordHash,
        },
        {
          headers: {
            'Content-Type': 'application/json',
            'X-API-Key': CERTYO_API_KEY,
          },
          timeout: 10000,
        }
      );

      if (verifyRes.data.verified) {
        await updateProductMetafield(rec.productId, {
          recordId: rec.recordId,
          recordHash: rec.recordHash,
          polygonTxHash: verifyRes.data.polygonTxHash,
          verifiedAt: new Date().toISOString(),
          anchorStatus: 'anchored',
        });
        console.log(`Verified and wrote metafield for product ${rec.productId}`);
      } else {
        console.log(`SKU ${rec.sku} not yet anchored, will retry`);
        // Retry after another 60 seconds
        setTimeout(() => verifyAndWriteBack([rec]), 60000);
      }
    } catch (err) {
      console.error(`Verification failed for SKU ${rec.sku}:`, err.message);
    }
  }
}

app.listen(PORT, () => console.log(`Webhook server listening on port ${PORT}`));
```

### 2. GraphQL Webhook Registration

```javascript
async function registerWebhook() {
  const mutation = `
    mutation webhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {
      webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {
        webhookSubscription {
          id
          topic
          endpoint {
            ... on WebhookHttpEndpoint {
              callbackUrl
            }
          }
        }
        userErrors {
          field
          message
        }
      }
    }
  `;

  const variables = {
    topic: 'ORDERS_FULFILLED',
    webhookSubscription: {
      callbackUrl: 'https://your-server.com/webhooks/orders/fulfilled',
      format: 'JSON',
    },
  };

  const response = await axios.post(
    `https://${SHOPIFY_STORE_DOMAIN}/admin/api/2025-01/graphql.json`,
    { query: mutation, variables },
    {
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Access-Token': SHOPIFY_ACCESS_TOKEN,
      },
    }
  );

  console.log('Webhook registered:', response.data);
}
```

### 3. GraphQL Metafield Update Mutation

```javascript
async function updateProductMetafield(productId, certData) {
  const mutation = `
    mutation productUpdate($input: ProductInput!) {
      productUpdate(input: $input) {
        product {
          id
          metafields(first: 5, namespace: "certyo") {
            edges {
              node {
                key
                value
              }
            }
          }
        }
        userErrors {
          field
          message
        }
      }
    }
  `;

  const variables = {
    input: {
      id: `gid://shopify/Product/${productId}`,
      metafields: [
        {
          namespace: 'certyo',
          key: 'record_id',
          value: certData.recordId,
          type: 'single_line_text_field',
        },
        {
          namespace: 'certyo',
          key: 'record_hash',
          value: certData.recordHash,
          type: 'single_line_text_field',
        },
        {
          namespace: 'certyo',
          key: 'polygon_tx_hash',
          value: certData.polygonTxHash || '',
          type: 'single_line_text_field',
        },
        {
          namespace: 'certyo',
          key: 'anchor_status',
          value: certData.anchorStatus,
          type: 'single_line_text_field',
        },
        {
          namespace: 'certyo',
          key: 'verified_at',
          value: certData.verifiedAt || '',
          type: 'single_line_text_field',
        },
      ],
    },
  };

  const response = await axios.post(
    `https://${SHOPIFY_STORE_DOMAIN}/admin/api/2025-01/graphql.json`,
    { query: mutation, variables },
    {
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Access-Token': SHOPIFY_ACCESS_TOKEN,
      },
    }
  );

  const errors = response.data?.data?.productUpdate?.userErrors;
  if (errors?.length > 0) {
    throw new Error(`Metafield update failed: ${JSON.stringify(errors)}`);
  }

  return response.data;
}
```

### 4. Liquid Storefront Certificate Badge

```liquid
{%- comment -%}
  Certyo Authenticity Badge - Add to product template (sections/main-product.liquid)
  Displays blockchain verification status from metafields.
{%- endcomment -%}

{%- assign anchor_status = product.metafields.certyo.anchor_status.value -%}
{%- assign record_hash = product.metafields.certyo.record_hash.value -%}
{%- assign polygon_tx = product.metafields.certyo.polygon_tx_hash.value -%}
{%- assign verified_at = product.metafields.certyo.verified_at.value -%}

{%- if anchor_status == 'anchored' -%}
<div class="certyo-badge" style="
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 12px 16px;
  background: linear-gradient(135deg, #0a1628 0%, #1a2940 100%);
  border: 1px solid #2dd4bf;
  border-radius: 8px;
  color: #fff;
  font-family: inherit;
  font-size: 14px;
  margin: 16px 0;
">
  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
    <path d="M10 1L12.5 3.5H16.5V7.5L19 10L16.5 12.5V16.5H12.5L10 19L7.5 16.5H3.5V12.5L1 10L3.5 7.5V3.5H7.5L10 1Z" fill="#2dd4bf" fill-opacity="0.15" stroke="#2dd4bf" stroke-width="1.5"/>
    <path d="M7 10L9 12L13 8" stroke="#2dd4bf" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
  </svg>
  <div>
    <div style="font-weight: 600; color: #2dd4bf;">Blockchain Verified</div>
    <div style="font-size: 11px; color: #94a3b8; margin-top: 2px;">
      Anchored on Polygon &middot;
      <a href="https://polygonscan.com/tx/{{ polygon_tx }}"
         target="_blank" rel="noopener"
         style="color: #60a5fa; text-decoration: underline;">
        View proof
      </a>
    </div>
  </div>
</div>
{%- elsif anchor_status == 'pending' -%}
<div class="certyo-badge" style="
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 12px 16px;
  background: #1a2940;
  border: 1px solid #f59e0b;
  border-radius: 8px;
  color: #fff;
  font-size: 14px;
  margin: 16px 0;
">
  <span style="color: #f59e0b;">&#9679;</span>
  <span style="color: #f59e0b; font-weight: 600;">Verification Pending</span>
</div>
{%- endif -%}
```

### 5. Environment Variables (.env)

```bash
# Shopify
SHOPIFY_API_SECRET=shpss_xxxxxxxxxxxxxxxxxxxx
SHOPIFY_ACCESS_TOKEN=shpat_xxxxxxxxxxxxxxxxxxxx
SHOPIFY_STORE_DOMAIN=my-store.myshopify.com

# Certyo
CERTYO_API_KEY=cty_xxxxxxxxxxxxxxxxxxxx
CERTYO_TENANT_ID=acme-corp

# Server
PORT=3000
NODE_ENV=production
```

### 6. package.json

```json
{
  "name": "certyo-shopify-webhook",
  "version": "1.0.0",
  "description": "Shopify Plus → Certyo blockchain authenticity integration",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  },
  "dependencies": {
    "axios": "^1.7.0",
    "express": "^4.21.0"
  },
  "devDependencies": {
    "nodemon": "^3.1.0"
  }
}
```

## Verification & Write-back

### Verification Flow

1. After ingesting each SKU, store `{ sku, productId, recordHash, recordId }` in memory
2. Wait 90 seconds for the Certyo pipeline to complete (Kafka -> Accumulate -> Merkle -> IPFS -> Polygon)
3. Call `POST /api/v1/verify/record` with `tenantId`, `recordId`, and `recordHash`
4. If `verified === true`, update the product's metafields via GraphQL `productUpdate` mutation
5. If not yet verified, retry after 60 seconds (up to 5 retries)

### Metafield Namespace Convention

All Certyo-related metafields use the `certyo` namespace:

| Key | Type | Description |
|-----|------|-------------|
| `certyo.record_id` | `single_line_text_field` | Certyo record identifier |
| `certyo.record_hash` | `single_line_text_field` | SHA-256 hash of the record |
| `certyo.polygon_tx_hash` | `single_line_text_field` | Polygon blockchain transaction hash |
| `certyo.anchor_status` | `single_line_text_field` | `pending` or `anchored` |
| `certyo.verified_at` | `single_line_text_field` | ISO 8601 verification timestamp |

### GraphQL Rate Limiting

Shopify Plus stores get 500 points per second for GraphQL. The `productUpdate` mutation with metafields costs approximately 10 points. At that rate, you can update ~50 products per second. For high-volume fulfillment events, batch updates or use a queue.

### Production Considerations

For production deployments, replace the in-memory `setTimeout` pattern with a proper job queue:

```javascript
// Example using Bull queue with Redis
const Queue = require('bull');
const verifyQueue = new Queue('certyo-verify', process.env.REDIS_URL);

verifyQueue.process(async (job) => {
  const { records } = job.data;
  await verifyAndWriteBack(records);
});

// In processOrder(), replace setTimeout with:
await verifyQueue.add({ records: results }, { delay: 90000, attempts: 5 });
```

## Code Generation Rules

1. **Always verify HMAC-SHA256 before processing any webhook payload.** Use `crypto.timingSafeEqual` to prevent timing attacks. Reject requests immediately with 401 if verification fails. Never skip this step, even in development.

2. **Respond to Shopify webhooks with 200 status immediately, then process asynchronously.** Shopify expects a response within 5 seconds. If you do not respond in time, Shopify will retry the webhook (up to 19 times over 48 hours), causing duplicate processing.

3. **Use GraphQL Admin API, not REST Admin API.** GraphQL is more efficient (fewer round trips, precise field selection) and is the recommended API for Shopify Plus. REST endpoints are being deprecated for new features.

4. **Create one Certyo record per line item SKU, not per order.** Authenticity certificates are product-specific. An order with 3 different SKUs should produce 3 separate Certyo records. Items with the same SKU in the same order share one record.

5. **Use the `certyo` metafield namespace for all certificate data.** Store `record_id`, `record_hash`, `polygon_tx_hash`, `anchor_status`, and `verified_at` as product metafields. This makes certificate data accessible in Liquid templates and the Shopify Admin.

6. **Construct idempotency keys from `order.id` + `line_item.sku`.** This prevents duplicate ingestion when Shopify retries webhooks. Certyo will return `idempotencyReplayed: true` if the same key was already processed.

7. **Never hardcode API keys, tokens, or store domains.** Use environment variables for all credentials. Provide a `.env.example` file documenting required variables, but never commit actual secrets.

8. **Use `express.raw()` middleware for the webhook route, not `express.json()`.** HMAC verification requires the raw request body. If you parse JSON first, the HMAC will not match. Parse JSON manually after verification.

9. **Implement retry logic for verification polling.** The Certyo pipeline takes 60-90 seconds. Poll `POST /api/v1/verify/record` starting at 90 seconds, retrying every 60 seconds up to 5 times. For production, use a Redis-backed job queue instead of `setTimeout`.

10. **Handle Shopify GraphQL rate limits with cost-aware throttling.** Check the `extensions.cost` field in GraphQL responses. If `throttleStatus.currentlyAvailable` drops below 50 points, add a delay before the next request. Never retry immediately on 429 responses.
