Skip to main contentCertyo Developer Portal

Shopify Plus Integration

Automatically create blockchain-backed authenticity certificates for every fulfilled order. This guide covers webhook registration, server-side handling, Certyo record ingestion, and storefront badge display using Shopify Plus.

GraphQL Admin API required
Since April 2025, all new public Shopify apps must use the GraphQL Admin API. The REST Admin API is deprecated and no longer accepts new app submissions. All examples in this guide use GraphQL.

Overview

The integration subscribes to Shopify's ORDERS_FULFILLED webhook. When a merchant fulfills an order, your server receives the webhook payload, extracts product and fulfillment data, sends it to Certyo for blockchain anchoring, and then writes the certificate ID back to the product as a metafield. Customers see a verification badge on the storefront.

Architecture

Data flowbash
┌─────────────────┐     ORDERS_FULFILLED      ┌──────────────────┐
│  Shopify Plus    │ ──── webhook (HTTPS) ───> │  Your Server     │
│  (fulfillment)   │                           │  (Node.js)       │
└─────────────────┘                            └────────┬─────────┘
                                                        │
                                          POST /api/v1/records
                                                        │
                                                        v
                                               ┌────────────────┐
                                               │  Certyo API    │
                                               │  (202 Accepted)│
                                               └────────┬───────┘
                                                        │
                                              ~60-90s anchoring
                                                        │
                                                        v
                                               ┌────────────────┐
                                               │  Webhook / Poll │
                                               │  (anchored)    │
                                               └────────┬───────┘
                                                        │
                                          GraphQL: productUpdate
                                            (set metafield)
                                                        │
                                                        v
                                               ┌────────────────┐
                                               │  Shopify        │
                                               │  Storefront     │
                                               │  (badge shown)  │
                                               └────────────────┘

Prerequisites

  • Shopify Plus or a Development store (for testing)
  • A Custom App with read_orders and write_products scopes enabled in the Shopify Admin
  • Your Custom App's Admin API access token and webhook signing secret (found under API credentials)
  • A Certyo API key (obtain from your Certyo dashboard under Settings → API Keys)
  • A publicly accessible HTTPS endpoint for receiving webhooks (use ngrok for local development)

Step 1: Register the webhook

Use the GraphQL Admin API to subscribe to ORDERS_FULFILLED. This tells Shopify to POST the full order payload to your endpoint whenever a merchant marks an order as fulfilled.

Register ORDERS_FULFILLED webhookbash
curl -X POST "https://your-store.myshopify.com/admin/api/2025-04/graphql.json" \
  -H "X-Shopify-Access-Token: $SHOPIFY_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation webhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) { webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) { webhookSubscription { id endpoint { __typename ... on WebhookHttpEndpoint { callbackUrl } } } userErrors { field message } } }",
    "variables": {
      "topic": "ORDERS_FULFILLED",
      "webhookSubscription": {
        "callbackUrl": "https://your-server.com/webhooks/shopify/orders-fulfilled",
        "format": "JSON"
      }
    }
  }'

Step 2: Webhook handler with Certyo ingestion

