BoomPay/docs

Shopify

Two real paths, not one: a manual-payment-method workaround any store can use today, and the formal Offsite Payments Extension for stores that qualify for it.

Node.jsManual method: any planOffsite extension: Plus / Partners only

Shopify doesn’t have an open "drop in any gateway" extension point the way WooCommerce or Magento do. There are exactly two ways to get BoomPay in front of a Shopify customer, and they have very different access requirements — worth understanding before you build either one.

Path Access What it looks like
Manual payment method + app Any plan, today, no approval needed Customer finishes checkout normally, then gets a BoomPay link by email; the order starts as unpaid and your app marks it paid once BoomPay confirms.
Offsite Payments Extension Gated — Shopify Plus merchants and approved Payments Partners only (confirmed against shopify.dev as of this writing) Customer pays BoomPay without leaving Shopify’s native checkout flow, same as any built-in gateway.
!

If you’re not on Shopify Plus or an approved Partner, skip straight to the manual-method path below — the Offsite extension section further down will 404 against your own app dashboard until you have that access.

Path A — manual payment method (works on any plan)

01

Create the method

In Settings -> Payments -> Manual payment methods, add “Pay with Boomcoin (BoomPay)” with instructions telling the customer to expect a payment email.

02

Order is created

Shopify finalizes the order immediately, unpaid, and fires an orders/create webhook to your app.

03

App creates the intent

Your Express app verifies Shopify’s own webhook signature, then calls createIntent() and emails the link.

04

Customer pays, signed return

BoomPay redirects to your /boompay/return route with paymentIntentId and X-Boom-Signature.

05

Order marked paid

Your app verifies the signature and posts an Order Transaction marking the order captured.

i

This route handles two different signed callbacks that are easy to conflate: Shopify’s own webhook HMAC (proving the order event really came from Shopify, verified with your app’s webhook secret) and BoomPay’s return signature (proving the payment really came from BoomPay, verified with your API key). They use different secrets and different mechanisms — don’t reuse one check for the other.

server.js
const express = require('express');
const BoomPay = require('boom-pay-sdk');

const app = express();

const boomPay = new BoomPay({
  apiKey: process.env.BOOMPAY_API_KEY,
  sandbox: process.env.NODE_ENV !== 'production',
});

app.set('boomPay', boomPay);
app.use('/shopify', require('./routes/shopify-webhook')(boomPay));
app.use('/boompay', require('./routes/boompay-return')(boomPay));

app.listen(process.env.PORT || 3000);
routes/shopify-webhook.js
const express = require('express');
const crypto = require('crypto');

// Name must exactly match the manual payment method you create in
// Settings -> Payments -> Manual payment methods.
const BOOMPAY_GATEWAY_NAME = 'Pay with Boomcoin (BoomPay)';

module.exports = function (boomPay) {
  const router = express.Router();

  // Shopify's webhook HMAC needs the raw, unparsed body -- keep this
  // separate from any express.json() middleware used elsewhere in the app.
  router.post(
    '/webhooks/orders-create',
    express.raw({ type: 'application/json' }),
    async (req, res) => {
      const digest = crypto
        .createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)
        .update(req.body)
        .digest('base64');

      const valid = crypto.timingSafeEqual(
        Buffer.from(digest),
        Buffer.from(req.get('X-Shopify-Hmac-Sha256') || '', 'utf8')
      );
      // NOTE: this verifies Shopify's own webhook signature -- a different
      // scheme, with a different secret, from BoomPay's return signature
      // below. Don't conflate the two.
      if (!valid) return res.status(401).end();

      const order = JSON.parse(req.body.toString('utf8'));
      if (order.gateway !== BOOMPAY_GATEWAY_NAME) {
        return res.status(200).end(); // not a BoomPay order, ignore
      }

      const intent = await boomPay.payments.createIntent({
        amount: toBmc(order.current_total_price, order.currency),
        successUrl: `${process.env.APP_URL}/boompay/return?status=success&order_id=${order.id}`,
        failureUrl: `${process.env.APP_URL}/boompay/return?status=failure&order_id=${order.id}`,
        label: `Shopify Order ${order.name}`,
        metadata: { shopifyOrderId: order.id },
      });

      // Manual payment methods finalize the order immediately, with no
      // mid-checkout redirect available -- so the link has to reach the
      // customer some other way. Emailing it is the most broadly reliable
      // option that needs no checkout-extensibility approval.
      await emailPaymentLink(order.email, intent.link, order.name);

      res.status(200).end();
    }
  );

  return router;
};

function toBmc(fiatTotal, currency) {
  // BoomPay has no FX endpoint (see API Reference > Currency) -- source your
  // own conversion rate before going live.
  return Number(fiatTotal);
}

async function emailPaymentLink(email, link, orderName) {
  // Wire up to your transactional email provider of choice.
}
routes/boompay-return.js
const express = require('express');

module.exports = function (boomPay) {
  const router = express.Router();

  // boomPay.webhooks() validates paymentIntentId + X-Boom-Signature on the
  // query string before this handler ever runs.
  router.get('/return', boomPay.webhooks(), async (req, res) => {
    const orderId = req.query.order_id;
    const status = req.query.status;
    const payment = await boomPay.payments.getPayment(req.query.paymentIntentId);

    if (status === 'success' && payment.paidAt) {
      await markShopifyOrderPaid(orderId, payment);
      return res.redirect(`https://${process.env.SHOP_DOMAIN}/pages/payment-confirmed`);
    }
    return res.redirect(`https://${process.env.SHOP_DOMAIN}/pages/payment-failed`);
  });

  return router;
};

