Shopify India · approved app or manual workflow

Shopify UPI integration without unsupported checkout hacks

Choose the legitimate Shopify path first. Approved payments partners can build payment apps; other merchants can automate a clearly labelled manual UPI order flow from an app backend.

Step 1

Choose a Shopify-supported path

Path A · payments partner

Approved payments app

Use this only after Shopify grants access to its payments platform. Implement the payment-session lifecycle, review requirements, support, compliance, and testing exactly as Shopify documents.

Shopify payments-app review →

Path B · merchant workflow

Manual UPI plus verified server-side reconciliation

Configure a manual payment method named accurately, receive the order on an app backend, send a hosted UPI link through an allowed customer channel, and mark the order paid only after server verification.

Shopify manual-payment guidance →
Do not inject an API key into a theme, replace Shopify checkout, mislabel a manual method as an approved gateway, access admin pages through unauthorised programmatic means, or ask customers to send payment screenshots as proof.

Step 2

Verify the Shopify order webhook

Subscribe the app to the applicable order event. Capture the raw request bytes before `express.json()`, validate Shopify’s base64 HMAC header, deduplicate the webhook ID, and queue the slow payment work before returning.

import crypto from "node:crypto";
import express from "express";

const app = express();

app.post(
  "/webhooks/shopify/orders-create",
  express.raw({ type: "application/json", limit: "512kb" }),
  async (req, res) => {
    const rawBody = req.body as Buffer;
    const received = req.header("x-shopify-hmac-sha256") ?? "";
    const expected = crypto
      .createHmac("sha256", mustGetEnv("SHOPIFY_CLIENT_SECRET"))
      .update(rawBody)
      .digest("base64");

    const receivedBytes = Buffer.from(received, "base64");
    const expectedBytes = Buffer.from(expected, "base64");
    const valid =
      receivedBytes.length === expectedBytes.length &&
      crypto.timingSafeEqual(receivedBytes, expectedBytes);

    if (!valid) return res.status(401).send("Invalid Shopify HMAC");

    const order = JSON.parse(rawBody.toString("utf8")) as ShopifyOrderWebhook;

    // Only automate the explicitly configured manual UPI method.
    if (!order.payment_gateway_names.includes("Manual UPI")) {
      return res.status(204).send();
    }

    await enqueueShopifyUpiOrder({
      shopDomain: req.header("x-shopify-shop-domain") ?? "",
      webhookId: req.header("x-shopify-webhook-id") ?? "",
      order,
    });

    return res.status(204).send();
  },
);

Step 3

Create and persist the mapped UPI order

Read the trusted total from the authenticated Shopify webhook. Persist a one-to-one mapping before making the external call, reuse the same `client_txn_id` after a timeout, and send the resulting hosted URL using a channel permitted by the merchant’s Shopify and communications setup.

async function createUpiForShopifyOrder(
  order: ShopifyOrderWebhook,
) {
  const amountPaise = decimalToPaise(order.current_total_price);
  const clientTxnId = `SHOP-${order.id}`;

  // insertOrLoadPaymentMapping has UNIQUE constraints on:
  // (shop_domain, shopify_order_id), client_txn_id, gateway_order_id
  const mapping = await insertOrLoadPaymentMapping({
    shopifyOrderId: String(order.admin_graphql_api_id),
    clientTxnId,
    amountPaise,
    currency: order.currency,
  });

  if (mapping.paymentUrl) return mapping;
  if (order.currency !== "INR") throw new Error("UPI order must be INR");

  const response = await fetch(
    `${mustGetEnv("VG_BASE_URL")}/api/v1/create_order`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-API-Key": mustGetEnv("VG_API_KEY"),
      },
      body: JSON.stringify({
        client_txn_id: clientTxnId,
        amount: paiseToRupees(amountPaise),
        p_info: `Shopify order ${order.name}`,
        customer_name:
          [order.customer?.first_name, order.customer?.last_name]
            .filter(Boolean)
            .join(" "),
        customer_email: order.email,
        customer_mobile: order.phone,
        redirect_url:
          `${mustGetEnv("APP_PUBLIC_URL")}/shopify/orders/${order.id}`,
        callback_url:
          `${mustGetEnv("APP_PUBLIC_URL")}/webhooks/vyapargateway`,
      }),
      signal: AbortSignal.timeout(10_000),
    },
  );

  const result = await response.json() as CreateOrderResponse;
  if (!response.ok || !result.status) {
    throw new Error("Unable to create UPI payment order");
  }
  if (
    result.data.client_txn_id !== clientTxnId ||
    result.data.currency !== "INR"
  ) {
    throw new Error("Gateway order mismatch");
  }

  return saveGatewayMapping(mapping.id, {
    gatewayOrderId: result.data.order_id,
    paymentUrl: result.data.payment_url,
    expiresAt: result.data.expires_at,
  });
}

