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
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"
- 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."
- Bridge app (a small custom/private app — Node example below) subscribes to
orders/createandfulfillments/create, and exposes an escrow webhook receiver. - 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.
- via an order-status-page / thank-you Checkout UI extension that reads a metafield
the bridge writes (
- On
escrow.funded, call the Admin GraphQLorderMarkAsPaidmutation. - 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)
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
- Apply to the Shopify Payments Partner program (requires the legal entity, compliance documentation, and PCI scope review).
- Build an offsite payments extension that creates a checkout session and resolves/rejects
the Shopify payment session from our
escrow.funded/checkout.session.expiredevents. - Everything on our side (sessions, webhooks, idempotency) is already in place for that.