// Marks a manual-payment-method order as paid via the Order Transactions
// API. Requires a custom app (Settings -> Apps -> Develop apps) with
// write_orders scope, or an equivalent OAuth access token for a public app.
async function markShopifyOrderPaid(orderId, payment) {
  const url = `https://${process.env.SHOP_DOMAIN}/admin/api/2025-01/orders/${orderId}/transactions.json`;
  await fetch(url, {
    method: 'POST',
    headers: {
      'X-Shopify-Access-Token': process.env.SHOPIFY_ADMIN_TOKEN,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      transaction: {
        kind: 'capture',
        amount: payment.amount,
        gateway: 'boompay',
        source: 'external',
      },
    }),
  });
}

Register the custom app under Settings → Apps and sales channels → Develop apps with read_orders and write_orders Admin API scopes, subscribe it to the orders/create webhook topic pointed at /shopify/webhooks/orders-create, and grab the generated Admin API access token for SHOPIFY_ADMIN_TOKEN.

Path B — Offsite Payments Extension (Plus / approved Partners)

If you do have payments-app access, this is the better experience: the customer never leaves Shopify’s checkout. Shopify’s contract for an offsite extension is simple — it POSTs order and customer details to a payment session URL you configure, and expects a 2xx response containing a redirect_url under 8192 bytes. That maps almost exactly onto BoomPay’s own link field.

routes/payment-session.js
const express = require('express');
const router = express.Router();

// Configured as the "payment session URL" in your app's Offsite Payments
// Extension settings. Shopify POSTs here when a buyer pays with BoomPay at
// native checkout. Requires Shopify Plus / approved Partner access -- see
// the callout above before building against this.
router.post('/payment-session', async (req, res) => {
  const boomPay = req.app.get('boomPay');
  const session = req.body; // order + customer details, per Shopify's payload

  try {
    const intent = await boomPay.payments.createIntent({
      amount: Number(session.payment_method.amount),
      successUrl: `${process.env.APP_URL}/shopify/offsite-return?session=${session.id}`,
      failureUrl: `${process.env.APP_URL}/shopify/offsite-return?session=${session.id}&failed=1`,
      label: `Shopify order ${session.order_id}`,
      metadata: { shopifySessionId: session.id },
    });

    // Required, required shape: a 2xx response with redirect_url under 8192 bytes.
    // This only *starts* the Shopify payment session -- see offsite-return.js
    // below for the step that actually finalizes it.
    res.status(200).json({ redirect_url: intent.link });
  } catch (err) {
    res.status(422).json({ error: 'boompay_intent_failed' });
  }
});

module.exports = router;
!

That 2xx response only starts the Shopify-side payment session — it doesn’t finish it. Once BoomPay’s outcome is known, your app has to separately call Shopify’s paymentSessionResolve or paymentSessionReject GraphQL mutation, or the session sits open indefinitely even though BoomPay has already settled the payment on its own side. It’s an easy step to miss, since none of it lives in the file that returns redirect_url.

routes/offsite-return.js
// routes/offsite-return.js
// BoomPay redirects the customer's browser here after they pay. The
// redirect_url returned from payment-session.js only starts the Shopify
// payment session -- this route is what actually finalizes it, by calling
// back into Shopify's own GraphQL API.
module.exports = function (boomPay) {
  const express = require('express');
  const router = express.Router();

  const SHOPIFY_GRAPHQL = `https://${process.env.SHOP_DOMAIN}/admin/api/2025-01/graphql.json`;

  router.get('/offsite-return', boomPay.webhooks(), async (req, res) => {
    const payment = await boomPay.payments.getPayment(req.query.paymentIntentId);
    const shopifySessionId = payment.metadata && payment.metadata.shopifySessionId;

    // The exact mutation arguments and id format belong to Shopify's
    // Payments Apps GraphQL schema -- confirm those against Shopify's
    // current API reference rather than copying this verbatim. What matters
    // here is that this call is mandatory, not optional: skip it and the
    // payment session sits open on Shopify's side indefinitely, even though
    // BoomPay has already settled the payment on its own.
    const mutation = payment.paidAt
      ? `mutation { paymentSessionResolve(id: "${shopifySessionId}") { paymentSession { id } userErrors { message } } }`
      : `mutation { paymentSessionReject(id: "${shopifySessionId}", reason: PROCESSING_ERROR) { paymentSession { id } userErrors { message } } }`;

    await fetch(SHOPIFY_GRAPHQL, {
      method: 'POST',
      headers: { 'X-Shopify-Access-Token': process.env.SHOPIFY_ADMIN_TOKEN, 'Content-Type': 'application/json' },
      body: JSON.stringify({ query: mutation }),
    });

    res.redirect(payment.paidAt ? '/checkout/thank-you' : '/checkout');
  });

  return router;
};

What’s still genuinely out of scope here: refund, capture, and void session endpoints, a checkout UI extension, and Partner-dashboard configuration — scaffolded for you by Shopify’s payments app template (shopify app init --template=payments). Everything above is the part that actually talks to BoomPay and finalizes the session; wire the rest following Shopify’s payments app docs.

Go-live checklist

  • For Path A: confirm the manual method’s name in your webhook handler exactly matches what you typed into the Shopify admin — the match is a plain string comparison.
  • Implement toBmc() with a real conversion rate before launch.
  • Switch sandbox to false and swap in a live API key once a full test order has gone through.
  • For Path B, confirm your payment-session endpoint responds well within Shopify’s retry window — slow responses are retried and can create duplicate intents if you’re not idempotent on the session id.