Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/web/app/api/projects/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FREE_TIER_LIMIT, PRO_TIER_LIMIT } from "@/lib/constants"
import { ensureMetronomeCustomer, ingestUsageEvent } from "@/lib/metronome"
import {
createAuthServerClient,
createProject,
Expand Down Expand Up @@ -87,6 +88,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "User email not found" }, { status: 400 })
}

// Ensure a Metronome customer record exists for this user (fire-and-forget)
ensureMetronomeCustomer(user.id, email).catch(() => {})

// Check project
const { data: existingProjects, error: countError } = await supabase
.from("projects")
Expand Down Expand Up @@ -157,6 +161,12 @@ export async function POST(request: NextRequest) {

console.log(`🎉 Project created: ${project.id} by user ${user.id}`)

// Ingest usage event into Metronome (fire-and-forget)
ingestUsageEvent(user.id, "project_created", {
project_id: project.id,
template_type,
}).catch(() => {})

const response = NextResponse.json({
projectId: project.id, // Add projectId for frontend compatibility
project: {
Expand Down
88 changes: 88 additions & 0 deletions apps/web/lib/metronome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Metronome usage-based billing API client.
* https://docs.metronome.com
*
* All functions are no-ops when METRONOME_API_KEY is not set, so the
* integration is safe to deploy before the key is provisioned.
*/

const METRONOME_BASE = "https://api.metronome.com/v1"

function getApiKey(): string | undefined {
return process.env.METRONOME_API_KEY
}

/**
* Idempotently ensure a Metronome customer exists for the given Supabase user.
* Uses the Supabase user ID as an `ingest_alias` so events can be attributed
* without separately storing the Metronome customer UUID.
*
* A 409 response is treated as success (customer already exists).
*/
export async function ensureMetronomeCustomer(userId: string, email: string): Promise<void> {
const apiKey = getApiKey()
if (!apiKey) return

try {
const res = await fetch(`${METRONOME_BASE}/customers`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: email,
ingest_aliases: [userId],
}),
})

if (!res.ok && res.status !== 409) {
console.error(`Metronome: ensureCustomer failed [${res.status}]`, await res.text())
}
} catch (err) {
console.error("Metronome: ensureCustomer error", err)
}
}

/**
* Ingest a usage event for the given user (identified by Supabase user ID,
* which must have been registered as an ingest_alias via ensureMetronomeCustomer).
*
* Failures are logged but never thrown — billing should never block the
* primary request path.
*/
export async function ingestUsageEvent(
userId: string,
eventType: string,
properties: Record<string, string | number | boolean> = {},
): Promise<void> {
const apiKey = getApiKey()
if (!apiKey) return

try {
const res = await fetch(`${METRONOME_BASE}/ingest`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
events: [
{
transaction_id: `${userId}-${eventType}-${Date.now()}`,
customer_id: userId,
event_type: eventType,
timestamp: new Date().toISOString(),
properties,
},
],
}),
})

if (!res.ok) {
console.error(`Metronome: ingest failed [${res.status}] for ${eventType}`, await res.text())
}
} catch (err) {
console.error(`Metronome: ingest error for ${eventType}`, err)
}
}
5 changes: 5 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ NETLIFY_ACCESS_TOKEN=your-netlify-access-token
# 21st.dev Subscription API (for subscription checks)
SUBSCRIPTION_API_KEY=your-21st-dev-api-key

# Metronome Usage-Based Billing (https://metronome.com)
# Tracks usage events (project_created, etc.) per customer.
# Leave unset to disable Metronome integration.
METRONOME_API_KEY=your-metronome-api-key

# Development: Skip subscription check (set to 'true' to bypass subscription validation in dev)
SKIP_SUBSCRIPTION_CHECK=false

Expand Down