Skip to main content

Integrate Shopify (DPP metafields, QR, email template, import)

This guide assumes you already followed the Publish on Shopify tutorial — your shop is connected, the catalogue is synced, and the keyban.app_url shop metafield exists. From there, this page covers the four customisations that turn the raw integration into a production-ready buyer experience.

At a glance

  1. Read the DPP URLFrom Liquid via shop.metafields.keyban.app_url (theme contexts only).
  2. Shipping confirmation emailEmbed the DPP URL and a QR code in the email Shopify sends when you ship the order.
  3. Keyban DPP applicationHosts the public passport URLs and the keyban.app_url metafield.
  4. Product pageEmbed the DPP URL and a QR code on the storefront product template.
  5. Catalogue importRe-trigger or troubleshoot the Shopify-to-Keyban product sync.
The Keyban DPP application is the hub. Four spokes describe the touch points covered by this guide: reading the DPP URL from a Shopify metafield, embedding it on the product page, embedding it in the shipping confirmation email, and controlling the catalogue import.

1. Read the DPP URL from Shopify metafields

When the Keyban app is installed, the OAuth callback creates a single shop-level metafield definition:

PropertyValue
Namespacekeyban
Keyapp_url
TypeURL
Owner typeSHOP (one value for the shop)
DescriptionURL to the Keyban DPP application

