Quickstart de React — Payment Link
Receta de extremo a extremo para agregar un formulario "generar payment link" a una app de React. El usuario escribe un monto, la app llama a Migo, y un botón muestra la URL resultante. Todo lo que sigue funciona contra Sandbox.
[ Amount input ] → POST /transactions → { uid, reference, URL }
[ Generate ] │
▼
[ Open payment link → URL ]
Cuándo usar esta receta
- Estás construyendo una single-page app y quieres emitir un Payment Link desde un formulario.
- Puedes hospedar un backend pequeño en Express.js (o cualquier equivalente: Next.js Route Handlers, tu servicio de API existente). Enrutar la llamada a través de tu propio backend es altamente recomendado para esta receta — consulta el recuadro a continuación.
- Si genuinamente no puedes correr ningún backend, cámbiate al flujo de navegador
POST /transactions-hook, que usa un par de llaves enviado en el cuerpo.
La razón principal es la higiene de secretos: POST /transactions se autentica con tu merchant token de larga vida, que nunca debe viajar en código del navegador. Un backend mantiene ese token del lado servidor. Ambos endpoints de /transactions están configurados con CORS habilitado del lado del servicio, así que el endpoint en sí no bloquea las llamadas del navegador — pero si tu despliegue de producción está detrás del reverse proxy mw.migopayments.com y ese proxy elimina los headers CORS, un fetch desde el navegador también fallaría el preflight. El servicio de Express.js del paso 3 es ese backend — es dueño del merchant token, valida el input, y es donde después agregarías persistencia de órdenes, observabilidad e idempotencia.
1. Estructura del proyecto (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. Variables de entorno
# .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
El token vive solo dentro del backend de Express.js. El navegador solo ve /api (las rutas propias de tu backend).
3. Backend de Express.js obligatorio
Mantén el merchant token fuera del navegador — llama a POST /transactions desde tu propio backend. El servicio de Express.js a continuación es tu propio backend — es dueño del merchant token, expone una ruta con sabor a dominio al navegador (POST /api/orders/payment-link), valida el monto, y es la pieza de la arquitectura que habla con Migo. Trátalo como un backend real, no un proxy passthrough: aquí es donde van la persistencia de órdenes, la idempotencia y la observabilidad en una configuración de producción.
// 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'));
Configura vite.config.ts para reenviar /api/* a http://localhost:8787 durante el desarrollo:
// 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. Tipos (navegador ↔ tu backend)
El navegador habla con tu backend de Express.js, no con Migo. Tipa el contrato en consecuencia:
// 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 de Migo al navegadorLa respuesta de Migo usa un campo URL en mayúsculas. Normalízalo dentro de tu backend de Express.js (el snippet del paso 3 ya lo hace con paymentLink: body.URL). La app de React nunca debería ver URL. Olvidar esto y escribir const { url } = response en el navegador obtendrá undefined silenciosamente.
5. Wrapper del cliente
// 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. Componente del formulario
// 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. Lista de verificación de smoke-test
| Caso | Input | Esperado |
|---|---|---|
| Camino feliz | amount = 125.50 | Aparece el botón "Open payment link", abre https://sandbox.migopayments.com/?orderId=... |
| Cero / vacío | amount = 0 o vacío | Mensaje de validación local; sin llamada de red |
| Token equivocado | revoca / escribe mal MIGO_MERCHANT_TOKEN en el env de Express.js | El authorizer del API Gateway de Migo rechaza el token con un 401 genérico { "message": "Unauthorized" } (sin ownCode). Tu backend mapea el 401 upstream a un error de credenciales de cara al usuario. Nota que ownCode 2007 solo lo retorna /transactions-hook, no /transactions. |
| Cliente equivocado | escribe mal MIGO_CLIENT en el env de Express.js | Error "Unknown Migo client slug" (ownCode 5004) |
| Monto fuera de rango | amount = 99999999 | Error "Amount is outside the configured range" (ownCode 2003) |
Cuando el cliente completa el webview, tu backend recibe un Merchant Generic Callback — conéctalo a tu sistema de órdenes para cerrar el ciclo.
8. Endurecimiento para producción
- Nunca envíes
MIGO_MERCHANT_TOKENen código del navegador. Mantenlo dentro del proceso de Express.js. - Promueve el servicio de Express.js solo-de-desarrollo a tu backend real (o una función serverless) con los secretos cargados desde tu secret store.
- Cambia
MIGO_BASE_URLahttps://mw.migopayments.comy rota a credenciales de producción. Los valores de Sandbox no funcionan en producción. - Valida
amountdentro del handler de Express.js — nunca confíes en el número del navegador. - Persiste la orden a la salida (
orderId,amount,migoUid,status: 'PENDING') para poder emparejar el eventual Merchant Generic Callback con la orden que produjo el link. - Agrega idempotencia: el mismo
orderIdno debería generar una nuevaURLde Migo si ya existe.
¿Qué sigue?
- Crear un Payment Link — referencia completa (cada campo opcional, ambos endpoints, todos los códigos de error)
- Cheat sheet de Sandbox (tarjetas de prueba, montos de prueba, errores comunes)
- Merchant Generic Callback (cómo recibir el resultado)
- Autenticación → Merchant token de Payment Link