Steps 4–5

Verify payment, then mark the mapped order paid

Verify the VyaparGateway `timestamp.rawBody` HMAC before parsing. In one database transaction, match gateway order ID, Shopify mapping, INR amount, and final success status, then insert a deduplication key.

After that commit, call Shopify’s `orderMarkAsPaid` mutation with the version configured for your app. The access token requires the applicable order scope. Treat `userErrors` and top-level GraphQL errors as failures.

const MARK_PAID = `#graphql
  mutation MarkOrderPaid($input: OrderMarkAsPaidInput!) {
    orderMarkAsPaid(input: $input) {
      order {
        id
        displayFinancialStatus
      }
      userErrors {
        field
        message
      }
    }
  }
`;

async function markShopifyOrderPaid(mapping: PaymentMapping) {
  const response = await fetch(
    `https://${mapping.shopDomain}/admin/api/${mustGetEnv(
      "SHOPIFY_API_VERSION",
    )}/graphql.json`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Shopify-Access-Token": await loadShopAccessToken(
          mapping.shopDomain,
        ),
      },
      body: JSON.stringify({
        query: MARK_PAID,
        variables: {
          input: { id: mapping.shopifyOrderGid },
        },
      }),
      signal: AbortSignal.timeout(10_000),
    },
  );

  const payload = await response.json() as MarkPaidResponse;
  const errors = payload.data?.orderMarkAsPaid?.userErrors ?? [];
  if (!response.ok || payload.errors?.length || errors.length) {
    throw new Error(
      errors.map((error) => error.message).join("; ") ||
      "Shopify orderMarkAsPaid failed",
    );
  }

  return payload.data.orderMarkAsPaid.order;
}
Shopify orderMarkAsPaid reference →

Launch controls

Test the races and policy boundaries

01

Method filter

Only automate orders that selected the exact manual UPI method.

02

Webhook replay

Deduplicate Shopify webhook IDs and gateway payment events independently.

03

Amount lock

Compare integer paise from Shopify with the verified gateway amount.

04

Customer expiry

Provide a clear way to retry an expired UPI payment without duplicating the Shopify order.

05

Refund boundary

Document whether refunds are initiated in Shopify, the provider, or a separate operations workflow.

06

Paid race

Handle an already-paid or cancelled Shopify order without forcing a second transition.

07

App removal

Stop creating payment links and revoke tokens when the app/uninstalled webhook is verified.

08

Billing check

Confirm Shopify plan and third-party transaction-fee treatment before launch.

Questions

Shopify UPI integration FAQ

Can any Shopify app become a payment provider?
No. Shopify's Payments Apps API and payment extensions are restricted to approved payments partners. Access requires eligibility, invitation or approval, review, and compliance with Shopify's current payments requirements.
Can a Shopify theme securely call VyaparGateway create_order?
No. Theme and storefront JavaScript are public. The X-API-Key must remain on an app backend that validates the Shopify order and creates the payment order server-side.
What is the manual-payment verification path?
Configure a transparent manual UPI method, receive the applicable Shopify order webhook on an authenticated app backend, create a linked UPI payment order, deliver the hosted payment URL through an allowed customer channel, and mark the Shopify order paid only after the gateway event is verified.
Can orderMarkAsPaid be called after a browser redirect?
It should not be. A browser redirect is not proof of payment. Call the mutation only after a verified gateway webhook or authenticated status response matches the mapped Shopify order and exact amount.
Do Shopify third-party transaction fees apply?
Shopify can apply third-party transaction fees according to the merchant's plan, payment-provider setup, and current billing rules. Manual payment methods are treated differently in Shopify's documentation. Verify the current plan and billing page before selecting a workflow.

Keep Shopify’s order and payment rules explicit.

Build only on APIs and extension points your app is authorised to use.

View all integrations