A.CamposContact

Velur

Revenue analytics for Shopify and subscription brands

Velur is a side project, live on the Shopify App Store. What I enjoy most is building it into a finished product that runs on its own: every step automated, with Claude writing the explanation in each brief.

Overview

I started building Velur on my own in December 2025, as a side project. It's a daily revenue brief for Shopify and subscription brands, and it's been live on the Shopify App Store since October 2026.

The Velur console showing a daily revenue brief for a store: the day's numbers with Claude's explanation beneath them.
The daily brief in Velur's console, with demo data.

Problem

A DTC brand gets its numbers from many tools, and they often disagree. Velur brings them into one place and sends one brief that lands before 08:00 in the store's own timezone.

How it works

  1. ConnectorsShopify, Stripe, Meta Ads and Recharge. Google Ads is in progress.
  2. WarehouseRaw, staging and mart layers in Supabase Postgres, in the EU.
  3. MetricsComputed in code, each from its own versioned definition.
  4. ClaudeWrites the explanation and never calculates a number.
  5. Verification and deliveryEvery number is checked against the data. The brief lands before 08:00 local time.SentBlocked
Velur's daily brief email, open in a mail app: the day's summary, the numbers against their 28-day pace, two things worth a look, and a button to open the brief in Velur.
The daily brief as it arrives by email, with demo data.

Data quality and safeguards

  1. Metrics are computed in code, never by the LLM. Each one has its own versioned definition, so changing a formula bumps the version and old results are never overwritten.
  2. Claude writes the explanation and never calculates a number. Every number in its text is checked against the computed data, and if one doesn't match, the brief doesn't send.
  3. Only aggregated figures from an allowlist go to the model. Raw customer data, like names, emails and single transactions, never leaves the database.
  4. Every table is isolated per customer with row-level security, and I wrote the isolation tests before the features.
  5. The tests cover the metrics with golden numbers, tenant isolation, every number in the brief, and schema drift in CI.
  6. One hourly job decides, in each store's timezone, what's due: sync, generate or send. Every step is idempotent, and a daily health alarm emails me if any check fails.

Code samples

index.ts — velur-app
TSlib›metrics›definitions›index.ts
export const grossRevenueDaily: MetricDefinition = {
  id: "gross_revenue_daily",
  label: "Gross revenue",
  calcVersion: 1,
  unit: "cents",
  description: "Sum of order line items before discounts, excl. shipping/tax, order-dated.",
  compute: (date, data) => ordersOn(date, data).reduce((s, o) => s + o.grossCents, 0),
};

export const netRevenueDaily: MetricDefinition = {
  id: "net_revenue_daily",
  label: "Net revenue",
  calcVersion: 1,
  unit: "cents",
  description: "Gross minus discounts (order-dated) minus refunds issued that day (refund-dated).",
  compute: (date, data) =>
    ordersOn(date, data).reduce((s, o) => s + o.grossCents - o.discountCents, 0) -
    refundsOn(date, data).reduce((s, r) => s + r.amountCents, 0),
};

export const ordersDaily: MetricDefinition = {
  id: "orders_daily",
  label: "Orders",
  calcVersion: 1,
  unit: "count",
  description: "Number of orders placed, order-dated.",
  compute: (date, data) => ordersOn(date, data).length,
};

export const aovDaily: MetricDefinition = {
  id: "aov_daily",
  label: "Average order value",
  calcVersion: 1,
  unit: "cents",
  description: "Average order value: (gross - discounts) / orders, rounded to the cent. 0 when no orders.",
  compute: (date, data) => {
    const orders = ordersOn(date, data);
    if (orders.length === 0) return 0;
    const net = orders.reduce((s, o) => s + o.grossCents - o.discountCents, 0);
    return Math.round(net / orders.length);
  },
};

Metric definitions

Every KPI is a typed, versioned definition that carries its own compute function.

