# 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.
