Skip to main content
Migo Docs

React Quickstart — Payment Link

End-to-end recipe to add a "generate payment link" form to a React app. The user types an amount, the app calls Migo, and a button surfaces the resulting URL. Everything below works against Sandbox.

[ Amount input ] → POST /transactions → { uid, reference, URL }
[ Generate ] │

[ Open payment link → URL ]

When to use this recipe

  • You are building a single-page app and want to issue a Payment Link from a form.
  • You can host a small backend in Express.js (or any equivalent: Next.js Route Handlers, your existing API service). Routing the call through your own backend is strongly recommended for this recipe — see the box below.
  • If you genuinely cannot run any backend, switch to the POST /transactions-hook browser flow, which uses a key-pair sent in the body.
Why your own backend is recommended

The main reason is secret hygiene: POST /transactions authenticates with your long-lived merchant token, which must never ship in browser code. A backend keeps that token server-side. Both /transactions endpoints are configured with CORS enabled at the service level, so the endpoint itself does not block browser calls — but if your production deployment sits behind the mw.migopayments.com reverse proxy and that proxy strips CORS headers, a fetch from the browser would also fail the preflight. The Express.js service in step 3 is that backend — it owns the merchant token, validates input, and is where you would later add order persistence, observability, and idempotency.

1. Project layout (Vite + React + TypeScript)

migo-paylink-demo/
├── .env.local # MIGO_BASE_URL, MIGO_CLIENT, MIGO_MERCHANT_TOKEN
├── server/
│ └── proxy.ts # Tiny Express proxy that forwards to Migo
├── src/
│ ├── App.tsx
│ ├── components/
│ │ └── PaymentLinkForm.tsx
│ ├── lib/
│ │ └── migo.ts # createPaymentLink(amount)
│ └── types/
│ └── migo.ts # CreatePaymentLinkRequest, PaymentLinkResponse
└── vite.config.ts

2. Environment variables

# .env.local

# --- Express.js backend only (NEVER prefixed with VITE_) ---
MIGO_BASE_URL=https://sb-mw.migopayments.com
MIGO_CLIENT=<your-client-slug>
MIGO_MERCHANT_TOKEN=<your-sandbox-token>

# --- Browser-safe values ---
VITE_API_BASE=/api

The token lives only inside the Express.js backend. The browser only sees /api (your backend's own routes).

3. Mandatory Express.js backend

Keep the merchant token out of the browser — call POST /transactions from your own backend. The Express.js service below is your own backend — it owns the merchant token, exposes a domain-flavored route to the browser (POST /api/orders/payment-link), validates the amount, and is the piece in the architecture that talks to Migo. Treat it as a real backend, not a passthrough proxy: this is where order persistence, idempotency, and observability go in a production setup.

// server/index.ts — Express.js backend
import express from 'express';
import { randomUUID } from 'node:crypto';

const app = express();
app.use(express.json({ limit: '64kb' }));

const { MIGO_BASE_URL, MIGO_CLIENT, MIGO_MERCHANT_TOKEN } = process.env;
if (!MIGO_BASE_URL || !MIGO_CLIENT || !MIGO_MERCHANT_TOKEN) {
throw new Error('Missing Migo backend env vars');
}

app.post('/api/orders/payment-link', async (req, res) => {
const amount = Number(req.body?.amount);
if (!Number.isFinite(amount) || amount <= 0) {
return res.status(400).json({ message: 'Amount must be > 0', code: 'INVALID_AMOUNT' });
}

const orderId = randomUUID();

const upstream = await fetch(`${MIGO_BASE_URL}/transactions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Raw token — no `Bearer` prefix.
Authorization: MIGO_MERCHANT_TOKEN,
},
body: JSON.stringify({
amount,
userId: '+50224865444', // pull from your session in real code
channel: 'web',
client: MIGO_CLIENT,
currency: 'GTQ',
externalId: orderId,
ads: [],
}),
});

const body = await upstream.json().catch(() => ({}));
if (!upstream.ok || !body.URL) {
return res.status(502).json({ message: body.message ?? 'Migo error', code: body.ownCode });
}

// In production: persist { orderId, amount, migoUid: body.uid, status: 'PENDING' }
return res.status(201).json({
orderId,
paymentLink: body.URL, // normalize Migo's uppercase `URL` here
reference: body.reference,
});
});

app.listen(8787, () => console.log('Express backend listening on :8787'));

Wire vite.config.ts to forward /api/* to http://localhost:8787 during development:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [react()],
server: {
proxy: { '/api': 'http://localhost:8787' },
},
});

4. Types (browser ↔ your backend)

The browser talks to your Express.js backend, not to Migo. Type the contract accordingly:

// src/types/api.ts
export interface CreatePaymentLinkOrderRequest {
amount: number;
}

export interface PaymentLinkOrderResponse {
orderId: string;
paymentLink: string; // your backend already normalized Migo's uppercase URL
reference: string;
}

export interface ApiErrorResponse {
message: string;
code?: string | number;
}
Don't leak Migo's URL casing into the browser

Migo's response uses an uppercase URL field. Normalize it inside your Express.js backend (the snippet in step 3 already does this with paymentLink: body.URL). The React app should never see URL. Forgetting this and writing const { url } = response in the browser will silently get undefined.

5. Client wrapper

// src/lib/api.ts
import type {
CreatePaymentLinkOrderRequest,
PaymentLinkOrderResponse,
ApiErrorResponse,
} from '../types/api';

const apiBase = import.meta.env.VITE_API_BASE; // "/api"

export async function createPaymentLinkOrder(amount: number): Promise<PaymentLinkOrderResponse> {
const res = await fetch(`${apiBase}/orders/payment-link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount } satisfies CreatePaymentLinkOrderRequest),
});