The webhook handler verifies the HMAC signature, extracts product and fulfillment data from each line item, and sends one Certyo record per SKU. The handler responds with 200 OK immediately (Shopify retries if it doesn't get a response within 5 seconds) and processes asynchronously.

server.js — Shopify webhook handlerjavascript
import express from "express";
import crypto from "crypto";

const app = express();

const SHOPIFY_WEBHOOK_SECRET = process.env.SHOPIFY_WEBHOOK_SECRET;
const SHOPIFY_ACCESS_TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const SHOPIFY_STORE_DOMAIN = process.env.SHOPIFY_STORE_DOMAIN; // e.g. "your-store.myshopify.com"
const CERTYO_API_KEY = process.env.CERTYO_API_KEY;
const CERTYO_TENANT_ID = process.env.CERTYO_TENANT_ID;

// Shopify sends raw body — must parse before JSON middleware
app.post(
  "/webhooks/shopify/orders-fulfilled",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    // ── 1. Verify HMAC-SHA256 signature ──
    const hmacHeader = req.get("X-Shopify-Hmac-Sha256");
    const digest = crypto
      .createHmac("sha256", SHOPIFY_WEBHOOK_SECRET)
      .update(req.body)
      .digest("base64");

    if (digest !== hmacHeader) {
      console.error("HMAC verification failed");
      return res.status(401).send("Unauthorized");
    }

    // Respond immediately — Shopify expects 200 within 5s
    res.status(200).send("OK");

    // ── 2. Parse order and extract line items ──
    const order = JSON.parse(req.body.toString());
    const fulfillment = order.fulfillments?.[order.fulfillments.length - 1];

    for (const item of order.line_items) {
      const sku = item.sku || item.variant_id?.toString();
      if (!sku) continue;

      // ── 3. Send record to Certyo ──
      try {
        const certyoResponse = await fetch(
          "https://www.certyos.com/api/v1/records",
          {
            method: "POST",
            headers: {
              "X-API-Key": CERTYO_API_KEY,
              "Content-Type": "application/json",
            },
            body: JSON.stringify({
              tenantId: CERTYO_TENANT_ID,
              database: "shopify",
              collection: "fulfilled-orders",
              recordId: sku,
              recordVersion: order.order_number.toString(),
              operationType: "insert",
              recordPayload: {
                orderId: order.id.toString(),
                orderNumber: order.order_number,
                sku: sku,
                productTitle: item.title,
                variantTitle: item.variant_title,
                quantity: item.quantity,
                price: item.price,
                currency: order.currency,
                fulfillmentId: fulfillment?.id?.toString(),
                trackingNumber: fulfillment?.tracking_number,
                trackingCompany: fulfillment?.tracking_company,
                fulfilledAt: fulfillment?.created_at,
                customerEmail: order.email,
                shippingCountry: order.shipping_address?.country_code,
              },
              sourceTimestamp: order.fulfilled_at || order.updated_at,
              idempotencyKey: `shopify-${order.id}-${sku}-${order.order_number}`,
            }),
          }
        );

        if (!certyoResponse.ok) {
          console.error(`Certyo ingestion failed for SKU ${sku}: ${certyoResponse.status}`);
          continue;
        }

        const result = await certyoResponse.json();
        console.log(`Record accepted — SKU: ${sku}, hash: ${result.recordHash}`);

        // ── 4. Store certificate data as product metafield ──
        // Wait for anchoring (~60-90s), then write the certificate back
        scheduleMetafieldUpdate(item.product_id, sku, result.recordHash);
      } catch (err) {
        console.error(`Error processing SKU ${sku}:`, err);
      }
    }
  }
);

/**
 * Poll Certyo for anchoring status, then write the certificate ID
 * back to Shopify as a product metafield via GraphQL.
 */
async function scheduleMetafieldUpdate(productId, sku, recordHash) {
  const maxAttempts = 24; // 24 x 5s = 2 minutes
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    await new Promise((r) => setTimeout(r, 5000));

    const statusRes = await fetch(
      `https://www.certyos.com/api/v1/snapshots?tenantId=${CERTYO_TENANT_ID}&recordId=${sku}`,
      { headers: { "X-API-Key": CERTYO_API_KEY } }
    );
    const { items } = await statusRes.json();
    const snapshot = items?.[0];

    if (snapshot?.status === "Anchored" && snapshot.onChainConfirmed) {
      await updateShopifyMetafield(productId, {
        certyoCertificateId: snapshot.id,
        recordHash: recordHash,
        anchoredAt: snapshot.anchoredAt,
        polygonTxHash: snapshot.onChainProof?.transactionHash,
        verificationUrl: `https://www.certyos.com/verify/${snapshot.id}`,
      });
      console.log(`Metafield updated for product ${productId}, SKU ${sku}`);
      return;
    }

    if (snapshot?.status === "Failed") {
      console.error(`Anchoring failed for SKU ${sku}`);
      return;
    }
  }
  console.error(`Timeout waiting for anchoring — SKU ${sku}`);
}

/**
 * Update a Shopify product metafield with certificate data via GraphQL Admin API.
 */
async function updateShopifyMetafield(productId, certData) {
  const mutation = `
    mutation productUpdate($input: ProductInput!) {
      productUpdate(input: $input) {
        product { id }
        userErrors { field message }
      }
    }
  `;

  const variables = {
    input: {
      id: `gid://shopify/Product/${productId}`,
      metafields: [
        {
          namespace: "certyo",
          key: "certificate_id",
          type: "single_line_text_field",
          value: certData.certyoCertificateId,
        },
        {
          namespace: "certyo",
          key: "verification_url",
          type: "url",
          value: certData.verificationUrl,
        },
        {
          namespace: "certyo",
          key: "polygon_tx_hash",
          type: "single_line_text_field",
          value: certData.polygonTxHash || "",
        },
        {
          namespace: "certyo",
          key: "anchored_at",
          type: "single_line_text_field",
          value: certData.anchoredAt || "",
        },
      ],
    },
  };

  const res = await fetch(
    `https://${SHOPIFY_STORE_DOMAIN}/admin/api/2025-04/graphql.json`,
    {
      method: "POST",
      headers: {
        "X-Shopify-Access-Token": SHOPIFY_ACCESS_TOKEN,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ query: mutation, variables }),
    }
  );

  const body = await res.json();
  if (body.data?.productUpdate?.userErrors?.length > 0) {
    console.error("Metafield update errors:", body.data.productUpdate.userErrors);
  }
}

