# How to Integrate Wave with V0

- Tool: V0
- Difficulty: Intermediate
- Time required: 35 minutes
- Last updated: April 2026

## TL;DR

To integrate Wave Accounting with V0 by Vercel, generate a financial dashboard UI with V0, create a Next.js API route that calls Wave's GraphQL API using your access token, store credentials in Vercel environment variables, and deploy. Your app can display invoices, customer records, payment statuses, and financial summaries from Wave's free accounting platform.

## Build Custom Financial Dashboards with Wave Accounting and V0 by Vercel

Wave is the go-to free accounting solution for freelancers, consultants, and small businesses — no subscription fees, no per-user charges, just clean invoicing, expense tracking, and financial reporting. For developers building client portals, bookkeeper tools, or custom financial dashboards on top of Wave, the Wave API provides GraphQL access to all the core accounting data including invoices, customers, products, payments, and accounts.

Wave's choice of GraphQL (rather than REST) means you can request exactly the data your dashboard needs in a single API call, avoiding over-fetching and multiple round trips. A single GraphQL query can fetch a list of invoices with their status, the associated customer name, the invoice total, the amount paid, and the due date — everything needed for a payment status dashboard in one request. V0 generates financial dashboard components well: invoice list tables, payment status badges, outstanding balance cards, and customer account summaries all map naturally to V0's component generation capabilities.

The Wave API uses OAuth 2.0 for authentication, meaning users authorize your app to access their Wave data through a consent flow — your app never sees their Wave password. For a tool serving multiple Wave users (like an accounting assistant or client portal), you store each user's access token and make API calls on their behalf. For a single-business tool (like a custom dashboard for your own Wave account), you can use a long-lived access token obtained through the OAuth flow and stored in your Vercel environment variables, skipping the need for a full multi-user auth flow.

## Before you start