The value is the public URL of the DPP application this shop is bound to (https://{slug}.{dpp-domain}). It updates automatically when the application is renamed in Keyban.

Where it works

Liquid contextSupportNotes
Themestemplates/*, sections/*, snippets/*, layout/*SupportedRecommended path. The syntax in the next paragraph applies as-is.
Order Printer templatesSupportedUseful for packing slips, invoices, and other printable artefacts.
Notification email templates (Shipping confirmation, Order confirmation, …)Unofficialshop.metafields access is not documented in notifications. Some shops report it works, others get an empty value. See Section 3 for the safe approach.

In a theme file, the syntax is:

{{ shop.metafields.keyban.app_url }}

If the metafield definition exists but the value has not been published yet (which can happen during the brief window between the OAuth callback and the first metafield write), the expression renders as an empty string — always guard your snippets with an {% if %} check, as shown in Section 2.

2. Embed the DPP URL and a QR code on a product page

Add a section to your templates/product.liquid (or a custom section/app block, depending on your theme architecture) that links to the DPP and displays a QR code that points to the same URL.

{%- assign dpp_url = shop.metafields.keyban.app_url -%}
{% if dpp_url != blank %}
<div class="keyban-dpp" style="display:flex; gap:16px; align-items:center;">
<img
src="https://api.qrserver.com/v1/create-qr-code/?size=160x160&data={{ dpp_url | url_encode }}"
width="160"
height="160"
alt="Scan to view the Keyban Digital Product Passport"
/>
<div>
<h3>Digital Product Passport</h3>
<p>
After your purchase, your passport will be available at the
Keyban application:
</p>
<a href="{{ dpp_url }}" target="_blank" rel="noopener noreferrer">
{{ dpp_url }}
</a>
</div>
</div>
{% endif %}

Notes:

  • The if dpp_url != blank guard prevents an empty section from rendering before the metafield value is propagated, or on shops that never installed the Keyban app.
  • The URL stored in keyban.app_url is the application's public URL, not a per-product URL. The DPP application identifies the buyer from their Keyban session and routes them to the right passports.

3. Embed the DPP URL and a QR code in the shipping confirmation email

The buyer's passport becomes claimable when you ship the order, not when they pay — that's when Keyban activates the placeholder passports created at checkout (see Step 4 of the tutorial). Sending the DPP link any earlier would frustrate the buyer because the claim wouldn't yet succeed.

So edit the Shipping confirmation template, not Order confirmation: Settings → Notifications → Shipping confirmation. Notifications are HTML emails rendered with a restricted Liquid context, so the approach is slightly different from a theme.

Copy the DPP URL once from Shopify Admin → Settings → Custom data → Shop → Keyban App URL, then paste it as a constant in your template:

{%- assign dpp_url = "https://your-app.dpp.keyban.io" -%}

<table style="margin: 24px auto; border-collapse: collapse;">
<tr>
<td style="padding-right: 16px; vertical-align: middle;">
<img
src="https://api.qrserver.com/v1/create-qr-code/?size=160x160&data={{ dpp_url | url_encode }}"
width="160"
height="160"
alt="Scan to claim your Keyban Digital Product Passport"
/>
</td>
<td style="vertical-align: middle;">
<strong>Your Digital Product Passport is ready.</strong>
<p>Sign in with the email used to place this order:</p>
<p>
<a href="{{ dpp_url }}">{{ dpp_url }}</a>
</p>
</td>
</tr>
</table>

This approach is robust: it has no runtime dependency on Shopify's notification context, it works on every shop, and there's nothing to debug. The downside is that you have to update the template if the DPP application is ever renamed in Keyban — but renames are rare and the metafield value gives you a single place to copy from.

QR code rendering across email clients

External <img> sources work in most clients, but two caveats:

  • Image blocking — Outlook and Gmail block external images by default until the user clicks "Display images". Repeat the URL as plain text below the QR so a buyer who never displays images can still copy-paste it.
  • Resolution — request a fixed pixel size in the QR-service URL (size=160x160) and set matching width / height attributes on the <img>. Email clients are unforgiving about responsive sizing.

Roadmap — first-class notification access via product metafields

Shopify does support {{ line.product.metafields.<namespace>.<key> }} inside notification templates. Once Keyban also publishes the DPP URL at the product scope (in addition to the existing shop scope), you'll be able to write:

{% for line in line_items %}
<a href="{{ line.product.metafields.keyban.app_url }}">View passport</a>
{% endfor %}

This is tracked as a future enhancement to the Shopify integration — the current backend only writes the shop-scope metafield (see services/shopify.service.ts:648).

4. Trigger and troubleshoot the catalogue import

The catalogue sync runs automatically: once on install (from the OAuth callback) and again on every PRODUCTS_CREATE / PRODUCTS_UPDATE webhook. You usually don't need to invoke anything manually.

Re-sync after a metadata fix

If you bulk-edit your Shopify products and want to push the changes to Keyban without waiting for the next webhook, the simplest trigger is to save any product in the Shopify Admin (no actual edit required). Saving fires a PRODUCTS_UPDATE webhook, which calls syncAllProducts(shop) server-side and re-walks the entire catalogue via the GraphQL products connection.

For a hard reset, uninstalling and reinstalling the Keyban app rebuilds the Shopify session and re-runs the initial install sync. Existing DPP model passports are reused (the unique key is the Shopify product id), so nothing is lost on the Keyban side.

Common errors

Symptom in the Keyban Admin / logsLikely causeFix
GraphQL 401 Unauthorized from ShopifyThe offline access token was revoked in Shopify Admin or the app was uninstalled.Reinstall the Keyban app from the Shopify Admin — the OAuth callback persists a new session.
GraphQL 403 Forbidden on a specific scopeThe Shopify Partner app definition is missing one of the required access scopes (read_products, read_orders, …).Update the app's scope set in the Partner Dashboard, then reinstall on the merchant shop — Shopify only re-prompts for scopes on a fresh OAuth.
Products synced but variants missingThe product has no published variant (e.g. archived).Publish at least one variant in Shopify, then save the product to retrigger PRODUCTS_UPDATE.
Webhook calls return 400 Invalid webhook signatureClock skew on the Keyban host, or the wrong API secret.Check the host's NTP sync; verify SHOPIFY_API_SECRET_KEY matches the Partner Dashboard secret.
ORDERS_PAID fired but no item passport appearedThe order's line items reference Shopify products that were never synced (e.g. the order pre-dates the install).Save (or edit-and-save) those products to force a PRODUCTS_UPDATE, then re-pay or re-trigger the order.

High-volume catalogues

The sync paginates Shopify's products connection in pages of 20. For shops with tens of thousands of SKUs, the initial sync can take several minutes — that's expected. Until it completes, the Keyban Admin will show the Shopify shop as connected with a partial passport count; refresh the DPP → Passports list to see progress.

If you need a one-shot bulk import (for instance to seed a non-Shopify catalogue while your storefront is still being built), the Keyban backend exposes a generic POST /v1/imports endpoint that accepts a JSON array of passports — see Bulk import in the PHP guide. Imports through that endpoint are stored as source = "import" (editable), in contrast to Shopify-sourced passports (source = "shopify", read-only).

Reference

MethodPathAuthPurpose
GET/v1/dpp/shopify/shopsX-Api-KeyList the Shopify shops linked to the calling organization.
POST/v1/dpp/shopify/shopsX-Api-KeyCreate a new shop entry and start the OAuth flow.
GET/v1/dpp/shopify/shops/{id}X-Api-KeyRead a single shop, including its OAuth session state.
DELETE/v1/dpp/shopify/shops/{id}X-Api-KeyDisconnect a shop. Existing passports are kept.
GET/v1/dpp/shopify/shops/authX-Api-KeyRedirect endpoint that begins the Shopify OAuth handshake.
GET/v1/dpp/shopify/shops/callback/{appId}X-Api-KeyOAuth callback — persists the session, registers webhooks, and triggers the first sync.

The public webhooks endpoint (POST /dpp/shopify/webhooks) is intentionally excluded from the OpenAPI surface (@ApiExcludeController() in controllers/shopify-public.controller.ts). Shopify reaches it directly with a signed payload; partner integrations should not call it.