# Build a payment gateway

Source: https://developers.swell.is/apps/build-a-payment-gateway-tutorial

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.

## Getting started

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.

```txt
npm install -g @swell/cli

swell login
```

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

```txt
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.

## App configuration

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.

`swell.json`

```json
{
  "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.

## Gateway credentials

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

`settings/card.json`

```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:

`functions/lib/swell.ts`

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

## The payment lifecycle

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.

### Creating a payment intent

`functions/card-intent-create.ts`

```typescript
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.

### Verifying the payment

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.

`functions/card-intent-get.ts`

```typescript
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) };
  }
}
```

### Charging the payment

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.

`functions/card-charge.ts`

```typescript
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.

### Refunding a payment

`functions/card-refund.ts`

```typescript
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.

## The checkout component

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.

`components/Razorpay.tsx`

```typescript
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.

## The gateway API client

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:

`functions/lib/razorpay.ts`

```typescript
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.

## Testing

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.

## Next steps

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.