narrative.ts — velur-app
TSlib›brief›narrative.ts
export function verifyNarrative(n: BriefNarrative, p: BriefPayload): string[] {
  const errors: string[] = [];
  const metricById = new Map(p.metrics.map((m) => [m.metricId, m]));
  // Payloads stored before ADR-008's currency field were EUR-labeled at birth;
  // re-verifying their prose must accept what was true when they were written.
  const currency = p.currency ?? "EUR";

  if (/\d/.test(n.headline)) errors.push("headline contains digits — numbers live in sections");
  for (const c of n.contextual) if (/\d/.test(c)) errors.push(`contextual contains digits: "${c}"`);

  for (const [i, s] of n.sections.entries()) {
    for (const id of s.metricIds)
      if (!metricById.has(id)) errors.push(`section ${i}: unknown metric ${id}`);

    // every mention must match a canonical rendering of a real payload number
    for (const men of s.mentions) {
      const m = metricById.get(men.metricId);
      if (!m) {
        errors.push(`section ${i}: mention references unknown metric ${men.metricId}`);
        continue;
      }
      const forms = allowedForms(m, men.field, currency);
      if (!forms) {
        errors.push(`section ${i}: ${men.metricId} has no ${men.field} in the payload`);
        continue;
      }
      // equality after normalization, never containment: "800" is not 8
      if (!new Set(forms.map(norm)).has(norm(men.text)))
        errors.push(
          `section ${i}: mention "${men.text}" does not match payload ${men.metricId}.${men.field} (allowed: ${forms.join(" | ")})`,
        );
      }

Number verifier

Every number in the narrative must equal a canonical rendering of a real payload value, never a substring match.

narrative.ts — velur-app
TSlib›brief›narrative.ts
/**
 * EXPLAIN + VERIFY with one corrective retry, then fail-safe.
 * Deterministic contract: a returned narrative ALWAYS verifies clean.
 */
export async function narrate(
  payload: BriefPayload,
  promptV1: string,
  call: ModelCall,
): Promise<NarrateResult> {
  let lastErrors: string[] = [];
  for (let attempt = 1; attempt <= 2; attempt++) {
    const user =
      attempt === 1
        ? JSON.stringify(payload)
        : `${JSON.stringify(payload)}\n\nYour previous output failed verification:\n- ${lastErrors.join("\n- ")}\nReturn corrected JSON.`;
    try {
      const text = await call(promptV1, user);
      const narrative = parseNarrative(text);
      lastErrors = verifyNarrative(narrative, payload);
      if (lastErrors.length === 0) return { narrative, attempts: attempt, verifyErrors: [] };
    } catch (e) {
      lastErrors = [e instanceof Error ? e.message : String(e)];
    }
  }
  return { narrative: null, attempts: 2, verifyErrors: lastErrors };
}

Retry, then fail-safe

Explain, verify, retry once with the errors attached. A returned narrative always verifies clean.

Incidents and fixes

  1. Scheduled runs were silently skipped twice, and a day's brief was lost. I replaced them with the hourly job, which only does what the database says is still missing, so a missed run costs at most an hour.
  2. Shopify moved the app to expiring tokens and syncs started failing. I rebuilt the connector on expiring tokens with refresh.
  3. When the LLM API credits ran out, the pipeline sent a fail-safe brief instead of nothing.

Tech stack

Next.js and TypeScript, Supabase Postgres, Claude API, Resend, Vercel and GitHub Actions. I also designed and built the marketing site, velur.io.

Numbers

API connectors, with Google Ads in progress
4
Merged pull requests
80+
Automated test files
79
LLM cost per brief, roughly
$0.05

Counted on October 11, 2026. The LLM cost was measured in September 2026.

Skills

Analytics engineeringRaw, staging and mart layers with versioned metrics
LLM output verificationEvery number in the brief checked against the data
Claude APIClaude writes the explanation, never the numbers
API connectorsShopify OAuth with expiring tokens, Stripe, Meta Ads, Recharge
Data securityRow-level security, aggregates only, EU region
Scheduling and monitoringA timezone-aware hourly job and a daily health alarm
TestingGolden numbers, tenant isolation, schema drift in CI
Fabletics & Yitty