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.
Integration method
Wave integrates with V0-generated Next.js apps through server-side API routes that call Wave's GraphQL API using OAuth 2.0 access tokens. Your Wave credentials are stored as server-only Vercel environment variables and never exposed to the browser. The V0-generated financial dashboard UI fetches invoice, customer, and payment data through your Next.js routes, which proxy GraphQL queries to Wave's API and return normalized financial data for display.
Prerequisites
- 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
Generate the Financial Dashboard UI with V0
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.
Build a Wave invoicing dashboard. Top navigation with 'Wave Dashboard' title and a business selector dropdown. Below: a row of summary KPI cards showing Total Outstanding (sum of unpaid invoices), Overdue Amount, Due This Week, and Paid This Month — each with a currency amount and a trend indicator. Below the cards: a data table of invoices. Columns: Invoice #, Client, Issue Date, Due Date, Amount, Amount Due (remaining), Status badge. Status badges: Overdue (red), Sent (yellow), Paid (green), Draft (gray). Table has a search input to filter by client name and a status filter dropdown. Pagination at bottom showing 'Showing 1-20 of 47 invoices'. Clean finance app design.
Paste this in V0 chat
Pro tip: Ask V0 to generate a separate 'Quick Stats' component for the top of the dashboard that shows the number of overdue invoices prominently — this is the most action-requiring metric for freelancers using an invoice dashboard.
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.
Create the Wave GraphQL API Route
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.
1// app/api/wave/invoices/route.ts2import { NextRequest, NextResponse } from 'next/server';34const WAVE_GQL_URL = 'https://gql.waveapps.com/graphql/public';56const INVOICES_QUERY = `7 query GetInvoices($businessId: ID!, $first: Int!, $after: String) {8 business(id: $businessId) {9 invoices(first: $first, after: $after) {10 pageInfo {11 hasNextPage12 endCursor13 }14 edges {15 node {16 id17 invoiceNumber18 status19 invoiceDate20 dueDate21 memo22 total {23 value24 currency {25 symbol26 code27 }28 }29 amountDue {30 value31 currency {32 code33 }34 }35 customer {36 id37 name38 email39 }40 }41 }42 }43 }44 }45`;4647async function queryWave(token: string, query: string, variables: Record<string, unknown>) {48 const response = await fetch(WAVE_GQL_URL, {49 method: 'POST',50 headers: {51 Authorization: `Bearer ${token}`,52 'Content-Type': 'application/json',53 },54 body: JSON.stringify({ query, variables }),55 });5657 if (!response.ok) {58 throw new Error(`Wave API HTTP error: ${response.status}`);59 }6061 const data = await response.json() as {62 data?: Record<string, unknown>;63 errors?: Array<{ message: string }>;64 };6566 if (data.errors?.length) {67 throw new Error(`Wave GraphQL error: ${data.errors[0].message}`);68 }6970 return data.data;71}7273export async function GET(request: NextRequest) {74 const token = process.env.WAVE_ACCESS_TOKEN;75 const businessId = process.env.WAVE_BUSINESS_ID;7677 if (!token || !businessId) {78 return NextResponse.json(79 { error: 'Wave credentials not configured' },80 { status: 500 }81 );82 }8384 const { searchParams } = new URL(request.url);85 const first = parseInt(searchParams.get('first') ?? '20', 10);86 const after = searchParams.get('after') ?? null;87 const statusFilter = searchParams.get('status') ?? '';8889 try {90 const data = await queryWave(token, INVOICES_QUERY, {91 businessId,92 first,93 after,94 });9596 const business = data?.business as Record<string, unknown>;97 const invoicesConn = business?.invoices as {98 pageInfo: { hasNextPage: boolean; endCursor: string };99 edges: Array<{ node: Record<string, unknown> }>;100 };101102 let invoices = (invoicesConn?.edges ?? []).map(({ node }) => {103 const total = node.total as { value: string; currency: { symbol: string; code: string } };104 const amountDue = node.amountDue as { value: string; currency: { code: string } };105 const customer = node.customer as { id: string; name: string; email: string } | null;106107 return {108 id: node.id as string,109 invoiceNumber: node.invoiceNumber as string,110 status: node.status as string,111 invoiceDate: node.invoiceDate as string,112 dueDate: node.dueDate as string | null,113 memo: node.memo as string,114 totalAmount: parseFloat(total?.value ?? '0'),115 amountDue: parseFloat(amountDue?.value ?? '0'),116 currency: total?.currency?.code ?? 'USD',117 currencySymbol: total?.currency?.symbol ?? '$',118 customerName: customer?.name ?? 'Unknown Customer',119 customerEmail: customer?.email ?? '',120 };121 });122123 if (statusFilter) {124 invoices = invoices.filter((inv) => inv.status === statusFilter.toUpperCase());125 }126127 const totalOutstanding = invoices128 .filter((inv) => inv.status !== 'PAID' && inv.status !== 'DRAFT')129 .reduce((sum, inv) => sum + inv.amountDue, 0);130131 const overdue = invoices132 .filter((inv) => inv.status === 'OVERDUE')133 .reduce((sum, inv) => sum + inv.amountDue, 0);134135 return NextResponse.json({136 invoices,137 pageInfo: invoicesConn?.pageInfo,138 summary: {139 totalOutstanding: totalOutstanding.toFixed(2),140 overdue: overdue.toFixed(2),141 invoiceCount: invoices.length,142 },143 });144 } catch (error) {145 const message = error instanceof Error ? error.message : 'Unknown error';146 console.error('Wave API error:', message);147 return NextResponse.json(148 { error: 'Failed to fetch Wave invoices', details: message },149 { status: 500 }150 );151 }152}Pro tip: Wave's GraphQL API uses cursor-based pagination — store the endCursor from pageInfo in your frontend state and pass it as the 'after' parameter to load the next page of invoices rather than using page numbers.
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.
Wire Up the Dashboard and Add Invoice Status Filtering
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.
Update the invoice dashboard to fetch from /api/wave/invoices on mount. Map the response invoices array to table rows with invoice number, customerName, invoiceDate formatted as 'Jan 15, 2025', dueDate formatted similarly, totalAmount formatted as currency with currencySymbol, amountDue formatted as currency, and a status badge. Badge colors: OVERDUE = red, SENT = amber, PARTIAL = orange, PAID = green, DRAFT = gray, SAVED = blue. Populate the KPI cards from the response summary object (totalOutstanding, overdue). When a status tab is clicked, refetch with the status filter. Add a 'Load More' button that passes the pageInfo.endCursor as the 'after' parameter and appends results to the list.
Paste this in V0 chat
Pro tip: Calculate 'days overdue' for OVERDUE invoices by comparing dueDate to today with Math.floor((today - new Date(inv.dueDate)) / 86400000). Display '15 days overdue' on the invoice card to create urgency for follow-up.
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.
Configure Environment Variables and Deploy to Vercel
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.
Pro tip: Wave provides a sandbox environment for testing at developer.waveapps.com — create a test business with sample invoices to develop against before connecting to your production Wave account with real financial data.
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.
Common 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.
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.
Copy this prompt to try it in V0
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.
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.
Copy this prompt to try it in V0
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.
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.
Copy this prompt to try it in V0
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.
1const totalAmount = parseFloat((node.total as {value: string})?.value ?? '0');2const 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.
1// Pass cursor as URL parameter:2const url = `/api/wave/invoices?first=20${cursor ? `&after=${encodeURIComponent(cursor)}` : ''}`;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
Alternatives
Use QuickBooks instead of Wave if your business needs more advanced accounting features like inventory management, payroll processing, or multi-currency reporting — QuickBooks is the industry standard for SMB accounting despite its higher price.
Choose FreshBooks instead of Wave if invoicing and time tracking are your primary needs — FreshBooks has a more polished invoicing experience and better time tracking features, though it requires a paid subscription unlike Wave's free tier.
Use Xero instead of Wave if you need accountant collaboration features, multi-currency support, or payroll integration — Xero is popular with accounting firms and has a more comprehensive API than Wave.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation