In this tutorial, we'll build a complete credit card payment gateway using a Swell payment extension, with Razorpay as the example gateway. The same patterns apply to integrating any payment provider with Swell checkout.

The scope of the app includes the following:

  • A payment extension that adds a credit card payment method to Swell checkout.
  • App settings for the gateway API credentials.
  • App functions handling the payment lifecycle: intent creation, verification, charges, and refunds.
  • A client-side checkout component that opens the gateway's payment UI and tokenizes cards.

→ Find the full source code for the payment example app on GitHub.

To follow along, you'll need the Swell CLI, a Swell account, and a Razorpay account with test mode API keys. First, install the CLI and log in.

npm install -g @swell/cli

swell login

Next, clone the example app and push it to your test environment.

git clone git@github.com:swellstores/payment-example-app.git

cd payment-example-app

npm install

swell app push

Once installed, open Apps > Razorpay > Settings in your test environment dashboard and enter your Razorpay Key Id and Secret key. Use test mode keys while developing.

The heart of a gateway app is a payment extension declared in swell.json. Extensions tap into core platform features — in this case, payment processing.

{
  "description": "Razorpay payment gateway",
  "id": "razorpay",
  "name": "Razorpay",
  "type": "integration",
  "version": "1.0.0",
  "permissions": [],
  "extensions": [
    {
      "id": "card",
      "type": "payment",
      "description": "Razorpay credit card payment gateway"
    }
  ]
}

The extension has an id of card — each of the app's functions and components references this id in its configuration to associate itself with the extension. When the app is installed and the extension is enabled, Swell routes payment events for the gateway to those functions.

The merchant provides their gateway API credentials through app settings in settings/card.json.

{
  "label": "Card payment settings",
  "description": "Razorpay payment configuration settings",
  "fields": [
    {
      "id": "key_id",
      "label": "Key Id",
      "type": "text",
      "public": true
    },
    {
      "id": "key_secret",
      "label": "Secret key",
      "type": "text"
    }
  ]
}

Note the distinction: key_id is public: true because the checkout component needs it client-side to open the gateway's payment UI, while key_secret is private and only ever read by app functions on the server.

Functions retrieve the settings group with a small helper:

export async function getAppSettings(req: SwellRequest, configId = "card") {
  return req.swell.settings(`${req.appId}/${configId}`);
}

Payment extensions hook synchronously into Swell's payment processing. The platform calls the app's functions at each stage of the payment lifecycle, using four extension events:

  • payment.create_intent — called before payment to set up the gateway-side transaction (customer and order).
  • payment.get_intent — called after the customer completes the gateway's payment UI, to verify and retrieve the result.
  • payment.charge — called when a payment is created, for example when an order is submitted.
  • payment.refund — called when a payment is refunded.

Each handler is a regular app function whose configuration names the extension it belongs to. Let's walk through them in the order they occur during checkout.

import Razorpay from "./lib/razorpay";
import { getAppSettings } from "./lib/swell";

export const config: SwellConfig = {
  extension: "card",
  description: "Initiate a payment",
  model: {
    events: ["payment.create_intent"],
    conditions: {},
  },
};

export default async function (req: SwellRequest) {
  const { account, intent } = req.data;

  try {
    const settings = await getAppSettings(req);
    const razorpay = new Razorpay(settings.key_id, settings.key_secret);
    const phone =
      account.phone || account.billing?.phone || account.shipping?.phone;

    const customer = await razorpay.createCustomer({
      name: account.name,
      email: account.email,
      contact: phone,
      fail_existing: "0", // If a customer with the same details already exists, fetches details of the existing customer.
    });

    const order = await razorpay.createOrder({
      ...intent,
      customer_id: customer.id,
    });

    return {
      result: { customer, order },
    };
  } catch (error) {
    if (error instanceof Error) {
      return { error: error.message };
    }

    return { error: String(error) };
  }
}

Notice the extension: "card" property in the config — this ties the function to the payment extension. The handler receives the customer account and the intent parameters from checkout, creates the gateway-side customer and order, and returns them under result, which is passed back to the checkout component.

After the customer completes the gateway's payment UI, the platform calls payment.get_intent to verify the result. This is where you confirm the payment actually came from the gateway — in Razorpay's case, by checking an HMAC signature over the order and payment IDs.

import Razorpay from "./lib/razorpay";
import { getAppSettings } from "./lib/swell";

export const config: SwellConfig = {
  extension: "card",
  description: "Retrieve payment details",
  model: {
    events: ["payment.get_intent"],
    conditions: {},
  },
};

export default async function (req: SwellRequest) {
  const {
    razorpay_order_id: orderId,
    razorpay_payment_id: paymentId,
    razorpay_signature: signature,
  } = req.data.intent;

  try {
    const settings = await getAppSettings(req);
    const razorpay = new Razorpay(settings.key_id, settings.key_secret);

    await razorpay.verifySignature(orderId, paymentId, signature);

    const payment = await razorpay.getPayment(paymentId, "token");

    return {
      result: payment,
    };
  } catch (error) {
    if (error instanceof Error) {
      return { error: error.message };
    }

    return { error: String(error) };
  }
}

When an order is submitted, Swell triggers payment.charge. The handler locates the authorized gateway payment — or creates one from a saved card token for recurring payments — and captures it.

import Razorpay from "./lib/razorpay";
import { getAppSettings } from "./lib/swell";
import { toSubunits } from "../components/lib/razorpay";

export const config: SwellConfig = {
  extension: "card",
  description: "Charge a Razorpay card payment",
  model: {
    events: ["payment.charge"],
    conditions: {},
  },
};

async function createPayment(req: SwellRequest, razorpay: Razorpay) {
  const { account_id: accountId, card, amount, currency, captured } = req.data;

  const account = await req.swell.get(`/accounts/${accountId}`);
  const customer = await razorpay.getCustomerByEmail(account.email);

  if (!customer) {
    throw new Error("Razorpay customer not found");
  }

  const razorpayAmount = toSubunits(amount, currency);
  const order = await razorpay.createOrder({
    amount: razorpayAmount,
    currency,
    payment_capture: captured !== false,
    notification: {
      token_id: card.token,
    },
  });

  const { razorpay_payment_id: paymentId } =
    await razorpay.createRecurringPayment({
      email: account.email,
      contact: account.phone,
      amount: razorpayAmount,
      currency,
      order_id: order.id,
      customer_id: customer.id,
      token: card.token,
      recurring: true,
    });

  return razorpay.getPayment(paymentId);
}

function getPayment(req: SwellRequest, razorpay: Razorpay) {
  const { intent, transaction_id } = req.data;
  const paymentId = intent?.razorpay?.id || transaction_id;

  return paymentId
    ? razorpay.getPayment(paymentId)
    : createPayment(req, razorpay);
}

export default async function (req: SwellRequest) {
  const { amount, currency, captured } = req.data;

  try {
    const settings = await getAppSettings(req);
    const razorpay = new Razorpay(settings.key_id, settings.key_secret);
    const payment = await getPayment(req, razorpay);

    if (payment.status !== "authorized") {
      throw new Error(`Payment is not capturable (status: ${payment.status})`);
    }

    if (captured !== false) {
      await razorpay.capturePayment(payment.id, {
        amount: toSubunits(amount, currency),
        currency,
      });
    }

    return {
      success: true,
      transaction_id: payment.id,
    };
  } catch (error) {
    return {
      success: false,
      error: {
        message: error instanceof Error ? error.message : String(error),
      },
    };
  }
}

The handler covers both checkout and recurring flows:

  • If the payment has a checkout intent or an existing transaction id, retrieve that payment from the gateway.
  • Otherwise — for example, a subscription renewal — create a new payment from the customer's saved card token.
  • Capture the payment, unless Swell requested an authorize-only charge with captured: false.

Charge and refund handlers return success and transaction_id — or success: false with an error message — which Swell records on the payment. See the Extensions guide for the full response contract.

import Razorpay from "./lib/razorpay";
import { getAppSettings } from "./lib/swell";
import { toSubunits } from "../components/lib/razorpay";

export const config: SwellConfig = {
  extension: "card",
  description: "Refund a Razorpay card payment",
  model: {
    events: ["payment.refund"],
    conditions: {},
  },
};

export default async function (req: SwellRequest) {
  const { amount, currency, transaction_id } = req.data;

  try {
    const settings = await getAppSettings(req);
    const razorpay = new Razorpay(settings.key_id, settings.key_secret);
    const refund = await razorpay.refundPayment(transaction_id, {
      amount: toSubunits(amount, currency),
    });

    return {
      success: true,
      transaction_id: refund.id,
    };
  } catch (error) {
    return {
      success: false,
      error: {
        message: error instanceof Error ? error.message : String(error),
      },
    };
  }
}

The refund handler uses the transaction_id recorded by the charge handler to refund the gateway payment, supporting partial refunds via the amount.

So far everything has been server-side. The last piece is the client: a component in the app's components/ folder that runs in checkout, opens the gateway's payment UI, and writes the tokenized card back to the cart. Components are written with Preact and are pushed with the rest of the app configuration.

import { useCallback, useEffect } from "preact/hooks";
import { memo } from "preact/compat";
import { getIntentData, getRazorpayOptions } from "./lib/razorpay";

declare const window: Window & { Razorpay: any };

const RAZORPAY_SCRIPT_ID = "razorpay";
const RAZORPAY_SDK_URL = "https://checkout.razorpay.com/v1/checkout.js";

