# Webhooks salientes

Recibe notificaciones firmadas cuando ocurren eventos de negocio en tu cuenta.

> [!WARNING]
> El SDK `vexor` de npm (Stripe, MercadoPago, PayPal, Square, Talo) está **deprecado**: ya no se mantiene y no debe usarse. vexorpay es hoy una **API REST de links de pago en USD sobre Polar (Merchant of Record), sin SDK oficial**. Migración: <https://developers.vexorpay.com/docs/migracion-desde-el-sdk>

Fuente: <https://developers.whop.vexorpay.com/docs/api/webhooks>

vexorpay te notifica por HTTP cuando pasan eventos de negocio en tu cuenta. Configuras los webhooks desde **Configuración → Webhooks** en el dashboard: creas un endpoint con la URL de tu servidor, eliges a qué eventos suscribirte y vexorpay genera un **secreto de firma** (mostrado una sola vez).

## Eventos

| Evento | Cuándo ocurre |
| --- | --- |
| `payment.completed` | Polar cobró una venta y la acreditó en tu saldo de Polar. |
| `payment.failed` | Un pago falló. |
| `payment.refunded` | Una venta fue reembolsada. |
| `payment.disputed` | Una venta fue disputada (chargeback). |
| `dispute.resolved` | Una disputa se resolvió (ganada o perdida). |
| `payout.paid` | Un retiro de Polar se acreditó en tu cuenta de cobro (hecho desde vexorpay o directamente en Polar). |
| `subscription.created` | Se creó una suscripción nueva sobre un link recurrente. |
| `subscription.payment.completed` | Polar cobró un ciclo de la suscripción. |
| `subscription.payment.failed` | Falló el cobro de un ciclo de la suscripción. |
| `subscription.canceled` | Se canceló una suscripción. |
| `subscription.trial_will_end` | Está por terminar el período de prueba de una suscripción. |

Puedes suscribir cada endpoint a todos o a un subconjunto de estos eventos.

## Entrega

- Cada evento se envía por `POST` al endpoint, con `Content-Type: application/json`.
- Sin confirmación `2xx`, vexorpay reintenta con backoff (~5 min, ~30 min, ~3 h) hasta 4 intentos y lo marca como fallido. En `Configuración → Webhooks` ves el log de entregas (intentos, estado, último error).
- Un mismo evento enviado a varios endpoints y en varios intentos comparte el **mismo `evt_`** (id idempotente para el receptor).

## Cuerpo del evento

```json
{
  "id": "evt_6c2314a4fb4a408c07c2",
  "type": "payment.completed",
  "created": "2026-01-02T12:05:00.000Z",
  "sandbox": false,
  "data": {
    "charge": {
      "id": "67f1c1e2c8a4b0d1f2e3a4b5",
      "provider": "polar",
      "reference": "ORD-1001",
      "metadata": null,
      "polarOrderId": "59f4a98b-7034-437d-92b7-f463758ba69f",
      "polarCheckoutId": "8150f7d1-6404-48c2-8797-842e71a500ad",
      "amountCents": 1900,
      "currency": "usd",
      "status": "paid",
      "sandbox": false,
      "paidAt": "2026-01-02T12:05:00.000Z"
    },
    "payment_link": {
      "id": "66f1c1e2c8a4b0d1f2e3a4b5",
      "slug": "a1b2c3d4e5",
      "title": "Ebook UX en 30 días",
      "reference": "CATALOGO-UX"
    }
  }
}
```