app.listen(3000, () => console.log("Webhook server running on port 3000"));

Step 3: Store certificate as product metafield

The handler above already writes metafields after anchoring. Here is the standalone GraphQL mutation if you need to set metafields separately (e.g., from a backfill script):

Set Certyo metafields on a productbash
mutation productUpdate($input: ProductInput!) {
  productUpdate(input: $input) {
    product {
      id
      metafields(first: 10, namespace: "certyo") {
        edges {
          node {
            key
            value
          }
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}

# Variables:
# {
#   "input": {
#     "id": "gid://shopify/Product/8234567890",
#     "metafields": [
#       {
#         "namespace": "certyo",
#         "key": "certificate_id",
#         "type": "single_line_text_field",
#         "value": "snap_abc123def456"
#       },
#       {
#         "namespace": "certyo",
#         "key": "verification_url",
#         "type": "url",
#         "value": "https://www.certyos.com/verify/snap_abc123def456"
#       },
#       {
#         "namespace": "certyo",
#         "key": "polygon_tx_hash",
#         "type": "single_line_text_field",
#         "value": "0x7a3f...e91c"
#       },
#       {
#         "namespace": "certyo",
#         "key": "anchored_at",
#         "type": "single_line_text_field",
#         "value": "2026-04-14T12:30:00Z"
#       }
#     ]
#   }
# }

Step 4: Display certificate badge on storefront

Add a Liquid snippet to your theme to render a verification badge on product pages. The snippet reads the certyo.certificate_id metafield and shows a clickable badge linking to the verification page.

snippets/certyo-badge.liquidjavascript
{% comment %}
  Certyo Authenticity Badge
  Add {% render 'certyo-badge' %} to your product template.
{% endcomment %}

{%- assign cert_id = product.metafields.certyo.certificate_id.value -%}
{%- assign verify_url = product.metafields.certyo.verification_url.value -%}
{%- assign anchored_at = product.metafields.certyo.anchored_at.value -%}

{%- if cert_id != blank -%}
  <div class="certyo-badge" style="
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 10px 16px;
    background: #0f2e1f;
    border: 1px solid #24a148;
    border-radius: 6px;
    margin: 16px 0;
  ">
    <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
      <path d="M10 1l2.39 4.84L18 6.71l-4 3.9.94 5.5L10 13.77l-4.94 2.34.94-5.5-4-3.9 5.61-.87L10 1z"
            fill="#24a148" stroke="#24a148" stroke-width="1.5"/>
    </svg>
    <div>
      <a href="{{ verify_url }}"
         target="_blank"
         rel="noopener noreferrer"
         style="color: #24a148; text-decoration: none; font-weight: 600; font-size: 14px;">
        Blockchain Verified Authentic
      </a>
      {%- if anchored_at != blank -%}
        <div style="font-size: 11px; color: #a8a8a8; margin-top: 2px;">
          Anchored {{ anchored_at | date: "%B %d, %Y" }} &middot; Powered by Certyo
        </div>
      {%- endif -%}
    </div>
  </div>
{%- endif -%}

Authentication reference

Shopify webhook HMAC verification

Every webhook request from Shopify includes an X-Shopify-Hmac-Sha256 header. Compute the HMAC-SHA256 of the raw request body using your app's webhook signing secret and compare it to the header value. Reject any request where the digest does not match.

Shopify Admin API access token

Your Custom App provides a static Admin API access token (begins with shpat_). Pass it in the X-Shopify-Access-Token header on all GraphQL Admin API calls. Store it securely in environment variables — never commit it to source control.

Certyo API key

Pass your Certyo API key in the X-API-Key header on all Certyo API calls. Use separate keys for development and production environments.


Webhook payload mapping

The table below shows how Shopify webhook fields map to the Certyo record payload:

Shopify fieldCertyo fieldNotes
line_items[].skurecordIdFalls back to variant_id if SKU is empty
order_numberrecordVersionEnsures each fulfillment is a new version
fulfillments[-1].tracking_numberrecordPayload.trackingNumberLast fulfillment's tracking number
fulfilled_atsourceTimestampISO 8601 timestamp of fulfillment
id + sku + order_numberidempotencyKeyComposite key prevents duplicate ingestion on retries

Rate limits

Shopify Plus stores get 500 GraphQL cost points per second with a bucket capacity of 10,000 points. Each productUpdate mutation costs approximately 10 points. For high-volume fulfillment events:

  • Queue metafield updates — process them serially or with a concurrency limiter (e.g., p-limit in Node.js) to stay within throttle limits
  • Check the extensions.cost.throttleStatus field in GraphQL responses to monitor your remaining budget
  • If currentlyAvailable drops below 100, pause for restoreRate milliseconds before the next call
  • Certyo's POST /api/v1/records endpoint has no per-second rate limit, but consider using POST /api/v1/records/bulk if you process more than 50 fulfillments per minute
GraphQL throttle status (in response extensions)json
{
  "extensions": {
    "cost": {
      "requestedQueryCost": 10,
      "actualQueryCost": 10,
      "throttleStatus": {
        "maximumAvailable": 10000,
        "currentlyAvailable": 9740,
        "restoreRate": 500
      }
    }
  }
}

Testing locally

Use ngrok to expose your local server to Shopify webhooks during development:

Local development setupbash
# Terminal 1: Start your webhook server
SHOPIFY_WEBHOOK_SECRET=your_secret \
SHOPIFY_ACCESS_TOKEN=shpat_xxx \
SHOPIFY_STORE_DOMAIN=your-dev-store.myshopify.com \
CERTYO_API_KEY=your_certyo_key \
CERTYO_TENANT_ID=your_tenant \
node server.js

# Terminal 2: Expose via ngrok
ngrok http 3000

# Then register the webhook with your ngrok URL:
# https://abc123.ngrok-free.app/webhooks/shopify/orders-fulfilled
Shopify CLI alternative
If you use the Shopify CLI, run ‘shopify app dev’ which automatically creates a tunnel and registers webhooks defined in your shopify.app.toml configuration file.

AI Integration · v1.0.0

AI Integration Skill

Download a skill file that enables AI agents to generate working Shopify Plus + Certyo integration code for any language or framework.

v1.0.0
What is this?
A markdown file containing Shopify Plus-specific field mappings, authentication setup, code examples, and integration patterns for Certyo. Drop it into your AI agent's context and ask it to generate integration code.

What's inside

  • AuthenticationHMAC-SHA256 webhook verification and Admin API access tokens
  • Architectureorders/fulfilled webhook → handler → Certyo → GraphQL metafield update
  • Field mappingSKU, order_number, tracking_number to Certyo record schema
  • Code examplesNode.js/Express webhook handler, GraphQL mutations, Liquid storefront badge
  • VerificationPolling with metafield update for certificate display on storefront
  • Rate limiting500 GraphQL cost points/second on Plus with throttling strategy

How to use

Claude Code

Place the file in your project's .claude/commands/ directory, then use it as a slash command:

# Download the skill file
mkdir -p .claude/commands
curl -o .claude/commands/certyo-shopify.md \
  https://www.certyos.com/developers/skills/certyo-shopify-skill.md

# Use it in Claude Code
/certyo-shopify "Generate a Node.js webhook handler for Shopify fulfilled orders to Certyo"

Cursor / Copilot / Any AI Agent

Add the file to your project root or attach it to a conversation. The AI agent will use the Shopify Plus-specific patterns, field mappings, and code examples to generate correct integration code.

# Add to your project
curl -o CERTYO_SHOPIFY.md \
  https://www.certyos.com/developers/skills/certyo-shopify-skill.md

# Then in your AI agent:
"Using the Certyo Shopify Plus spec in CERTYO_SHOPIFY.md,
 generate a node.js webhook handler for shopify fulfilled orders to certyo"

CLAUDE.md Context File

Append the skill file to your project's CLAUDE.md so every Claude conversation has Shopify Plus + Certyo context automatically.

# Append to your project's CLAUDE.md
echo "" >> CLAUDE.md
echo "## Certyo Shopify Plus Integration" >> CLAUDE.md
cat CERTYO_SHOPIFY.md >> CLAUDE.md