Saltar al contenido principal
Migo Docs

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.
Por qué se recomienda tu propio backend

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;
}
No filtres el casing URL de Migo al navegador

La 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

CasoInputEsperado
Camino felizamount = 125.50Aparece el botón "Open payment link", abre https://sandbox.migopayments.com/?orderId=...
Cero / vacíoamount = 0 o vacíoMensaje de validación local; sin llamada de red
Token equivocadorevoca / escribe mal MIGO_MERCHANT_TOKEN en el env de Express.jsEl 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 equivocadoescribe mal MIGO_CLIENT en el env de Express.jsError "Unknown Migo client slug" (ownCode 5004)
Monto fuera de rangoamount = 99999999Error "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_TOKEN en 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_URL a https://mw.migopayments.com y rota a credenciales de producción. Los valores de Sandbox no funcionan en producción.
  • Valida amount dentro 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 orderId no debería generar una nueva URL de Migo si ya existe.

¿Qué sigue?