Build a Verified Recent Sales Notification with Polar and Astro
LaunchFast Logo LaunchFast

Build a Verified Recent Sales Notification with Polar and Astro

Rishi Raj Jain
Build a Verified Recent Sales Notification with Polar and Astro

“Someone in Germany just purchased Pro 2 hours ago.” That line is everywhere on SaaS landing pages and often it is entirely fabricated. A better approach is to surface real orders from your payment provider. If you sell with Polar, you can read recent orders through the official API, expose a small JSON endpoint from your Astro application, and let the client render a tasteful toast with a Verified label so visitors know the signal comes from your checkout, not a random number generator.

High Quality Starter Kits with built-in authentication flow (Auth.js), object uploads (AWS, Clouflare R2, Firebase Storage, Supabase Storage), integrated payments (Stripe, LemonSqueezy), email verification flow (Resend, Postmark, Sendgrid), and much more. Compatible with any database (Redis, Postgres, MongoDB, SQLite, Firestore).
Next.js Starter Kit
SvelteKit Starter Kit

In this guide, you will wire Polar SDK into an Astro server route, deploy it on Vercel, and add a floating notification component that cycles through recent sales. The pattern matches what we use on this site: server-only secrets, edge-friendly caching headers, and a minimal client script.

Table Of Contents

Prerequisites

You will need:

  • Node.js 20 or later
  • A Polar account with at least one product and (for meaningful demos) a few test or real orders
  • A Vercel account if you want to deploy there

How the pieces fit together

1. Browser
→ GET /api/sale (your Astro route)
→ Receives JSON: product name, ISO timestamp, optional billing country label
2. Astro API route (Node / Vercel Function)
→ Uses POLAR_ACCESS_TOKEN with Polar SDK
→ Lists orders (newest first), maps to a small public shape
→ Sets Cache-Control for CDN caching
3. Polar API
→ Authoritative list of orders and line items

The Verified badge in the UI communicates that the payload was fetched from Polar on the server at request time (or from a cached response that was produced that way). Keep the access token out of client bundles and environment prefixes.

Create a Polar access token

  1. Open the Polar dashboard and sign in.
  2. Go to your organization Settings and find Developers (wording may vary slightly).
  3. Create a token with permission to orders:read.
  4. Copy the token once and store it securely - you will add it to Vercel and local .env as POLAR_ACCESS_TOKEN. (Never commit this value to git!)

Scaffold Astro with the Vercel adapter

Create a new project (or use an existing one with output: 'server'):

Terminal window
npm create astro@latest polar-sale-badge
cd polar-sale-badge

Add the Vercel adapter so API routes run as serverless functions on deploy:

Terminal window
npx astro add vercel --yes

Ensure astro.config.mjs uses server output. A minimal config looks like:

import { defineConfig } from 'astro/config'
import vercel from '@astrojs/vercel'
export default defineConfig({
output: 'server',
adapter: vercel(),
})

Install the Polar SDK:

Terminal window
npm install @polar-sh/sdk

Implement the API route

Add src/pages/api/sale.ts. The important details:

  1. export const prerender = false so Astro does not try to statically bake this URL at build time.
  2. Instantiate Polar with import.meta.env.POLAR_ACCESS_TOKEN.
  3. Call polar.orders.list with sorting newest first and a small limit, then map each item to only what the UI needs (product name, createdAt, optional billing country).
  4. Return Cache-Control so Vercel’s CDN can cache the JSON for a day while allowing revalidation (max-age=0, s-maxage=86400 is a reasonable starting point).
src/pages/api/sale.ts
export const prerender = false
import { Polar } from '@polar-sh/sdk'
const countryCodeToName: { [key: string]: string } = {
AF: 'Afghanistan',
242 collapsed lines
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',
},
})
}

Filtering by product: In production you may only want certain SKUs (for example names containing your product family). After mapping, .filter((order) => order.name.includes('YourBrand')) keeps the toast relevant.

Country names: Polar returns a country code (like DE, US, etc.) in the billingAddress.country field, not the full country name. To display the proper country name, map this code to a country name using a lookup table, as shown in the example API route above.

Environment variables locally and on Vercel

Create .env (and add .env to .gitignore if it is not already):

Terminal window
POLAR_ACCESS_TOKEN="polar_oat_…"

For Vercel:

  1. Project → Settings → Environment Variables
  2. Add POLAR_ACCESS_TOKEN for Production (and Preview if you test PR deployments)
  3. Redeploy so the new value is available

Run locally:

Terminal window
npm run dev

Visit http://localhost:4321/api/sale and confirm you get a JSON array.

Add the floating recent-sale notification

Create a component such as src/components/SalesPopup.astro with the following code:

src/components/SalesPopup.astro
---
---
<script>
let popupTimeout
let salesItems: any[] = []
let currentIndex = 0
function showSalePopup(item) {
// Remove if already exists
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">
Someone in <span class="font-bold text-branding">${item.billingCountry ? item.billingCountry : 'Unknown'}</span> purchased <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">Verified</span></span>
</div>
`
document.body.appendChild(popup)
// Animate in
setTimeout(() => {
popup.classList.remove('translate-y-8', 'opacity-0', 'pointer-events-none')
popup.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto')
}, 10)
// Remove after 3s
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 `${diff} seconds ago`
if (diff < 3600) return `${Math.floor(diff / 60)} ${Math.floor(diff / 60) === 1 ? 'minute' : 'minutes'} ago`
if (diff < 86400) return `${Math.floor(diff / 3600)} ${Math.floor(diff / 3600) === 1 ? 'hour' : 'hours'} ago`
return `${Math.floor(diff / 86400)} ${Math.floor(diff / 86400) === 1 ? 'day' : 'days'} ago`
}
function fetchAndShow() {
if (Array.isArray(salesItems) && salesItems.length > 0) {
// Sequential, not random
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>

The code above does the following:

  • fetch('/api/sale') after page load.
  • Stores the array in memory and rotates entries on an interval (for example every 10 seconds).
  • Renders a small fixed card (bottom-right is common) with:
    • Country (or “Unknown” if you prefer not to show coarse location)
    • Product name
    • Relative time (“3 minutes ago”) from the ISO date
    • A Verified row with a shield-style icon to reinforce that data is backed by Polar

Keep the script lean: plain DOM APIs or a tiny helper for timeAgo are enough. Link the card to your pricing section so the nudge has a clear call to action.

If you want to match stricter privacy expectations, only show region or omit geography entirely and display product + time only.

Output

Build a Verified Recent Sales Notification with Polar and Astro

Conclusion

You now have a Polar-backed sales feed: the Astro API route owns the secret, Vercel serves it as a serverless function with CDN caching, and the browser only sees anonymized fields you choose to expose. Swap the styling to match your brand, tune intervals and filters, and you get credible social proof without maintaining a separate analytics pipeline for “fake” notifications.

Learn More Live Collaborative Editing in Astro with Cloudflare Durable Objects
Live Collaborative Editing in Astro with Cloudflare Durable Objects December 9, 2025
Bot Protection in Astro with Cloudflare Turnstile
Bot Protection in Astro with Cloudflare Turnstile December 9, 2025
Generating PDFs in Astro with Cloudflare Browser Rendering at the Edge
Generating PDFs in Astro with Cloudflare Browser Rendering at the Edge December 8, 2025