if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as ApiErrorResponse;
throw new Error(humanize(err) ?? `API error ${res.status}`);
}

return (await res.json()) as PaymentLinkOrderResponse;
}

function humanize(err: ApiErrorResponse): string | null {
// Your backend translated Migo ownCodes into domain codes already.
switch (err.code) {
case 'INVALID_AMOUNT': return 'Enter a positive amount';
case 2003: return 'Amount is outside the configured range';
case 2007: return 'Invalid Migo credentials — check the backend environment';
case 5000: return 'Missing or invalid request field';
case 5004: return 'Unknown Migo client slug';
default: return err.message ?? null;
}
}

6. Form component

// src/components/PaymentLinkForm.tsx
import { useState, type FormEvent } from 'react';
import { createPaymentLinkOrder } from '../lib/api';

export function PaymentLinkForm() {
const [amount, setAmount] = useState('');
const [link, setLink] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setLink(null);

const numericAmount = Number(amount);
if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
setError('Enter an amount greater than 0');
return;
}

setLoading(true);
try {
const order = await createPaymentLinkOrder(numericAmount);
setLink(order.paymentLink);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}

return (
<section>
<h1>Generate payment link</h1>
<form onSubmit={handleSubmit}>
<label>
Amount
<input
type="number"
step="0.01"
min="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={loading}
required
/>
</label>
<button type="submit" disabled={loading}>
{loading ? 'Generating…' : 'Generate payment link'}
</button>
</form>

{error && <p role="alert">{error}</p>}

{link && (
<a href={link} target="_blank" rel="noopener noreferrer">
<button type="button">Open payment link</button>
</a>
)}
</section>
);
}

7. Smoke-test checklist

CaseInputExpected
Happy pathamount = 125.50"Open payment link" button appears, opens https://sandbox.migopayments.com/?orderId=...
Zero / emptyamount = 0 or emptyLocal validation message; no network call
Wrong tokenrevoke / mistype MIGO_MERCHANT_TOKEN in the Express.js envMigo's API Gateway authorizer rejects the token with a generic 401 { "message": "Unauthorized" } (no ownCode). Your backend maps the upstream 401 to a user-facing credentials error. Note ownCode 2007 is returned only by /transactions-hook, not by /transactions.
Wrong clientmistype MIGO_CLIENT in the Express.js envError "Unknown Migo client slug" (ownCode 5004)
Out-of-range amountamount = 99999999Error "Amount is outside the configured range" (ownCode 2003)

When the customer completes the webview, your backend receives a Merchant Generic Callback — wire that into your order system to close the loop.

8. Production hardening

  • Never ship MIGO_MERCHANT_TOKEN in browser code. Keep it inside the Express.js process.
  • Promote the dev-only Express.js service to your real backend (or a serverless function) with secrets loaded from your secret store.
  • Switch MIGO_BASE_URL to https://mw.migopayments.com and rotate to production credentials. Sandbox values do not work in production.
  • Validate amount inside the Express.js handler — never trust the browser's number.
  • Persist the order on the way out (orderId, amount, migoUid, status: 'PENDING') so you can match the eventual Merchant Generic Callback to the order that produced the link.
  • Add idempotency: the same orderId should not generate a new Migo URL if it already exists.

What next?