`data.charge` es la venta de Polar: su `id` es el de [`GET /api/v1/orders/{id}`](/docs/api/orders), `amountCents` es el total que pagó el comprador y `reference` es **tu** referencia de esa venta (la que mandaste con `?reference=` en el link o en el checkout por API; ver [Identificar cada venta](/docs/api/orders#identificar-cada-venta-con-tu-referencia)). La referencia del link va en `payment_link.reference`. `sandbox` viene a nivel raíz y, para `payment.*` y `payout.paid`, también dentro del objeto. Los eventos de venta agregan bloques extra según el caso:

- `payment.refunded` → `data.refund` (`id`, `amountCents`).
- `payment.disputed` → `data.dispute` (`status`, `reason`, `evidenceDeadline`, `amountCents`).
- `dispute.resolved` → `data.dispute` (`status`: `won`/`lost`, `amountCents`, `recoveredCents`).

Las suscripciones (`subscription.*`) traen `"provider": "polar"` en `data.subscription`. `payout.paid` se emite también por los retiros que haces directamente en Polar (se detectan cada 10 minutos); `data.payout` trae `"provider": "polar"`, `source` (`vexorpay` o `polar`) y el monto acreditado en tu moneda (`accountCurrency`, `accountAmount`). Ver [Retiros](/docs/api/payouts).

## Eventos de suscripción

Los eventos `subscription.*` traen el bloque `subscription` y `payment_link`:

```json
{
  "id": "evt_9a1b2c3d4e5f6a7b8c9d",
  "type": "subscription.payment.completed",
  "created": "2026-02-02T12:05:00.000Z",
  "sandbox": false,
  "data": {
    "subscription": {
      "id": "68f1c1e2c8a4b0d1f2e3a4b5",
      "provider": "polar",
      "status": "active",
      "amountCents": 900,
      "currency": "usd",
      "interval": "month",
      "intervalCount": 1,
      "cancelAtPeriodEnd": false,
      "customerEmail": "cliente@mail.com",
      "sandbox": false,
      "createdAt": "2026-01-02T12:00:00.000Z"
    },
    "payment_link": {
      "id": "66f1c1e2c8a4b0d1f2e3a4b7",
      "slug": "b2c3d4e5f6",
      "title": "Membresía mensual",
      "reference": null
    },
    "charge": {
      "id": "67f1c1e2c8a4b0d1f2e3a4c9",
      "amountCents": 900,
      "currency": "usd",
      "status": "paid",
      "paidAt": "2026-02-02T12:05:00.000Z",
      "sandbox": false
    }
  }
}
```

`subscription.payment.failed` agrega `data.payment_failure` (`amountCents`, `nextPaymentAttempt`). Para `payout.paid` el `data` contiene `payout` en vez de `charge`/`payment_link`.

## Verificación de firma

Cada request incluye:

| Header | Valor |
| --- | --- |
| `vexorpay-Signature` | HMAC-SHA256 del **cuerpo crudo** (exactamente como llega) con tu secreto, en hex. |
| `vexorpay-Event-Id` | Id del evento (`evt_...`), igual al `id` del body. |

### Ejemplo

**Node.js**

```js

async function verify(req, secret) {
  const raw = await text(req)              // cuerpo crudo, SIN parsear
  const sig = req.headers['vexorpay-signature']
  const expected = createHmac('sha256', secret).update(raw).digest('hex')
  const a = Buffer.from(sig ?? '', 'hex')
  const b = Buffer.from(expected, 'hex')
  const ok = a.length === b.length && timingSafeEqual(a, b)
  if (ok) req.event = JSON.parse(raw)      // recién acá parseás
}

// Uso:
// 1) verificás la firma
// 2) deduplicás por body.id (guardás el evt_ procesado)
// 3) recién entonces procesás y respondés 2xx
```

**PHP**

```php
<?php

// $secret es el secreto de firma de tu endpoint (nunca lo expongas).
function verify_signature(string $raw, string $sig, string $secret): bool {
  $expected = hash_hmac('sha256', $raw, $secret);
  return hash_equals($expected, $sig); // comparación en tiempo constante
}

// Uso:
// 1) leés el cuerpo crudo (file_get_contents('php://input'))
// 2) comparás con el header 'vexorpay-Signature'
// 3) recién entonces parseás y procesás el evento
```

**Python**

```python

def verify_signature(raw_body: bytes, signature: str, secret: bytes) -> bool:
    expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
```

**Go**

```go

    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
)

func verifySignature(raw []byte, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(raw)
    expected := hex.EncodeToString(mac.Sum(nil))
    return subtle.ConstantTimeCompare([]byte(expected), []byte(signature)) == 1
}
```

- Nunca confíes en un POST sin firma válida.
- Compara en tiempo constante (`timingSafeEqual`).
- Parsea el body solo después de verificar; el hash usa el body crudo.

## Verificación a demanda (reconciliación)

Si perdiste un webhook, puedes consultar el mismo evento en el histórico:

```
GET /v1/events
```

Cada evento del histórico tiene el **mismo `evt_`** que se envía por webhook (mismos `id`, `type` y `data`) → puedes deduplicar y re-sincronizar lo que faltó.

## Rotación y seguridad

- El secreto solo se muestra **una vez** al crearlo; si lo pierdes, rótalo desde el dashboard (el endpoint deja de funcionar hasta que actualices tu verificación).
- Puedes pausar un endpoint sin borrarlo (no recibe eventos mientras está pausado).
- Guarda el secreto como variable de entorno, nunca en el frontend ni en repositorios.
