Guides

Shopify

The supported integration pattern for Shopify stores.

The constraint (read this first)

Shopify does not let arbitrary apps add a payment method to its checkout. Third-party payment methods must be built with the Payments Apps API and approved by Shopify as a Payments Partner — a commercial and compliance review, not a coding task. Until that approval exists, any "escrow gateway for Shopify" is a workaround. We recommend being explicit about which one you ship.

Supported pattern today: manual payment method + order webhook

code
Customer → Shopify checkout → chooses manual method "Pay with Escrow"
Shopify ── orders/create webhook ──▶ your bridge app ── POST /checkout/sessions ──▶ Escrow API
Bridge  ── email / order-status page link ──▶ Customer opens hosted escrow checkout, pays
Escrow  ── escrow.funded webhook ──▶ bridge ── Admin API: mark order paid ──▶ Shopify
Shopify ── fulfillments/create webhook ──▶ bridge ── POST …/milestones/{id}/submit ──▶ Escrow
Escrow  ── escrow.completed ──▶ bridge ── add tag/note "escrow-released"
  1. Shopify admin → Settings → Payments → Manual payment methods → Create custom payment method named "Pay with Escrow (buyer protection)", with instructions like "You'll receive a secure escrow link to complete payment."
  2. Bridge app (a small custom/private app — Node example below) subscribes to orders/create and fulfillments/create, and exposes an escrow webhook receiver.
  3. Give the customer the link:
    • via an order-status-page / thank-you Checkout UI extension that reads a metafield the bridge writes (escrow.checkout_url), and
    • via email (Shopify Flow or your ESP) as a fallback.
  4. On escrow.funded, call the Admin GraphQL orderMarkAsPaid mutation.
  5. On fulfillments/create, submit the escrow milestone with the tracking number.

Trade-off to accept consciously: the customer leaves Shopify's checkout without having paid, so some will not complete the escrow step. Measure the drop-off, send reminders, and expire sessions (POST /checkout/sessions/{id}/expire) when you cancel stale orders.

Minimal bridge (Node)

js
import express from 'express';
import crypto from 'node:crypto';
import { EscrowClient, constructEvent } from '@smart-contracts-escrow/node';

const escrow = new EscrowClient({ apiKey: process.env.SCE_API_KEY });
const app = express();

function verifyShopify(req) {
  const digest = crypto.createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET).update(req.body).digest('base64');
  const given = Buffer.from(req.get('X-Shopify-Hmac-Sha256') || '', 'base64');
  return given.length === 32 && crypto.timingSafeEqual(given, Buffer.from(digest, 'base64'));
}

app.post('/shopify/orders-create', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyShopify(req)) return res.sendStatus(401);
  const order = JSON.parse(req.body);
  if (!order.payment_gateway_names?.includes('Pay with Escrow (buyer protection)')) return res.sendStatus(200);

  const session = await escrow.checkoutSessions.create(
    {
      title: `Order ${order.name} from ${process.env.SHOP_NAME}`,
      amount: order.total_price,
      currency: order.currency,
      external_reference: String(order.id),
      customer_email: order.email,
      platform: 'shopify',
      line_items: order.line_items.map((li) => ({ name: li.title, quantity: li.quantity, unit_amount: li.price })),
      success_url: order.order_status_url,
    },
    { idempotencyKey: `shopify-order-${order.id}` },
  );
  await saveEscrowLink(order.id, session); // write metafield + trigger email
  res.sendStatus(200);
});

app.post('/escrow/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = constructEvent(req.body, req.get('X-Escrow-Signature'), process.env.SCE_WEBHOOK_SECRET);
  } catch {
    return res.sendStatus(400);
  }
  if (await alreadyProcessed(event.id)) return res.sendStatus(200);
  const obj = event.data.object;
  if (event.type === 'escrow.funded') await markShopifyOrderPaid(obj.external_reference);
  if (event.type === 'escrow.completed') await tagShopifyOrder(obj.external_reference, 'escrow-released');
  if (event.type === 'escrow.disputed') await tagShopifyOrder(obj.external_reference, 'escrow-dispute');
  await markProcessed(event.id);
  res.sendStatus(200);
});

saveEscrowLink, markShopifyOrderPaid (Admin GraphQL orderMarkAsPaid), tagShopifyOrder and the dedupe store are app-specific and omitted. Using the Shopify order ID as the idempotency key makes Shopify's own webhook retries safe.

Path to a native integration

  1. Apply to the Shopify Payments Partner program (requires the legal entity, compliance documentation, and PCI scope review).
  2. Build an offsite payments extension that creates a checkout session and resolves/rejects the Shopify payment session from our escrow.funded / checkout.session.expired events.
  3. Everything on our side (sessions, webhooks, idempotency) is already in place for that.