- A Wave account at waveapps.com — the free plan includes full API access; no paid subscription is required
- A Wave developer application — create one at developer.waveapps.com by clicking 'Create an application', which provides your OAuth 2.0 client ID and secret
- Wave OAuth credentials — the client ID and client secret from your Wave developer application, available in the developer portal after creating an app
- Your Wave business ID — found in the Wave URL when logged into your Wave account (https://app.waveapps.com/businesses/{businessId}/accounting/dashboard); needed for GraphQL queries targeting your specific business
- A V0 account at v0.dev and a Vercel account for deploying the Next.js app

## Step-by-step guide

### 1. Generate the Financial Dashboard UI with V0

Open V0 at v0.dev and describe the financial dashboard you want to build. Wave data organizes into businesses (the top-level entity), invoices (with status, amounts, and customer links), customers (contact records), products/services (line item catalog), and transactions (income and expenses). For a freelancer or small business dashboard, the most useful views are outstanding invoices (invoices not yet paid, sorted by due date), payment history (recent payments received), and client overview (customers with their outstanding balances and payment history). V0 generates financial UI components well — invoice status badges (overdue/pending/paid), currency-formatted amount displays, date formatting, and data tables with status filters are all patterns V0 handles reliably with Tailwind CSS and shadcn/ui components. For invoice status visualization, describe a clear color coding system: overdue in red, pending/sent in yellow, paid in green, and draft in gray. For amount displays, mention that values will be in USD with two decimal places formatted as $X,XXX.XX. After generating the UI, pay particular attention to the empty states — what should the dashboard show when there are no outstanding invoices or when Wave API data hasn't loaded yet? Describing these states to V0 produces more complete, production-ready components. Push to GitHub via V0's Git panel after generating.

**Expected result:** A Wave invoice dashboard renders in V0's preview with KPI summary cards, a filterable invoice table with status badges, and pagination. Components are structured to fetch from /api/wave/invoices.

### 2. Create the Wave GraphQL API Route

Create the Next.js API route that queries Wave's GraphQL API and returns invoice data to your dashboard. Wave's GraphQL API endpoint is https://gql.waveapps.com/graphql/public and requires a Bearer token in the Authorization header. All queries target a specific business using the businessId from your Wave account. The GraphQL query for fetching invoices uses the business.invoices connection, which accepts pagination arguments (first, after cursor for cursor-based pagination) and returns invoice nodes with fields including invoiceNumber, status, total (amount and currencyCode), amountDue, dueDate, invoiceDate, and customer (with name and email). Wave's invoice status values are DRAFT, SAVED, OVERDUE, PARTIAL, PAID, and SENT — map these to your display labels and colors. For the GraphQL query structure, Wave uses standard Relay-style pagination with a pageInfo object containing hasNextPage and endCursor for fetching subsequent pages. The total and amountDue fields return objects with amount (as a string) and currencyCode — parse the amount string to a float for display formatting. For currency formatting in your API route or frontend, use JavaScript's Intl.NumberFormat with style: 'currency' and the currencyCode from the response for multi-currency Wave accounts. Wave also has rate limits on their API — stay within 100 requests per minute to avoid throttling.

```
// app/api/wave/invoices/route.ts
import { NextRequest, NextResponse } from 'next/server';

const WAVE_GQL_URL = 'https://gql.waveapps.com/graphql/public';

const INVOICES_QUERY = `
  query GetInvoices($businessId: ID!, $first: Int!, $after: String) {
    business(id: $businessId) {
      invoices(first: $first, after: $after) {
        pageInfo {
          hasNextPage
          endCursor
        }
        edges {
          node {
            id
            invoiceNumber
            status
            invoiceDate
            dueDate
            memo
            total {
              value
              currency {
                symbol
                code
              }
            }
            amountDue {
              value
              currency {
                code
              }
            }
            customer {
              id
              name
              email
            }
          }
        }
      }
    }
  }
`;

async function queryWave(token: string, query: string, variables: Record<string, unknown>) {
  const response = await fetch(WAVE_GQL_URL, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ query, variables }),
  });

  if (!response.ok) {
    throw new Error(`Wave API HTTP error: ${response.status}`);
  }

  const data = await response.json() as {
    data?: Record<string, unknown>;
    errors?: Array<{ message: string }>;
  };

  if (data.errors?.length) {
    throw new Error(`Wave GraphQL error: ${data.errors[0].message}`);
  }

  return data.data;
}

export async function GET(request: NextRequest) {
  const token = process.env.WAVE_ACCESS_TOKEN;
  const businessId = process.env.WAVE_BUSINESS_ID;

  if (!token || !businessId) {
    return NextResponse.json(
      { error: 'Wave credentials not configured' },
      { status: 500 }
    );
  }

  const { searchParams } = new URL(request.url);
  const first = parseInt(searchParams.get('first') ?? '20', 10);
  const after = searchParams.get('after') ?? null;
  const statusFilter = searchParams.get('status') ?? '';

  try {
    const data = await queryWave(token, INVOICES_QUERY, {
      businessId,
      first,
      after,
    });

    const business = data?.business as Record<string, unknown>;
    const invoicesConn = business?.invoices as {
      pageInfo: { hasNextPage: boolean; endCursor: string };
      edges: Array<{ node: Record<string, unknown> }>;
    };

    let invoices = (invoicesConn?.edges ?? []).map(({ node }) => {
      const total = node.total as { value: string; currency: { symbol: string; code: string } };
      const amountDue = node.amountDue as { value: string; currency: { code: string } };
      const customer = node.customer as { id: string; name: string; email: string } | null;

      return {
        id: node.id as string,
        invoiceNumber: node.invoiceNumber as string,
        status: node.status as string,
        invoiceDate: node.invoiceDate as string,
        dueDate: node.dueDate as string | null,
        memo: node.memo as string,
        totalAmount: parseFloat(total?.value ?? '0'),
        amountDue: parseFloat(amountDue?.value ?? '0'),
        currency: total?.currency?.code ?? 'USD',
        currencySymbol: total?.currency?.symbol ?? '$',
        customerName: customer?.name ?? 'Unknown Customer',
        customerEmail: customer?.email ?? '',
      };
    });

    if (statusFilter) {
      invoices = invoices.filter((inv) => inv.status === statusFilter.toUpperCase());
    }

    const totalOutstanding = invoices
      .filter((inv) => inv.status !== 'PAID' && inv.status !== 'DRAFT')
      .reduce((sum, inv) => sum + inv.amountDue, 0);

    const overdue = invoices
      .filter((inv) => inv.status === 'OVERDUE')
      .reduce((sum, inv) => sum + inv.amountDue, 0);

    return NextResponse.json({
      invoices,
      pageInfo: invoicesConn?.pageInfo,
      summary: {
        totalOutstanding: totalOutstanding.toFixed(2),
        overdue: overdue.toFixed(2),
        invoiceCount: invoices.length,
      },
    });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    console.error('Wave API error:', message);
    return NextResponse.json(
      { error: 'Failed to fetch Wave invoices', details: message },
      { status: 500 }
    );
  }
}
```

**Expected result:** GET /api/wave/invoices returns a normalized array of invoices with status, amounts, due dates, customer names, and a summary object showing total outstanding and overdue amounts.

### 3. Wire Up the Dashboard and Add Invoice Status Filtering

Update your V0-generated dashboard components to fetch from /api/wave/invoices and handle the Wave data structure correctly. The invoice table component should load data on mount with no filters applied, and update when the user selects a status filter tab. Map Wave's status values (DRAFT, SAVED, SENT, OVERDUE, PARTIAL, PAID) to your display labels and badge colors — OVERDUE maps to a red badge, SENT and PARTIAL map to yellow, PAID maps to green, and DRAFT maps to gray. The summary cards at the top should use the summary object returned by the API (totalOutstanding and overdue) rather than recalculating from the invoice list on the client. For currency formatting, use JavaScript's Intl.NumberFormat to format amounts with the currency symbol and two decimal places: new Intl.NumberFormat('en-US', { style: 'currency', currency: inv.currency }).format(inv.totalAmount). For date formatting, Wave returns dates as ISO strings (YYYY-MM-DD) — format them with Intl.DateTimeFormat for locale-appropriate display. For the 'Overdue' badge, you can also add a 'days overdue' calculation by comparing the dueDate to today's date. Cursor-based pagination (Wave's pagination approach) requires storing the endCursor from the pageInfo object and passing it as the 'after' parameter for the next page — update your 'Load More' or 'Next Page' button to pass the cursor rather than a page number.

