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.
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
┌─────────────────┐ 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_ordersandwrite_productsscopes 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.
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"
}
}
}'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.
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):
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"
# }
# ]
# }
# }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 productUpdate($input: ProductInput!) { productUpdate(input: $input) { product { id } 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"
}
]
}
}
}'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.
{% 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" }} · Powered by Certyo
</div>
{%- endif -%}
</div>
</div>
{%- endif -%}{% comment %}
In sections/main-product.liquid or templates/product.liquid,
add the following line where you want the badge to appear
(typically below the "Add to Cart" button):
{% endcomment %}
<div class="product__certyo-verification">
{% render 'certyo-badge' %}
</div>
{% comment %}
To also expose metafields in the storefront API (for headless builds),
enable the certyo namespace in:
Settings → Custom data → Products → Metafield definitions
Then query via the Storefront API:
query product($handle: String!) {
product(handle: $handle) {
metafield(namespace: "certyo", key: "certificate_id") {
value
}
metafield(namespace: "certyo", key: "verification_url") {
value
}
}
}
{% endcomment %}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 field | Certyo field | Notes |
|---|---|---|
line_items[].sku | recordId | Falls back to variant_id if SKU is empty |
order_number | recordVersion | Ensures each fulfillment is a new version |
fulfillments[-1].tracking_number | recordPayload.trackingNumber | Last fulfillment's tracking number |
fulfilled_at | sourceTimestamp | ISO 8601 timestamp of fulfillment |
id + sku + order_number | idempotencyKey | Composite 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-limitin Node.js) to stay within throttle limits - Check the
extensions.cost.throttleStatusfield in GraphQL responses to monitor your remaining budget - If
currentlyAvailabledrops below 100, pause forrestoreRatemilliseconds before the next call - Certyo's
POST /api/v1/recordsendpoint has no per-second rate limit, but consider usingPOST /api/v1/records/bulkif you process more than 50 fulfillments per minute
{
"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:
# 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-fulfilledAI Integration Skill
Download a skill file that enables AI agents to generate working Shopify Plus + Certyo integration code for any language or framework.
What's inside
- Authentication — HMAC-SHA256 webhook verification and Admin API access tokens
- Architecture — orders/fulfilled webhook → handler → Certyo → GraphQL metafield update
- Field mapping — SKU, order_number, tracking_number to Certyo record schema
- Code examples — Node.js/Express webhook handler, GraphQL mutations, Liquid storefront badge
- Verification — Polling with metafield update for certificate display on storefront
- Rate limiting — 500 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