# payhub — Guía de integración
> Documento para desarrolladores y agentes de IA que integran un proyecto con
> payhub, el servicio central de pagos. Léelo completo antes de escribir código.
> Version legible por máquina: `GET https://payhub.papaya.com.pe/docs.md`
## Qué es payhub
payhub centraliza las pasarelas de pago (Stripe, MercadoPago, Culqi, Niubiz,
Izipay, Paddle, Lemon Squeezy, dLocal, billetera Yape/Plin). Tu proyecto NO
integra ninguna pasarela directamente. Solo hace dos cosas:
1. **Crear checkouts**: `POST https://payhub.papaya.com.pe/checkout` → recibe una `redirect_url`
y redirige al usuario a pagar.
2. **Recibir eventos**: expone UN endpoint de webhook que recibe eventos
unificados firmados (el mismo formato para todas las pasarelas).
Reembolsos, impuestos y disputas se gestionan en el dashboard de cada pasarela,
no via payhub.
## Variables de entorno del proyecto
| Variable | Qué es |
|---|---|
| `PAYHUB_URL` | Base del servicio, ej. `https://pagos.tudominio.com` |
| `PAYHUB_API_KEY` | API key del proyecto (`ph_...`). SOLO servidor, nunca al navegador |
| `PAYHUB_CALLBACK_SECRET` | Secreto (`whsec_...`) para verificar la firma de los eventos |
Se instalan con el comando de setup que genera el panel de payhub
(botón "Generar instalador" en la página del proyecto), que también copia el
cliente TypeScript a `src/lib/server/payhub.ts`.
## Cliente TypeScript (proyectos Node/SvelteKit/Astro)
```ts
import { createPayhub } from '$lib/server/payhub';
import { env } from '$env/dynamic/private';
const payhub = createPayhub({
baseUrl: env.PAYHUB_URL,
apiKey: env.PAYHUB_API_KEY,
callbackSecret: env.PAYHUB_CALLBACK_SECRET,
});
// 1) Iniciar un pago (en un action / endpoint de servidor):
const { checkout_id, redirect_url } = await payhub.createCheckout({
plan: 'pro-mensual', // código del plan definido en el panel
customer: { external_id: user.id, email: user.email },
success_url: `${origin}/gracias`,
cancel_url: `${origin}/precios`, // opcional
});
// redirigir al usuario a redirect_url (HTTP 303)
// 2) Recibir eventos — src/routes/api/payhub/webhook/+server.ts:
export const POST = async ({ request }) => {
const event = await payhub.receiveWebhook(request); // verifica la firma
if (!event) return new Response('firma invalida', { status: 401 });
// procesar event (ver catálogo abajo) — idempotente por event.id
return new Response('ok'); // responder 2xx rápido
};
```
## API REST (cualquier lenguaje)
### Crear checkout
```
POST https://payhub.papaya.com.pe/checkout
X-Api-Key: {PAYHUB_API_KEY}
Content-Type: application/json
{
"plan": "pro-mensual",
"gateway": "stripe", // OPCIONAL: solo si el plan existe en varias pasarelas
"customer": {
"external_id": "user_42", // OBLIGATORIO: id del usuario en TU sistema
"email": "a@b.com",
"name": "Ana Perez", // opcional
"phone": "999888777", // opcional
"document": "12345678" // opcional (DNI, pasarelas peruanas)
},
"success_url": "https://tuapp.com/gracias", // OBLIGATORIO, http(s)
"cancel_url": "https://tuapp.com/precios" // opcional
}
→ 201 { "checkout_id": "<uuid>", "redirect_url": "https://..." }
```
Redirige al usuario a `redirect_url`. El resultado llega DESPUÉS por webhook —
nunca asumas que volver a `success_url` significa pago confirmado; espera el
evento (o consulta el estado).
### Consultar estado de un checkout
```
GET https://payhub.papaya.com.pe/checkouts/{checkout_id}
X-Api-Key: {PAYHUB_API_KEY}
→ 200 { "checkout_id": "...", "gateway": "...", "plan": "...", "status": "created|charging|completed" }
```
Útil en la página de éxito mientras llega el webhook.
## Webhook: contrato del endpoint del proyecto
payhub envía `POST {callback_url}` (la URL registrada en el panel) con:
**Headers**
- `X-PayHub-Signature: t=<unix_ts>,v1=<hmac_hex>` — HMAC-SHA256 de `"<ts>.<body>"`
con `PAYHUB_CALLBACK_SECRET`. Verificar SIEMPRE con comparación timing-safe y
ventana anti-replay (300s recomendado). Rechazar con 401 si falla.
- `X-PayHub-Event-Id`, `X-PayHub-Event-Type` — informativos.
**Body** (JSON, el "envelope"):
```json
{
"id": "9b2e6c1e-...",
"type": "subscription.activated",
"project": "miproyecto",
"gateway": "stripe",
"occurred_at": "2026-07-27T15:04:05Z",
"received_at": "2026-07-27T15:04:06Z",
"data": {
"checkout_id": "uuid del checkout",
"customer_external_id": "user_42",
"customer_email": "a@b.com",
"plan_code": "pro-mensual",
"gateway_ref": "id de la transaccion en la pasarela",
"subscription_ref": "id de la suscripcion en la pasarela",
"amount_cents": 2990,
"currency": "PEN",
"current_period_end": "2026-08-27T00:00:00Z",
"reason": "detalle en fallos/cancelaciones"
}
}
```
Todos los campos de `data` son opcionales según el tipo de evento.
**Reglas que el proyecto DEBE cumplir:**
1. Responder 2xx en pocos segundos (procesa asíncrono si es pesado). Si no hay
2xx, payhub reintenta con backoff: 1m, 5m, 30m, 2h, 6h, 24h×3 y luego lo
marca muerto (re-encolable desde el panel).
2. **Deduplicar por `id`**: es estable entre reintentos. Guarda los ids
procesados y responde 2xx sin re-procesar los repetidos.
3. **Tolerar desorden**: los eventos son hints; puede llegar un
`payment.succeeded` antes que su `subscription.activated`. El estado más
reciente manda.
4. Correlacionar por `customer_external_id` (tu id de usuario) o
`checkout_id`. En renovaciones de suscripción usa `subscription_ref`.
## Catálogo de eventos
| type | Cuándo | data relevante |
|---|---|---|
| `payment.succeeded` | Pago único cobrado, o cobro de un ciclo de suscripción | `amount_cents`, `currency`, `checkout_id`, `subscription_ref` (si es de suscripción) |
| `payment.failed` | Pago único rechazado/expirado | `reason` |
| `payment.pending` | Pago en verificación (billetera Yape/Plin, MP pendiente) | `gateway_ref` |
| `subscription.activated` | Suscripción activa (alta o reactivación). SIN monto: el dinero lo reporta `payment.succeeded` | `subscription_ref`, `current_period_end`, `plan_code` |
| `subscription.updated` | Cambio de plan/estado no terminal | `reason` |
| `subscription.canceled` | Cancelada (puede seguir activa hasta `current_period_end`) | `current_period_end` |
| `subscription.payment_failed` | Cobro de renovación falló | `reason` |
| `subscription.expired` | Terminó definitivamente | — |
| `refund.completed` | Reembolso hecho (desde el dashboard de la pasarela) | `amount_cents`, `gateway_ref` |
Lógica típica: `subscription.activated` → activar acceso · `payment.succeeded`
→ registrar cobro/acreditar · `subscription.canceled|expired` → programar/quitar
acceso · `subscription.payment_failed` → avisar al usuario · `payment.pending`
→ mostrar "en verificación".
## Pasarelas y planes
Un **plan** (definido en el panel de payhub) mapea un código tuyo (ej.
`pro-mensual`) a una pasarela. Dos familias:
- **Precio en el dashboard de la pasarela** (lemonsqueezy, paddle, stripe): el
plan lleva el ID del precio (`variant`, `pri_...`, `price_...`).
- **Precio en payhub** (mercadopago, culqi, niubiz, izipay, dlocal, billetera):
el plan lleva monto y moneda.
El proyecto NUNCA sabe cuál pasarela hay detrás: siempre llama con el código de
plan y recibe los mismos eventos. Cambiar de pasarela = editar el plan en el
panel, cero cambios de código.
Notas por pasarela (transparentes para el proyecto):
- `billetera` (Yape/Plin manual): el pagador registra su nro. de operación →
llega `payment.pending`; cuando el operador confirma en el panel llega
`payment.succeeded`.
- `niubiz`, `izipay`, `culqi`: la página de pago la sirve payhub
(`/pay/{checkout_id}`) — para el proyecto es una `redirect_url` normal.
En estos flujos embebidos un rechazo de tarjeta se muestra inline al pagador
(que puede reintentar ahí mismo) y NO genera evento `payment.failed`; el
proyecto solo recibe el `payment.succeeded` cuando finalmente paga.
## Errores comunes
- `401` en `/checkout`: falta o es inválida `X-Api-Key`.
- `400 plan "x" existe en varias pasarelas`: agrega `"gateway"` al request.
- `400 ... amount_cents`: el plan no tiene monto configurado en el panel.
- Firma inválida en tu webhook: estás verificando sobre el body parseado —
usa SIEMPRE el body CRUDO (raw bytes) tal como llegó.
- No llegan eventos: revisa que la `callback_url` del proyecto sea alcanzable
desde payhub y que respondas 2xx; revisa "Entregas" en el panel.