**Expected result:** The invoice dashboard loads real Wave data with correct status badges, currency-formatted amounts, and summary KPI cards. Status filtering tabs show the correct subset of invoices, and Load More works with cursor-based pagination.

### 4. Configure Environment Variables and Deploy to Vercel

Push your code to GitHub and configure Wave credentials in Vercel. Open the Vercel Dashboard, select your project, and go to Settings → Environment Variables. Add WAVE_ACCESS_TOKEN with your Wave OAuth access token and WAVE_BUSINESS_ID with your Wave business ID. To obtain an access token for your own Wave account without building a full OAuth flow, use the Wave developer portal at developer.waveapps.com — after creating an app, you can generate a personal access token for testing. For a production app where multiple users connect their Wave accounts, implement the full OAuth 2.0 authorization code flow, store each user's tokens in your database, and look up the correct token per user in your API routes. The business ID is visible in your Wave account URL when viewing your dashboard (format: waveapps.com/businesses/{uuid}/accounting). Neither credential should have the NEXT_PUBLIC_ prefix. Set variables for Production and Preview environments, save, and redeploy. After deployment, open your Vercel URL and verify that the invoice dashboard loads your actual Wave invoices with correct statuses and amounts. If the API returns a 'Business not found' error, verify the WAVE_BUSINESS_ID is the correct UUID from your Wave account URL and that the access token has permission to access that business.

**Expected result:** The Vercel deployment succeeds and the financial dashboard loads real Wave accounting data including invoice lists, payment statuses, and outstanding balance summaries from your Wave business account.

## Best practices

