---
title: "Crea una notificación verificada de ventas recientes con Polar y Astro"
description: "Muestre actividad de compra real en su sitio de marketing listando pedidos recientes de Polar del lado del servidor, almacenando respuestas en caché en el edge y renderizando una notificación verificada ligera en el navegador, sin pop-ups falsos."
source: "https://www.launchfa.st/es/blog/polar-verified-latest-sale-badge-astro"
author: "Rishi Raj Jain"
created_at: 2026-04-05T12:00:00.000Z
keywords: "astro, polar, polar.sh, verified notification, social proof, payments, api routes, vercel, serverless, typescript"
---

> **Site index for agents**
> Fetch https://www.launchfa.st/llms.txt to discover every page on this site.
> Every page is also available as Markdown by appending `.md` to its URL.

<img width="1600" height="900" alt="Crea una notificación verificada de ventas recientes con Polar y Astro" decoding="async" loading="eager" class="mt-4 border rounded-sm bg-cover bg-center bg-no-repeat transform will-change-auto" src="https://ik.imagekit.io/vjeqenuhn/launchfast-website/polar-verified-latest-sale-badge-astro.png" />

«Alguien en **Alemania** acaba de comprar **Pro** hace 2 horas.» Esa frase aparece en casi todas las landing pages de SaaS y, a menudo, está completamente inventada. Un enfoque mejor es mostrar pedidos **reales** de su proveedor de pagos. Si vende con **[Polar](https://polar.sh/)**, puede leer pedidos recientes a través de la API oficial, exponer un pequeño endpoint JSON desde su aplicación Astro y dejar que el cliente renderice un toast discreto con la etiqueta **Verificado** para que los visitantes sepan que la señal proviene de su checkout, no de un generador de números aleatorios.

En esta guía, conectará el [Polar SDK](https://github.com/polarsource/polar-js) a una ruta de servidor de Astro, lo desplegará en Vercel y añadirá un componente de notificación flotante que rota entre ventas recientes. El patrón coincide con el que usamos en este sitio: secretos solo en el servidor, cabeceras de caché compatibles con el edge y un script de cliente mínimo.

## Tabla de contenidos

- [Cómo encajan las piezas](#how-the-pieces-fit-together)
- [Crear un token de acceso de Polar](#create-a-polar-access-token)
- [Configurar Astro con el adaptador de Vercel](#scaffold-astro-with-the-vercel-adapter)
- [Implementar la ruta API](#implement-the-api-route)
- [Variables de entorno localmente y en Vercel](#environment-variables-locally-and-on-vercel)
- [Añadir la notificación flotante de venta reciente](#add-the-floating-recent-sale-notification)

## Requisitos previos

Necesitará:

- [Node.js 20](https://nodejs.org/en/blog/announcements/v20-release-announce) o posterior
- Una cuenta de [Polar](https://polar.sh/) con al menos un producto y (para demos significativas) algunos pedidos de prueba o reales
- Una cuenta de [Vercel](https://vercel.com/) si desea desplegar allí

## Cómo encajan las piezas

```
1. Navegador
   → GET /api/sale (su ruta Astro)
   → Recibe JSON: nombre del producto, marca de tiempo ISO, etiqueta opcional de país de facturación

2. Ruta API de Astro (Node / Vercel Function)
   → Usa POLAR_ACCESS_TOKEN con Polar SDK
   → Lista pedidos (más recientes primero), los mapea a una estructura pública pequeña
   → Establece Cache-Control para caché en CDN

3. API de Polar
   → Lista autoritativa de pedidos y líneas de artículo
```

La insignia **Verificado** en la interfaz comunica que la carga útil se **obtuvo de Polar en el servidor** en el momento de la solicitud (o proviene de una respuesta en caché generada de esa forma). Mantenga el token de acceso fuera de los bundles del cliente y de los prefijos de entorno.

## Crear un token de acceso de Polar

1. Abra el [panel de Polar](https://polar.sh/) e inicie sesión.
2. Vaya a **Settings** de su organización y busque **Developers** (el nombre puede variar ligeramente).
3. Cree un token con permiso **orders:read**.
4. Copie el token una vez y guárdelo de forma segura; lo añadirá a Vercel y al `.env` local como `POLAR_ACCESS_TOKEN`. (¡Nunca suba este valor a git!)

## Configurar Astro con el adaptador de Vercel

Cree un proyecto nuevo (o use uno existente con `output: 'server'`):

```bash
npm create astro@latest polar-sale-badge
cd polar-sale-badge
```

Añada el adaptador de Vercel para que las rutas API se ejecuten como funciones serverless al desplegar:

```bash
npx astro add vercel --yes
```

Asegúrese de que `astro.config.mjs` use salida **server**. Una configuración mínima se ve así:

```mjs
import { defineConfig } from 'astro/config'
import vercel from '@astrojs/vercel'

export default defineConfig({
  output: 'server',
  adapter: vercel(),
})
```

Instale el Polar SDK:

```bash
npm install @polar-sh/sdk
```

## Implementar la ruta API

Añada `src/pages/api/sale.ts`. Los detalles importantes:

1. **`export const prerender = false`** para que Astro no intente pre-renderizar esta URL estáticamente en tiempo de compilación.
2. Instancie **`Polar`** con `import.meta.env.POLAR_ACCESS_TOKEN`.
3. Llame a **`polar.orders.list`** con ordenación **más recientes primero** y un **limit** pequeño, luego mapee cada elemento solo a lo que la interfaz necesita (nombre del producto, createdAt, país de facturación opcional).
4. Devuelva **`Cache-Control`** para que la CDN de Vercel pueda almacenar el JSON en caché durante un día permitiendo revalidación (`max-age=0, s-maxage=86400` es un punto de partida razonable).

```ts title="src/pages/api/sale.ts" collapse={0,7-248}
export const prerender = false

import { Polar } from '@polar-sh/sdk'

const countryCodeToName: { [key: string]: string } = {
  AF: 'Afghanistan',
  AL: 'Albania',
  DZ: 'Algeria',
  AS: 'American Samoa',
  AD: 'Andorra',
  AO: 'Angola',
  AI: 'Anguilla',
  AQ: 'Antarctica',
  AG: 'Antigua and Barbuda',
  AR: 'Argentina',
  AM: 'Armenia',
  AW: 'Aruba',
  AU: 'Australia',
  AT: 'Austria',
  AZ: 'Azerbaijan',
  BS: 'Bahamas',
  BH: 'Bahrain',
  BD: 'Bangladesh',
  BB: 'Barbados',
  BY: 'Belarus',
  BE: 'Belgium',
  BZ: 'Belize',
  BJ: 'Benin',
  BM: 'Bermuda',
  BT: 'Bhutan',
  BO: 'Bolivia',
  BA: 'Bosnia and Herzegovina',
  BW: 'Botswana',
  BV: 'Bouvet Island',
  BR: 'Brazil',
  IO: 'British Indian Ocean Territory',
  BN: 'Brunei Darussalam',
  BG: 'Bulgaria',
  BF: 'Burkina Faso',
  BI: 'Burundi',
  KH: 'Cambodia',
  CM: 'Cameroon',
  CA: 'Canada',
  CV: 'Cape Verde',
  KY: 'Cayman Islands',
  CF: 'Central African Republic',
  TD: 'Chad',
  CL: 'Chile',
  CN: 'China',
  CX: 'Christmas Island',
  CC: 'Cocos (Keeling) Islands',
  CO: 'Colombia',
  KM: 'Comoros',
  CG: 'Congo',
  CD: 'Congo, Democratic Republic of the',
  CK: 'Cook Islands',
  CR: 'Costa Rica',
  CI: "Côte d'Ivoire",
  HR: 'Croatia',
  CU: 'Cuba',
  CY: 'Cyprus',
  CZ: 'Czech Republic',
  DK: 'Denmark',
  DJ: 'Djibouti',
  DM: 'Dominica',
  DO: 'Dominican Republic',
  EC: 'Ecuador',
  EG: 'Egypt',
  SV: 'El Salvador',
  GQ: 'Equatorial Guinea',
  ER: 'Eritrea',
  EE: 'Estonia',
  ET: 'Ethiopia',
  FK: 'Falkland Islands (Malvinas)',
  FO: 'Faroe Islands',
  FJ: 'Fiji',
  FI: 'Finland',
  FR: 'France',
  GF: 'French Guiana',
  PF: 'French Polynesia',
  TF: 'French Southern Territories',
  GA: 'Gabon',
  GM: 'Gambia',
  GE: 'Georgia',
  DE: 'Germany',
  GH: 'Ghana',
  GI: 'Gibraltar',
  GR: 'Greece',
  GL: 'Greenland',
  GD: 'Grenada',
  GP: 'Guadeloupe',
  GU: 'Guam',
  GT: 'Guatemala',
  GG: 'Guernsey',
  GN: 'Guinea',
  GW: 'Guinea-Bissau',
  GY: 'Guyana',
  HT: 'Haiti',
  HM: 'Heard Island and McDonald Islands',
  VA: 'Holy See (Vatican City State)',
  HN: 'Honduras',
  HK: 'Hong Kong',
  HU: 'Hungary',
  IS: 'Iceland',
  IN: 'India',
  ID: 'Indonesia',
  IR: 'Iran, Islamic Republic of',
  IQ: 'Iraq',
  IE: 'Ireland',
  IM: 'Isle of Man',
  IL: 'Israel',
  IT: 'Italy',
  JM: 'Jamaica',
  JP: 'Japan',
  JE: 'Jersey',
  JO: 'Jordan',
  KZ: 'Kazakhstan',
  KE: 'Kenya',
  KI: 'Kiribati',
  KP: "Korea, Democratic People's Republic of",
  KR: 'Korea, Republic of',
  KW: 'Kuwait',
  KG: 'Kyrgyzstan',
  LA: "Lao People's Democratic Republic",
  LV: 'Latvia',
  LB: 'Lebanon',
  LS: 'Lesotho',
  LR: 'Liberia',
  LY: 'Libyan Arab Jamahiriya',
  LI: 'Liechtenstein',
  LT: 'Lithuania',
  LU: 'Luxembourg',
  MO: 'Macao',
  MK: 'Macedonia, The Former Yugoslav Republic of',
  MG: 'Madagascar',
  MW: 'Malawi',
  MY: 'Malaysia',
  MV: 'Maldives',
  ML: 'Mali',
  MT: 'Malta',
  MH: 'Marshall Islands',
  MQ: 'Martinique',
  MR: 'Mauritania',
  MU: 'Mauritius',
  YT: 'Mayotte',
  MX: 'Mexico',
  FM: 'Micronesia, Federated States of',
  MD: 'Moldova, Republic of',
  MC: 'Monaco',
  MN: 'Mongolia',
  ME: 'Montenegro',
  MS: 'Montserrat',
  MA: 'Morocco',
  MZ: 'Mozambique',
  MM: 'Myanmar',
  NA: 'Namibia',
  NR: 'Nauru',
  NP: 'Nepal',
  NL: 'Netherlands',
  NC: 'New Caledonia',
  NZ: 'New Zealand',
  NI: 'Nicaragua',
  NE: 'Niger',
  NG: 'Nigeria',
  NU: 'Niue',
  NF: 'Norfolk Island',
  MP: 'Northern Mariana Islands',
  NO: 'Norway',
  OM: 'Oman',
  PK: 'Pakistan',
  PW: 'Palau',
  PS: 'Palestinian Territory, Occupied',
  PA: 'Panama',
  PG: 'Papua New Guinea',
  PY: 'Paraguay',
  PE: 'Peru',
  PH: 'Philippines',
  PN: 'Pitcairn',
  PL: 'Poland',
  PT: 'Portugal',
  PR: 'Puerto Rico',
  QA: 'Qatar',
  RE: 'Réunion',
  RO: 'Romania',
  RU: 'Russian Federation',
  RW: 'Rwanda',
  BL: 'Saint Barthélemy',
  SH: 'Saint Helena',
  KN: 'Saint Kitts and Nevis',
  LC: 'Saint Lucia',
  MF: 'Saint Martin (French part)',
  PM: 'Saint Pierre and Miquelon',
  VC: 'Saint Vincent and the Grenadines',
  WS: 'Samoa',
  SM: 'San Marino',
  ST: 'Sao Tome and Principe',
  SA: 'Saudi Arabia',
  SN: 'Senegal',
  RS: 'Serbia',
  SC: 'Seychelles',
  SL: 'Sierra Leone',
  SG: 'Singapore',
  SK: 'Slovakia',
  SI: 'Slovenia',
  SB: 'Solomon Islands',
  SO: 'Somalia',
  ZA: 'South Africa',
  GS: 'South Georgia and the South Sandwich Islands',
  ES: 'Spain',
  LK: 'Sri Lanka',
  SD: 'Sudan',
  SR: 'Suriname',
  SJ: 'Svalbard and Jan Mayen',
  SZ: 'Swaziland',
  SE: 'Sweden',
  CH: 'Switzerland',
  SY: 'Syrian Arab Republic',
  TW: 'Taiwan, Province of China',
  TJ: 'Tajikistan',
  TZ: 'Tanzania, United Republic of',
  TH: 'Thailand',
  TL: 'Timor-Leste',
  TG: 'Togo',
  TK: 'Tokelau',
  TO: 'Tonga',
  TT: 'Trinidad and Tobago',
  TN: 'Tunisia',
  TR: 'Turkey',
  TM: 'Turkmenistan',
  TC: 'Turks and Caicos Islands',
  TV: 'Tuvalu',
  UG: 'Uganda',
  UA: 'Ukraine',
  AE: 'United Arab Emirates',
  GB: 'United Kingdom',
  US: 'United States',
  UM: 'United States Minor Outlying Islands',
  UY: 'Uruguay',
  UZ: 'Uzbekistan',
  VU: 'Vanuatu',
  VE: 'Venezuela',
  VN: 'Viet Nam',
  VG: 'Virgin Islands, British',
  VI: 'Virgin Islands, U.S.',
  WF: 'Wallis and Futuna',
  EH: 'Western Sahara',
  YE: 'Yemen',
  ZM: 'Zambia',
  ZW: 'Zimbabwe',
}

export async function GET() {
  const polar = new Polar({
    accessToken: import.meta.env.POLAR_ACCESS_TOKEN,
  })
  let orders: { date: string; billingCountry: string | undefined; name: string }[] = []
  const result = await polar.orders.list({
    sorting: ['-created_at'],
    limit: 10,
  })
  for await (const page of result) {
    orders = page.result.items
      .map((item) => ({
        name: item.product.name,
        date: item.createdAt.toISOString(),
        billingCountry: item.billingAddress?.country ? countryCodeToName[item.billingAddress.country] || item.billingAddress.country : undefined,
      }))
      .filter((order) => order.name.includes('LaunchFast'))
      .slice(0, 5)
    break
  }
  return new Response(JSON.stringify(orders), {
    headers: {
      'Content-Type': 'application/json',
      'Cache-Control': 'max-age=0, s-maxage=86400',
    },
  })
}
```

**Filtrar por producto:** En producción puede que solo quiera ciertos SKU (por ejemplo, nombres que contengan su familia de productos). Tras el mapeo, `.filter((order) => order.name.includes('YourBrand'))` mantiene el toast relevante.

**Nombres de países:** Polar devuelve un código de país (como `DE`, `US`, etc.) en el campo `billingAddress.country`, no el nombre completo. Para mostrar el nombre correcto, mapee ese código con una tabla de consulta, como se muestra en la ruta API de ejemplo anterior.

## Variables de entorno localmente y en Vercel

Cree `.env` (y añada `.env` a `.gitignore` si aún no está):

```bash
POLAR_ACCESS_TOKEN="polar_oat_…"
```

Para Vercel:

1. Proyecto → Settings → Environment Variables
2. Añada `POLAR_ACCESS_TOKEN` para **Production** (y **Preview** si prueba despliegues de PR)
3. Vuelva a desplegar para que el nuevo valor esté disponible

Ejecute localmente:

```bash
npm run dev
```

Visite `http://localhost:4321/api/sale` y confirme que recibe un array JSON.

## Añadir la notificación flotante de venta reciente

Cree un componente como `src/components/SalesPopup.astro` con el siguiente código:

```astro title="src/components/SalesPopup.astro"
---

---

<script>
  let popupTimeout
  let salesItems: any[] = []
  let currentIndex = 0

  function showSalePopup(item) {
    // Eliminar si ya existe
    const existing = document.getElementById('sale-popup')
    if (existing) existing.remove()
    const popup = document.createElement('a')
    popup.id = 'sale-popup'
    popup.href = '/#pricing'
    popup.className = `fixed bottom-6 right-6 z-50 bg-white shadow-lg rounded-xl px-6 py-4 flex flex-col items-start min-w-[270px] max-w-xs border border-gray-100 transition-transform duration-500 translate-y-8 opacity-0 pointer-events-none`
    popup.innerHTML = `
          <div class="text-sm text-gray-700">
            Alguien en <span class="font-bold text-branding">${item.billingCountry ? item.billingCountry : 'Desconocido'}</span> compró <span class="font-bold text-branding">${item.name}</span>
          </div>
          <div class="mt-1 text-xs text-gray-500 flex items-center gap-2">
            <span>${timeAgo(item.date)}</span>
            <span>| <svg class="inline-block size-3 text-branding" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M269.4 2.9C265.2 1 260.7 0 256 0s-9.2 1-13.4 2.9L54.3 82.8c-22 9.3-38.4 31-38.3 57.2c.5 99.2 41.3 280.7 213.6 363.2c16.7 8 36.1 8 52.8 0C454.7 420.7 495.5 239.2 496 140c.1-26.2-16.3-47.9-38.3-57.2L269.4 2.9zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"></path></svg> <span class="text-branding font-semibold">Verificado</span></span>
          </div>
        `
    document.body.appendChild(popup)
    // Animar entrada
    setTimeout(() => {
      popup.classList.remove('translate-y-8', 'opacity-0', 'pointer-events-none')
      popup.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto')
    }, 10)
    // Eliminar tras 3 s
    clearTimeout(popupTimeout)
    popupTimeout = setTimeout(() => {
      popup.classList.remove('translate-y-0', 'opacity-100', 'pointer-events-auto')
      popup.classList.add('translate-y-8', 'opacity-0', 'pointer-events-none')
      setTimeout(() => popup.remove(), 500)
    }, 3000)
  }

  function timeAgo(dateStr) {
    const now = new Date()
    const date = new Date(dateStr)
    const diff = Math.floor((now.getTime() - date.getTime()) / 1000)
    if (diff < 60) return `hace ${diff} segundos`
    if (diff < 3600) return `hace ${Math.floor(diff / 60)} ${Math.floor(diff / 60) === 1 ? 'minuto' : 'minutos'}`
    if (diff < 86400) return `hace ${Math.floor(diff / 3600)} ${Math.floor(diff / 3600) === 1 ? 'hora' : 'horas'}`
    return `hace ${Math.floor(diff / 86400)} ${Math.floor(diff / 86400) === 1 ? 'día' : 'días'}`
  }

  function fetchAndShow() {
    if (Array.isArray(salesItems) && salesItems.length > 0) {
      // Secuencial, no aleatorio
      const item = salesItems[currentIndex]
      if (item && item.billingCountry) showSalePopup(item)
      currentIndex = (currentIndex + 1) % salesItems.length
    }
  }

  fetch('/api/sale')
    .then((res) => res.json())
    .then((res) => {
      salesItems = Array.isArray(res) ? res : []
      currentIndex = 0
      fetchAndShow()
      setInterval(fetchAndShow, 10000)
    })
</script>
```

El código anterior hace lo siguiente:

- `fetch('/api/sale')` tras cargar la página.
- Almacena el array en memoria y **rota** entradas en un intervalo (por ejemplo, cada 10 segundos).
- Renderiza una tarjeta fija pequeña (abajo a la derecha es lo habitual) con:
  - País (o «Desconocido» si prefiere no mostrar ubicación aproximada)
  - Nombre del producto
  - **Tiempo relativo** («hace 3 minutos») a partir del `date` ISO
  - Una fila **Verificado** con un icono de escudo que refuerza que los datos provienen de Polar

Mantenga el script ligero: APIs DOM simples o un pequeño helper para `timeAgo` son suficientes. Enlace la tarjeta a su sección de precios para que el aviso tenga una llamada a la acción clara.

Si desea cumplir expectativas de privacidad más estrictas, muestre solo la región u omita la geografía por completo y muestre solo producto y tiempo.

## Resultado

<img width="679" height="211" alt="Crea una notificación verificada de ventas recientes con Polar y Astro" decoding="async" loading="eager" class="mt-4 border rounded-sm bg-cover bg-center bg-no-repeat transform will-change-auto" src="https://ik.imagekit.io/vjeqenuhn/launchfast-website/sales-popup-blog.png" />

## Conclusión

Ahora tiene un feed de ventas respaldado por **Polar**: la ruta API de Astro gestiona el secreto, Vercel la sirve como función serverless con caché en CDN, y el navegador solo ve los campos anonimizados que elija exponer. Adapte el estilo a su marca, ajuste intervalos y filtros, y obtendrá prueba social creíble sin mantener un pipeline de analítica aparte para notificaciones «falsas».
