# Crear link de pago recurrente

Crea planes por suscripción (pagos recurrentes) en dólares con la API de vexorpay.

> [!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/create-recurring-payment-link>

Un link recurrente agrupa hasta **5 planes** (precios por ciclo) y permite cobrar automáticamente cada período. El comprador elige un plan en `url` y paga en el checkout de Polar; a partir de ahí Polar cobra la suscripción cada ciclo.

El link y sus planes se crean en **Polar**, el Merchant of Record: Polar cobra cada ciclo, calcula el impuesto y acredita cada renovación en tu saldo de Polar. Necesitas Polar conectado (si no, `400 account_not_connected`). Ver [Suscripciones](/docs/api/subscriptions).

## Request

`POST /api/v1/payment-links`

### Headers

| Header | Valor |
| --- | --- |
| `Authorization` | `Bearer vxp_live_tu_clave` |
| `Content-Type` | `application/json` |
| `Idempotency-Key` | opcional — para repetir sin duplicar |
| `Vexor-Account` | opcional — id del usuario conectado, para crear el link en su nombre ([cuentas conectadas](/docs/api/connected-accounts)) |

### Body

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `type` | string | **sí** | `"recurring"`. |
| `prices` | array | **sí** | Entre 1 y 5 planes. |
| `title` | string | no | Título del link. |
| `description` | string | no | Descripción. |
| `image_file_id` | string | no | Foto del producto (archivo tuyo subido desde el dashboard). |
| `reference` | string | no | Referencia tuya para reconciliar. |
| `metadata` | object | no | Pares clave/valor libres. |

#### Cada elemento de `prices`

| Campo | Tipo | Requerido | Descripción |
| --- | --- | --- | --- |
| `amount` | number | **sí** | Precio por ciclo en dólares. Mínimo USD 4, o `0` para un plan gratis de precio fijo (el comprador se suscribe sin tarjeta). |
| `interval` | string | **sí** | `day`, `week`, `month` o `year`. |
| `interval_count` | number | no | Cada N intervalos (entero 1–12, default 1). El intervalo `year` solo admite 1. |
| `trial_period_days` | number | no | Días de prueba gratis (1–90). |
| `description` | string | no | Nombre visible del plan ("Básico", "Pro", "Anual"…). |
| `pricing_type` | string | no | `flat` (default) o `per_seat`: el comprador elige cuántos asientos compra. |
| `per_seat_amount` | number | no | Precio de cada asiento en dólares (variante "base + extra"). |
| `min_seats` | integer | no | Asientos mínimos (default 1, máximo 1000). |
| `max_seats` | integer | no | Asientos máximos (sin límite por default). |
| `seat_tiers` | array | no | Matriz de tramos. Cada elemento: `up_to` (`null` = sin límite, solo en el último) y `unit_amount` o `flat_amount`, **en centavos** (a diferencia de `amount`, que va en dólares). |
| `tiers_mode` | string | no | `graduated` (cada tramo se suma) o `volume` (se aplica el tramo del total). Requiere `seat_tiers`. |

> Todos los planes deben ser en USD. El link en sí queda con `amountCents: 0`; el precio real vive en cada plan.

#### Planes por asientos

Con `pricing_type: "per_seat"` el plan admite tres formas de precio:

| Variante | Campos | Total para N asientos |
| --- | --- | --- |
| Mismo precio por asiento | solo `min_seats`/`max_seats` | `N × amount` |
| Base + precio por asiento extra | `amount` + `per_seat_amount` | `amount + (N − 1) × per_seat_amount` |
| Tramos por volumen | `seat_tiers` + `tiers_mode` | según la matriz (abajo) |

La matriz de `seat_tiers` arranca en el primer asiento y el último tramo siempre es
abierto (`up_to: null`). En `graduated` cada tramo se suma a los anteriores; en
`volume` la tasa del tramo alcanzado se aplica a **todos** los asientos. Máximo
10 tramos. Cada tramo lleva `unit_amount` (por asiento) o `flat_amount` (cargo
fijo del tramo), en centavos:

```json
{
  "amount": 0,
  "interval": "month",
  "pricing_type": "per_seat",
  "tiers_mode": "graduated",
  "seat_tiers": [
    { "up_to": 5,    "unit_amount": 900 },
    { "up_to": 20,   "unit_amount": 700 },
    { "up_to": null, "unit_amount": 500 }
  ]
}
```

Con esa matriz (graduated), 3 asientos cuestan USD 27, 10 cuestan USD 80 y 30 cuestan USD 200.

> La API rechaza con `validation_error` cualquier key desconocida dentro de un
> plan, así que un typo (por ejemplo `seats` en vez de `min_seats`) no se ignora en
> silencio: falla el request.

### Ejemplo

**curl**

```bash
curl https://api.vexorpay.com/api/v1/payment-links \
  -X POST \
  -H "Authorization: Bearer vxp_live_tu_clave" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "recurring",
    "title": "Membresía Vexor",
    "reference": "MEMBRESIA",
    "prices": [
      {
        "amount": 9,
        "interval": "month",
        "description": "Mensual"
      },
      {
        "amount": 90,
        "interval": "year",
        "trial_period_days": 7,
        "description": "Anual"
      }
    ]
  }'
```

**Node.js**

```js
const res = await fetch('https://api.vexorpay.com/api/v1/payment-links', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer vxp_live_tu_clave',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    type: 'recurring',
    title: 'Membresía Vexor',
    reference: 'MEMBRESIA',
    prices: [
      { amount: 9, interval: 'month', description: 'Mensual' },
      { amount: 90, interval: 'year', trial_period_days: 7, description: 'Anual' },
    ],
  }),
})

const data = await res.json()
```

**PHP**

```php
<?php

$client = curl_init('https://api.vexorpay.com/api/v1/payment-links');
curl_setopt_array($client, [
  CURLOPT_POST => true,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    'Authorization: Bearer vxp_live_tu_clave',
    'Content-Type: application/json',
  ],
  CURLOPT_POSTFIELDS => json_encode([
    'type' => 'recurring',
    'title' => 'Membresía Vexor',
    'reference' => 'MEMBRESIA',
    'prices' => [
      ['amount' => 9, 'interval' => 'month', 'description' => 'Mensual'],
      ['amount' => 90, 'interval' => 'year', 'trial_period_days' => 7, 'description' => 'Anual'],
    ],
  ]),
]);

$data = json_decode(curl_exec($client), true);
```

**Python**

```python

response = requests.post(
    'https://api.vexorpay.com/api/v1/payment-links',
    headers={
        'Authorization': 'Bearer vxp_live_tu_clave',
        'Content-Type': 'application/json',
    },
    json={
        'type': 'recurring',
        'title': 'Membresía Vexor',
        'reference': 'MEMBRESIA',
        'prices': [
            {'amount': 9, 'interval': 'month', 'description': 'Mensual'},
            {
                'amount': 90,
                'interval': 'year',
                'trial_period_days': 7,
                'description': 'Anual',
            },
        ],
    },
)
data = response.json()
```

**Go**

```go
body, _ := json.Marshal(map[string]any{
    "type":      "recurring",
    "title":     "Membresía Vexor",
    "reference": "MEMBRESIA",
    "prices": []map[string]any{
        {"amount": 9, "interval": "month", "description": "Mensual"},
        {"amount": 90, "interval": "year", "trial_period_days": 7, "description": "Anual"},
    },
})

req, _ := http.NewRequest("POST", "https://api.vexorpay.com/api/v1/payment-links", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer vxp_live_tu_clave")
req.Header.Set("Content-Type", "application/json")

res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
```

### Respuesta exitosa

`201 Created`

```json
{
  "id": "66f1c1e2c8a4b0d1f2e3a4b6",
  "amountCents": 0,
  "currency": "usd",
  "title": "Membresía Vexor",
  "description": null,
  "image": null,
  "reference": "MEMBRESIA",
  "metadata": null,
  "status": "active",
  "slug": "b2c3d4e5f6",
  "sandbox": false,
  "url": "https://www.vexorpay.com/p/b2c3d4e5f6",
  "checkoutUrl": "https://buy.polar.sh/polar_cl_...",
  "type": "recurring",
  "prices": [
    {
      "id": "66f1c1e2c8a4b0d1f2e3a4c1",
      "amountCents": 900,
      "currency": "usd",
      "interval": "month",
      "intervalCount": 1,
      "trialPeriodDays": null,
      "description": "Mensual",
      "isDefault": true
    },
    {
      "id": "66f1c1e2c8a4b0d1f2e3a4c2",
      "amountCents": 9000,
      "currency": "usd",
      "interval": "year",
      "intervalCount": 1,
      "trialPeriodDays": 7,
      "description": "Anual",
      "isDefault": false
    }
  ],
  "createdAt": "2026-01-02T12:00:00.000Z",
  "updatedAt": "2026-01-02T12:00:00.000Z"
}
```

`checkoutUrl` es el checkout de Polar del link. El comprador también puede elegir el plan desde `url`, o puedes llevarlo directo a un plan con [`POST /v1/payment-links/{id}/checkout`](/docs/api/checkout) y `price_id`. El **tipo** del link no se puede cambiar con `PATCH`; de los planes existentes se editan el precio, el nombre y la prueba gratis. Agregar o quitar planes, o cambiar el intervalo o los asientos, exige un link nuevo (ver [Listar y gestionar links](/docs/api/payment-links)).

## Errores

| Error | HTTP | Causa |
| --- | --- | --- |
| `unauthorized` | 401 | Falta el header `Authorization`. |
| `invalid_key` | 401 | La API key es inválida o fue revocada. |
| `prices_required` | 400 | `type: "recurring"` sin `prices[]`. |
| `too_many_prices` | 400 | Más de 5 planes. |
| `minimum_amount` | 400 | Algún plan es menor a USD 4. |
| `invalid_interval` | 400 | `interval` distinto de `day`, `week`, `month` o `year`. |
| `invalid_pricing_type` | 400 | `pricing_type` no es `flat` ni `per_seat`. |
| `invalid_seat_tiers` | 400 | Matriz `seat_tiers` inválida: no es una lista, está vacía, el primer tramo no arranca en el asiento 1, los tramos se pisan, o un plan de precio fijo define tramos. |
| `invalid_tiers_mode` | 400 | `tiers_mode` no es `graduated` ni `volume`, o se indicó sin `seat_tiers`. |
| `validation_error` | 400 | Otros datos inválidos (p. ej. moneda distinta de USD). |
| `invalid_interval_count` | 400 | `interval_count` fuera de 1–12 (o `year` ≠ 1). |
| `invalid_trial` | 400 | `trial_period_days` fuera de 1–90. |
| `account_not_connected` | 400 | No conectaste Polar (o la conexión se revocó): conéctalo desde el dashboard. |
| `unsupported_plan` | 400 | Un plan no se puede expresar en Polar (p. ej. una combinación de precio por asiento no soportada). |
| `polar_test_key_not_supported` | 400 | Una clave de prueba (`vxp_test_...`) no puede crear links en producción. Usa una clave de producción. |
| `polar_not_ready` | 409 | La organización de Polar todavía no puede cobrar: completá su verificación en Polar y reintentá. |
| `idempotency_conflict` | 409 | La `Idempotency-Key` ya se usó con un body distinto. |
| `polar_provisioning_failed` | 500 | Polar rechazó la creación del link. No quedó nada publicado: reintentá. |
| `rate_limited` | 429 | Superaste el límite de 100 requests por minuto. |