- Use GraphQL field selection to request only the invoice fields your dashboard displays — Wave's GraphQL API lets you avoid fetching unused data, which is especially important for invoice bodies with long memo and line item fields
- Parse Wave's amount fields (total.value, amountDue.value) from string to float in the API route normalization layer before returning data to the frontend
- Store WAVE_ACCESS_TOKEN and WAVE_BUSINESS_ID without the NEXT_PUBLIC_ prefix — Wave financial data includes sensitive business revenue information that must never be exposed to the browser
- Implement cursor-based pagination correctly by storing endCursor in React state and passing it as the 'after' parameter — Wave uses Relay-style cursor pagination, not page number pagination
- Format currency amounts using Intl.NumberFormat with the currency code from Wave's response — Wave supports multiple currencies, so hardcoding USD formatting breaks for non-US businesses
- Handle Wave's invoice status PARTIAL separately from SENT — PARTIAL means the invoice has been partially paid and has an outstanding balance, which requires different styling than fully unpaid SENT invoices
- Cache Wave invoice API responses for 5-10 minutes since invoice data doesn't change on every page load — this reduces Wave API calls without showing meaningfully stale payment information

## Use cases

### Invoice Management Dashboard

A custom invoice tracking interface that shows all Wave invoices sorted by status — overdue, pending, and paid. The dashboard highlights overdue invoices with the amount outstanding and days overdue, helping freelancers follow up on late payments without logging into Wave.

Prompt example:

```
Create an invoice management dashboard. Top row: 3 summary cards — 'Outstanding' (total amount in unpaid invoices, in red), 'Overdue' (amount past due date, in dark red), 'Paid This Month' (amount received this month, in green). Below: a tabbed invoice table with tabs 'All', 'Overdue', 'Pending', 'Paid'. Table columns: Invoice #, Client Name, Invoice Date, Due Date, Amount, Status badge (Overdue red / Sent yellow / Paid green / Draft gray), and a 'View' link. Sort by Due Date descending. Show 20 per page. Clean financial dashboard design with white cards on light gray background.
```

### Client Payment Portal

A customer-facing portal where clients can view their outstanding invoices, payment history, and account balance. Clients get a clear, branded view of what they owe without needing a Wave account themselves.

Prompt example:

```
Build a client payment portal. Header with business logo placeholder, client company name, and 'Account Balance: $X,XXX' in a prominent card. Below: two sections side by side — 'Outstanding Invoices' list with invoice number, description, amount, due date, and 'Pay Now' button; and 'Payment History' list showing recent payments with date, invoice reference, and amount paid with a green checkmark. Add a 'Download Statement' button at the top. Use a clean professional design with the business's blue accent color.
```

### Revenue and Expense Summary

A monthly financial summary page pulling Wave data to show revenue by client, expense categories, and net income — giving freelancers or small business owners a quick P&L snapshot without generating a full Wave report.

Prompt example:

```
Design a monthly P&L summary page. Period selector (month/year dropdown) at the top. Three KPI cards: 'Revenue' (total invoiced and paid), 'Expenses' (total recorded expenses), 'Net Income' (revenue minus expenses). Below: two charts side by side — 'Revenue by Client' bar chart (top 5 clients by revenue this period), 'Expenses by Category' donut chart. Below charts: a simple income table showing each income source with amount. Clean financial reporting design in forest green and white.
```

## Troubleshooting

### Wave GraphQL API returns 'Unauthorized' or 401 error

Cause: The WAVE_ACCESS_TOKEN is missing, expired, or incorrectly formatted. Wave OAuth access tokens expire and require refresh using the refresh token from the initial OAuth exchange.

Solution: Verify WAVE_ACCESS_TOKEN in Vercel environment variables is set correctly without the NEXT_PUBLIC_ prefix. If the token has expired, re-run the OAuth flow to obtain a fresh access token and update the environment variable in Vercel, then redeploy. For production apps with multiple users, implement automatic token refresh using the refresh token before each API call.

### GraphQL query returns 'Business not found' error

Cause: The WAVE_BUSINESS_ID doesn't match any business accessible by the current access token, or the ID format is incorrect.