export const config: SwellConfig = {
  extension: "card",
  description: "Razorpay client side integration",
};

function Razorpay({
  settings,
  loadLib,
  registerHandlers,
  getIntent,
  createIntent,
  updateCart,
  onReady,
}: SwellData) {
  const onSubmit = useCallback(
    async (cart: Record<string, any>) => {
      const intentData = getIntentData(cart);
      const { order, customer } = await createIntent(intentData);

      const paymentDetails = await new Promise<Record<string, any>>(
        (resolve, reject) => {
          const razorpayOptions = getRazorpayOptions(
            order,
            customer,
            settings,
            resolve,
            reject
          );
          const razorpay = new window.Razorpay(razorpayOptions);

          razorpay.open();
        }
      );

      const payment = await getIntent(paymentDetails);
      const { token } = payment;

      if (!token?.card) {
        throw new Error("Payment is not tokenized.");
      }

      const { card } = token;

      return updateCart({
        billing: {
          card: {
            token: token.id,
            last4: card.last4,
            brand: card.network,
            exp_month: card.expiry_month,
            exp_year: card.expiry_year,
          },
          intent: {
            razorpay: {
              id: payment.id,
            },
          },
        },
      });
    },
    [settings, getIntent, createIntent, updateCart]
  );

  useEffect(() => {
    registerHandlers({
      onSubmit,
    });
    loadLib(RAZORPAY_SCRIPT_ID, RAZORPAY_SDK_URL).then(onReady);
  }, [registerHandlers, loadLib, onReady, onSubmit]);

  return null;
}

export default memo(Razorpay);

Like the functions, the component's config names the extension with extension: "card". At checkout, Swell renders the component and passes it props for interacting with the platform:

  • settings — the app's public setting values (like key_id).
  • loadLib — loads the gateway's browser SDK by URL.
  • registerHandlers — registers the onSubmit handler that runs when the customer submits payment.
  • createIntent and getIntent — call the app's payment.create_intent and payment.get_intent functions.
  • updateCart — saves the tokenized card and intent reference on the cart's billing details.

The onSubmit flow ties the whole lifecycle together: build intent parameters from the cart, create the intent, open the gateway UI, verify the completed payment, and store the card token on the cart. When the order is submitted, the payment.charge handler picks it up from there.

App functions run in the Cloudflare Workers runtime, so gateway SDKs that depend on Node.js APIs may not work. Instead, the app implements a small REST client using fetch and verifies signatures with the Web Crypto API:

const API_URL = "https://api.razorpay.com/v1";

async function hmacSha256(message: string, secret: string) {
  const enc = new TextEncoder();

  const key = await crypto.subtle.importKey(
    "raw",
    enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  );

  const signature = await crypto.subtle.sign("HMAC", key, enc.encode(message));

  return Array.from(new Uint8Array(signature))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

export default class Razorpay {
  // ...

  async request(path: string, method: string = "GET", body?: Record<string, any> | null) {
    const headers = new Headers();
    headers.set("Authorization", "Basic " + btoa(`${this.#keyId}:${this.#keySecret}`));
    headers.set("Content-Type", "application/json");

    const response = await fetch(`${API_URL}${path}`, {
      method,
      headers,
      body: body ? JSON.stringify(body) : null,
    });

    if (!response.ok) {
      const errorText = await response.text();
      throw new Error(
        `Razorpay API Error: ${response.status} ${response.statusText} - ${errorText}`
      );
    }

    return response.json();
  }

  async verifySignature(orderId: string, paymentId: string, signature: string) {
    const message = `${orderId}|${paymentId}`;
    const generatedSignature = await hmacSha256(message, this.#keySecret);

    if (generatedSignature !== signature) {
      throw new Error("Invalid payment signature");
    }
  }
}

One more detail worth noting: gateways typically expect amounts in minor units (cents, paise). The toSubunits helper in components/lib/razorpay.ts converts Swell's decimal amounts using a per-currency factor — remember that some currencies, like JPY, have no minor unit.

With the app pushed and test mode credentials configured in settings:

  • Place an order in your test storefront checkout using the gateway's test card numbers, and confirm the payment is recorded on the order with a transaction id.
  • Issue a refund from the order in the dashboard, and confirm the refund appears in the gateway's test dashboard.
  • Watch function invocations and errors under Developer > Console (Logs tab), or with the swell logs CLI command.

You now have the full shape of a payment gateway app: an extension declaration, lifecycle event handlers, a checkout component, and a gateway client. To adapt it to another provider, replace the gateway API client and checkout UI while keeping the same extension events and response contracts. For the complete extension configuration and event reference, see the Extensions guide.

The payment example app is meant as a reference for partners to learn and build from, but it is not ready for production. Review it carefully and test against your gateway's sandbox environment before using any of it in a live store.