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-hookbrowser flow, which uses a key-pair sent in the body.
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;
}
URL casing into the browserMigo'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
| Case | Input | Expected |
|---|---|---|
| Happy path | amount = 125.50 | "Open payment link" button appears, opens https://sandbox.migopayments.com/?orderId=... |
| Zero / empty | amount = 0 or empty | Local validation message; no network call |
| Wrong token | revoke / mistype MIGO_MERCHANT_TOKEN in the Express.js env | Migo'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 client | mistype MIGO_CLIENT in the Express.js env | Error "Unknown Migo client slug" (ownCode 5004) |
| Out-of-range amount | amount = 99999999 | Error "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_TOKENin 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_URLtohttps://mw.migopayments.comand rotate to production credentials. Sandbox values do not work in production. - Validate
amountinside 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
orderIdshould not generate a new MigoURLif it already exists.
What next?
- Create a Payment Link — full reference (every optional field, both endpoints, all error codes)
- Sandbox cheat sheet (test cards, test amounts, common errors)
- Merchant Generic Callback (how to receive the result)
- Authentication → Payment Link merchant token