Solution: In your Wave account, navigate to your business dashboard and copy the UUID from the URL (format: app.waveapps.com/businesses/{uuid}/accounting). Ensure the UUID is copied completely without extra characters. Also verify that the access token was generated for an account that has access to that business — if you have multiple Wave accounts, the token must belong to the account that owns the business.

### Invoice amounts display as '0' or 'NaN' in the dashboard

Cause: Wave's total and amountDue GraphQL fields return the amount as a string inside a nested object, not a direct number. parseFloat() on the nested value property is needed.

Solution: Access the amount value correctly from Wave's response structure: the total field returns { value: '1500.00', currency: { code: 'USD', symbol: '$' } }. Parse with parseFloat(total.value) rather than using total directly. Use optional chaining to handle cases where total might be null.

```
const totalAmount = parseFloat((node.total as {value: string})?.value ?? '0');
const amountDue = parseFloat((node.amountDue as {value: string})?.value ?? '0');
```

### Pagination returns duplicate invoices when using Load More

Cause: Cursor-based pagination requires passing the endCursor from the previous response as the 'after' parameter. If the cursor is not passed correctly, Wave returns the first page again.

Solution: Store the endCursor string from the pageInfo object in React state after each successful fetch. When the user clicks 'Load More', include after: endCursor in the API request query parameters. The cursor string is base64-encoded by Wave and should be URL-encoded before passing as a query parameter.

```
// Pass cursor as URL parameter:
const url = `/api/wave/invoices?first=20${cursor ? `&after=${encodeURIComponent(cursor)}` : ''}`;
```

## Frequently asked questions

### Is Wave's API completely free to use?

Yes — Wave's API access is included with the free Wave accounting plan with no additional charges. You need to create a developer application at developer.waveapps.com to obtain OAuth credentials, but there are no API usage fees. Wave's business model is free accounting software with optional paid features like payroll and payments processing, so the API remains free as part of the core platform.

### Why does Wave use GraphQL instead of a REST API?

Wave chose GraphQL for their public API because it allows developers to fetch exactly the data they need in a single query, reducing over-fetching and network requests. For a V0-generated dashboard, this is beneficial — you can request invoice data, customer names, and payment totals in one GraphQL query rather than multiple REST calls. The trade-off is that GraphQL requires more familiarity with query syntax than a simple REST API.

### How do I get a Wave access token without building a full OAuth flow?

For personal use or testing, you can generate a personal access token directly from the Wave developer portal at developer.waveapps.com after creating an application. This token provides access to your own Wave account without going through the OAuth consent flow. For production apps serving multiple Wave users, you must implement the full OAuth 2.0 authorization code flow — Wave's developer documentation covers the specific OAuth endpoints and scopes required.

### Can I create invoices or record payments through the Wave API, not just read data?

Yes — Wave's GraphQL API supports mutations for creating invoices, recording payments, managing customers, and recording transactions in addition to query (read) operations. Creating an invoice requires a GraphQL mutation with the customer ID, invoice date, due date, and line items. Recording a payment requires the invoice ID and payment amount. These write operations require the same OAuth access token as read operations.

### How do I handle Wave accounts with multiple businesses?

Wave users can have multiple businesses in one account. The businesses query in Wave's GraphQL API returns a list of all businesses accessible by the access token. Fetch this list to build a business selector in your dashboard, then use the selected business ID in all subsequent invoice and customer queries. Store the selected business ID in your component state so users can switch between businesses without re-authenticating.

### Does Wave's API support real-time updates when invoices are paid?

Wave does not currently offer webhooks for real-time event notifications in their public API. To detect invoice payment status changes, you need to poll the API periodically or refresh the dashboard on user action. For a live payment dashboard, a reasonable polling interval is every 5-10 minutes, implemented with a setInterval in a useEffect hook with cleanup to prevent memory leaks. Alternatively, add a manual 'Refresh' button for the invoice list so users can trigger a fresh fetch on demand.

---

Source: https://www.rapidevelopers.com/v0-integrations/wave
© RapidDev — https://www.rapidevelopers.com/v0-integrations/wave
