Integrate Bolt.new with Wave by obtaining a free API token at developer.waveapps.com, then sending GraphQL queries through a Next.js API route. Wave uses GraphQL instead of REST — every operation (reading invoices, creating customers, sending invoices) is a query or mutation against a single endpoint. Store your token in .env, proxy all calls server-side, and deploy to Netlify or Bolt Cloud before testing invoice sending and webhook events.
Building Free Accounting Features with Wave's GraphQL API
Wave stands out from every other accounting platform in the Bolt.new ecosystem for one reason: it is completely free. QuickBooks starts at $30/month, Xero at $15/month, FreshBooks at $19/month — Wave charges nothing for its core accounting features. For Bolt developers building tools for very small businesses, freelancers, or solo founders, Wave is the obvious starting point that removes financial barrier entirely.
The Wave API uses GraphQL, which is architecturally different from the REST APIs you encounter with most Bolt integrations. Instead of multiple endpoints with different URLs (GET /invoices, POST /customers), GraphQL exposes a single endpoint and lets you specify exactly what data you want in a query language. This means less over-fetching — you only receive the fields you ask for — and a single, unified interface for all operations. The tradeoff is that queries require more upfront authoring than simple REST calls, but the Wave API documentation includes an interactive Explorer that makes this straightforward.
Wave's GraphQL API covers the full accounting surface: businesses (Wave's term for separate company entities), customers, products, invoices, invoice line items, payments, transactions, chart of accounts, and financial reports. For a Bolt app targeting freelancers or micro-businesses, this covers every common use case: creating invoices and sending them by email, tracking which invoices are paid, viewing outstanding balances, and pulling transaction history. All through a well-documented API with a generous free tier.
Integration method
Bolt generates the Wave integration code — GraphQL query/mutation helpers and Next.js API routes — through conversation with the AI. Wave's GraphQL API means all operations use POST requests to a single endpoint, varying the query string rather than the URL path. API calls go through server-side routes to keep your token out of the browser, and outbound calls to Wave work in Bolt's WebContainer preview. Invoice sending and webhook notifications require deployment to a public URL.
Prerequisites
- A Wave account at waveapps.com (free — no subscription required)
- A Wave API token from developer.waveapps.com (free, requires Wave login)
- Your Wave Business ID (visible in the Wave Developer portal after connecting your account)
- A Next.js project in Bolt (prompt: 'Create a Next.js app')
- Basic familiarity with GraphQL queries (variables and operation names — the Wave API Explorer helps)
Step-by-step guide
Get your Wave API token and find your Business ID
Get your Wave API token and find your Business ID
Wave's developer access is straightforward compared to OAuth-based accounting platforms. There is no app registration process, no redirect URIs, and no multi-step approval flow. You simply log into developer.waveapps.com with your Wave account and generate a token. Go to developer.waveapps.com and click 'Applications'. Create a new application, give it a name (e.g., 'My Bolt App'), and click Create. The application appears with a Full Access token. This is your API token — copy it and store it securely. This token grants full access to your Wave account including reading and writing financial data, so treat it like a password. Next, find your Business ID. Every Wave account can have multiple businesses (separate accounting entities). The API requires a business ID for most operations to specify which business you are working with. In the Wave Developer portal, navigate to the GraphQL Explorer (or go to developer.waveapps.com/graphql/explorer). Run this query to find your business IDs: query { businesses(page: 1, pageSize: 10) { edges { node { id name } } } } The business ID looks like a UUID. Copy the ID for the business you want to work with and store it as WAVE_BUSINESS_ID in your .env file alongside WAVE_API_TOKEN. Wave's GraphQL endpoint is https://gql.waveapps.com/graphql/public. All API requests — queries and mutations — go to this single URL as POST requests with the token in the Authorization header and the GraphQL operation in the request body.
Set up Wave GraphQL API integration in my Next.js app. Create a lib/wave.ts utility that exports a waveQuery function accepting an operation string (query or mutation) and variables object. It should POST to https://gql.waveapps.com/graphql/public with the WAVE_API_TOKEN as a Bearer token in the Authorization header and Content-Type: application/json. Parse the response and throw an error if data.errors exists. Return data.data. Store WAVE_API_TOKEN and WAVE_BUSINESS_ID in .env.
Paste this in Bolt.new chat
1// .env.local2WAVE_API_TOKEN=your_wave_api_token_here3WAVE_BUSINESS_ID=your_wave_business_id_uuid45// lib/wave.ts6const WAVE_ENDPOINT = 'https://gql.waveapps.com/graphql/public';78interface GraphQLResponse<T> {9 data: T;10 errors?: Array<{ message: string; locations?: unknown[]; path?: string[] }>;11}1213export async function waveQuery<T>(14 operation: string,15 variables: Record<string, unknown> = {}16): Promise<T> {17 const apiToken = process.env.WAVE_API_TOKEN;1819 if (!apiToken) {20 throw new Error('WAVE_API_TOKEN is not set in environment variables');21 }2223 const response = await fetch(WAVE_ENDPOINT, {24 method: 'POST',25 headers: {26 Authorization: `Bearer ${apiToken}`,27 'Content-Type': 'application/json',28 },29 body: JSON.stringify({ query: operation, variables }),30 });3132 if (!response.ok) {33 throw new Error(`Wave API HTTP error: ${response.status} ${response.statusText}`);34 }3536 const result: GraphQLResponse<T> = await response.json();3738 if (result.errors && result.errors.length > 0) {39 const errorMessages = result.errors.map((e) => e.message).join('; ');40 throw new Error(`Wave GraphQL errors: ${errorMessages}`);41 }4243 return result.data;44}Pro tip: Test your token immediately using the Wave GraphQL Explorer at developer.waveapps.com/graphql/explorer. Run the businesses query to confirm the token is valid and see your business IDs. This interactive explorer is the fastest way to prototype queries before implementing them in your Next.js routes.
Expected result: The waveQuery utility is ready. Testing it with the businesses query returns your Wave business names and IDs, confirming the token and endpoint are correctly configured.
Fetch invoices and customer data via GraphQL queries
Fetch invoices and customer data via GraphQL queries
With the waveQuery utility ready, create Next.js API routes that query Wave for invoice and customer data. These routes receive requests from your React components, execute GraphQL operations against Wave, and return normalized JSON. Wave's invoice query supports filtering by date range, status, and customer. For a dashboard showing outstanding receivables, filter by status IN [SAVED, SENT, VIEWED, OVERDUE] — these are invoices that have been issued but not yet fully paid. The status values in Wave's GraphQL schema are: DRAFT, SAVED, SENT, VIEWED, PARTIAL, OVERDUE, PAID, UNPAID. Wave uses cursor-based pagination for list queries (not offset pagination). The response includes a pageInfo object with hasNextPage and endCursor. For loading all invoices, implement a loop that fetches pages until hasNextPage is false, passing the endCursor as the after variable to the next query. For a dashboard showing recent invoices, fetching the first 50 by invoice date descending is typically sufficient without needing full pagination. The invoice data structure in Wave includes: id, invoiceNumber, status, invoiceDate, dueDate, amountDue (the remaining balance), amountPaid, total, customer (name, email), memo, and lineItems. The lineItems breakdown shows what was billed. Parse these fields to build a clean invoice card component.
Create a /api/wave/invoices route that fetches invoices from Wave using GraphQL. Query the invoices for WAVE_BUSINESS_ID, requesting the first 50 ordered by invoice date descending. Filter to show invoices where status is not DRAFT. Return normalized invoice data: id, invoiceNumber, status, invoiceDate, dueDate, amountDue, amountPaid, total, customerName, customerEmail, and lineItems (description, quantity, unitPrice, subtotal).
Paste this in Bolt.new chat
1// app/api/wave/invoices/route.ts2import { NextRequest, NextResponse } from 'next/server';3import { waveQuery } from '@/lib/wave';45const GET_INVOICES = `6 query GetInvoices($businessId: ID!, $page: Int!, $pageSize: Int!) {7 business(id: $businessId) {8 invoices(page: $page, pageSize: $pageSize) {9 pageInfo {10 currentPage11 totalPages12 totalCount13 }14 edges {15 node {16 id17 invoiceNumber18 status19 invoiceDate20 dueDate21 amountDue {22 value23 currency { symbol }24 }25 amountPaid {26 value27 }28 total {29 value30 }31 customer {32 name33 email34 }35 memo36 lineItems {37 description38 quantity39 unitPrice {40 value41 }42 subtotal {43 value44 }45 }46 }47 }48 }49 }50 }51`;5253export async function GET(request: NextRequest) {54 const { searchParams } = new URL(request.url);55 const page = parseInt(searchParams.get('page') ?? '1', 10);56 const pageSize = Math.min(parseInt(searchParams.get('pageSize') ?? '50', 10), 100);5758 try {59 const businessId = process.env.WAVE_BUSINESS_ID;60 if (!businessId) {61 return NextResponse.json({ error: 'WAVE_BUSINESS_ID not configured' }, { status: 500 });62 }6364 const data = await waveQuery<{65 business: {66 invoices: {67 pageInfo: { currentPage: number; totalPages: number; totalCount: number };68 edges: Array<{ node: Record<string, unknown> }>;69 };70 };71 }>(GET_INVOICES, { businessId, page, pageSize });7273 const invoices = data.business.invoices.edges.map(({ node }) => ({74 id: node.id,75 invoiceNumber: node.invoiceNumber,76 status: node.status,77 invoiceDate: node.invoiceDate,78 dueDate: node.dueDate,79 amountDue: (node.amountDue as Record<string, unknown>)?.value,80 amountPaid: (node.amountPaid as Record<string, unknown>)?.value,81 total: (node.total as Record<string, unknown>)?.value,82 currency: ((node.amountDue as Record<string, unknown>)?.currency as Record<string, unknown>)?.symbol ?? '$',83 customerName: (node.customer as Record<string, unknown>)?.name,84 customerEmail: (node.customer as Record<string, unknown>)?.email,85 memo: node.memo,86 lineItems: node.lineItems,87 }));8889 return NextResponse.json({90 invoices,91 pagination: data.business.invoices.pageInfo,92 });93 } catch (error) {94 const message = error instanceof Error ? error.message : 'Failed to fetch invoices';95 return NextResponse.json({ error: message }, { status: 500 });96 }97}Pro tip: Wave's invoices query uses traditional page/pageSize pagination (not cursor-based). Pass page: 1 initially and increment the page number to load more results. The pageInfo.totalPages tells you how many pages exist. This is simpler than cursor-based pagination for dashboard use cases.
Expected result: Calling /api/wave/invoices returns a JSON array of invoices from your Wave account with amounts, statuses, customer names, and line items.
Create customers and invoices via GraphQL mutations
Create customers and invoices via GraphQL mutations
Wave's GraphQL mutations follow the same pattern as queries — POST to the same endpoint, but with a mutation operation instead of a query. Creating an invoice in Wave is a two-step process: first create the invoice header (customer, dates, memo), then add line items to it. Before creating an invoice, you need a Wave customer ID. Use customerCreate mutation to create a new customer or query existing customers to find a match by email. Wave's customerCreate accepts name (required), email, phone, currency code, and address details. It returns the created customer with an ID that you pass to invoiceCreate. The invoiceCreate mutation creates an invoice header with: businessId, customerId, invoiceDate, dueDate (optional), memo, and status (DRAFT to save without sending, or SAVED to mark as ready to send). After creating the invoice, use invoiceAddLineItems mutation to add line items — each with a description, quantity, unit price, taxIds (optional), and an accountId (Wave's chart of accounts ID for the income category). Finally, to send the invoice to the customer by email, use invoiceSend mutation with the invoice ID. This triggers Wave to email the invoice to the customer using Wave's own email infrastructure — your app does not handle email delivery. Invoice sending requires the customer to have an email address on their Wave record. Note: while you can create and structure invoices in Bolt's WebContainer preview (the API calls are outbound HTTP), you should test the actual invoiceSend mutation carefully — it sends real emails to customers. Create a test customer with your own email address for development.
Create a /api/wave/invoices/create route that accepts a POST body with customerEmail, customerName, lineItems (array of description, quantity, unitPrice), invoiceDate, dueDate, and memo. Use Wave GraphQL mutations to: (1) find or create the customer using customerCreate, (2) create the invoice with invoiceCreate, (3) add line items with invoiceAddLineItems, and optionally (4) send the invoice if sendImmediately is true in the body. Return the created invoice ID and Wave invoice URL.
Paste this in Bolt.new chat
1// app/api/wave/invoices/create/route.ts2import { NextRequest, NextResponse } from 'next/server';3import { waveQuery } from '@/lib/wave';45const CREATE_CUSTOMER = `6 mutation CreateCustomer($input: CustomerCreateInput!) {7 customerCreate(input: $input) {8 didSucceed9 inputErrors { code message path }10 customer { id name email }11 }12 }13`;1415const CREATE_INVOICE = `16 mutation CreateInvoice($input: InvoiceCreateInput!) {17 invoiceCreate(input: $input) {18 didSucceed19 inputErrors { code message path }20 invoice {21 id22 invoiceNumber23 viewUrl24 status25 }26 }27 }28`;2930const ADD_LINE_ITEMS = `31 mutation AddLineItems($input: InvoiceAddLineItemsInput!) {32 invoiceAddLineItems(input: $input) {33 didSucceed34 inputErrors { code message path }35 invoice { id total { value } }36 }37 }38`;3940const SEND_INVOICE = `41 mutation SendInvoice($input: InvoiceSendInput!) {42 invoiceSend(input: $input) {43 didSucceed44 inputErrors { code message path }45 }46 }47`;4849export async function POST(request: NextRequest) {50 const body = await request.json() as {51 customerEmail: string;52 customerName: string;53 lineItems: Array<{ description: string; quantity: number; unitPrice: number }>;54 invoiceDate: string;55 dueDate?: string;56 memo?: string;57 sendImmediately?: boolean;58 };5960 const businessId = process.env.WAVE_BUSINESS_ID!;6162 try {63 // Step 1: Create customer64 const customerData = await waveQuery<{ customerCreate: { didSucceed: boolean; customer: { id: string } } }>(65 CREATE_CUSTOMER,66 {67 input: {68 businessId,69 name: body.customerName,70 email: body.customerEmail,71 currency: { code: 'USD' },72 },73 }74 );7576 if (!customerData.customerCreate.didSucceed) {77 return NextResponse.json({ error: 'Failed to create customer' }, { status: 400 });78 }79 const customerId = customerData.customerCreate.customer.id;8081 // Step 2: Create invoice82 const invoiceData = await waveQuery<{ invoiceCreate: { didSucceed: boolean; invoice: { id: string; invoiceNumber: string; viewUrl: string } } }>(83 CREATE_INVOICE,84 {85 input: {86 businessId,87 customerId,88 invoiceDate: body.invoiceDate,89 dueDate: body.dueDate,90 memo: body.memo ?? '',91 status: 'SAVED',92 },93 }94 );9596 if (!invoiceData.invoiceCreate.didSucceed) {97 return NextResponse.json({ error: 'Failed to create invoice' }, { status: 400 });98 }99 const invoice = invoiceData.invoiceCreate.invoice;100101 // Step 3: Add line items102 await waveQuery(ADD_LINE_ITEMS, {103 input: {104 invoiceId: invoice.id,105 lineItems: body.lineItems.map((item) => ({106 product: { name: item.description },107 quantity: item.quantity,108 unitPrice: item.unitPrice,109 })),110 },111 });112113 // Step 4: Optionally send the invoice114 if (body.sendImmediately) {115 await waveQuery(SEND_INVOICE, {116 input: {117 invoiceId: invoice.id,118 to: [{ name: body.customerName, email: body.customerEmail }],119 subject: `Invoice ${invoice.invoiceNumber}`,120 message: 'Please find your invoice attached.',121 },122 });123 }124125 return NextResponse.json({126 invoiceId: invoice.id,127 invoiceNumber: invoice.invoiceNumber,128 viewUrl: invoice.viewUrl,129 sent: body.sendImmediately ?? false,130 });131 } catch (error) {132 const message = error instanceof Error ? error.message : 'Invoice creation failed';133 return NextResponse.json({ error: message }, { status: 500 });134 }135}Pro tip: Wave's customerCreate mutation creates a new customer even if one with that email already exists. To avoid duplicate customers, query for the customer by email first using a customers query filtered by name or email, and only create a new one if no match is found.
Expected result: Sending a POST request to /api/wave/invoices/create with customer details and line items creates a real Wave invoice, adds line items, and returns the invoice ID and view URL. If sendImmediately is true, the customer receives an email from Wave.
Build an invoicing dashboard UI
Build an invoicing dashboard UI
With the API routes in place, build a React dashboard that shows all Wave invoices with summary statistics and invoice management actions. This dashboard gives business owners a clear view of their receivables without logging into Wave directly. The summary section at the top should show four key numbers: total outstanding (sum of amountDue for all non-paid invoices), total overdue (sum of amountDue for OVERDUE status invoices), total paid this month, and total invoiced this month. Calculate these client-side from the invoices array returned by your API route — no additional API calls needed. Below the summary, display invoices in a sortable table or card list. The most useful columns are: invoice number, customer name, issue date, due date, amount, and status. Color-code status badges: green for PAID, yellow for SENT/VIEWED, orange for OVERDUE, grey for DRAFT. Add a filter row that lets users filter by status — the most common workflow is looking at only unpaid invoices. For the action buttons, include a 'View in Wave' link that opens the Wave invoice editor in a new tab (Wave provides a viewUrl in the invoice data) and, for draft invoices, a 'Send' button that triggers your /api/wave/invoices/[id]/send route. This covers the core workflow without needing to build a full invoice editor. This entire dashboard works in Bolt's WebContainer preview — the API calls to Wave are outbound HTTP, which the WebContainer supports. Only the invoice sending action sends a real email, so test that carefully. The dashboard is production-ready as-is after deploying to Netlify or Bolt Cloud.
Build a WaveInvoiceDashboard React component that fetches invoices from /api/wave/invoices on mount. Show summary cards at the top for: total outstanding, overdue amount, paid this month, and invoiced this month. Below, render an invoice table with columns: Invoice #, Customer, Date, Due Date, Amount, Status (colored badge), Actions (View in Wave link, Send button for non-sent invoices). Add status filter buttons (All, Outstanding, Overdue, Paid). Show a loading state and handle empty states gracefully.
Paste this in Bolt.new chat
1// components/WaveInvoiceDashboard.tsx2'use client';34import { useEffect, useState } from 'react';56interface Invoice {7 id: string;8 invoiceNumber: string;9 status: string;10 invoiceDate: string;11 dueDate: string | null;12 amountDue: number;13 amountPaid: number;14 total: number;15 currency: string;16 customerName: string;17 customerEmail: string;18}1920const STATUS_COLORS: Record<string, string> = {21 PAID: 'bg-green-100 text-green-800',22 SENT: 'bg-blue-100 text-blue-800',23 VIEWED: 'bg-blue-100 text-blue-800',24 OVERDUE: 'bg-red-100 text-red-800',25 DRAFT: 'bg-gray-100 text-gray-600',26 SAVED: 'bg-yellow-100 text-yellow-800',27 PARTIAL: 'bg-orange-100 text-orange-800',28};2930function formatCurrency(amount: number, symbol = '$'): string {31 return `${symbol}${amount.toFixed(2)}`;32}3334export default function WaveInvoiceDashboard() {35 const [invoices, setInvoices] = useState<Invoice[]>([]);36 const [loading, setLoading] = useState(true);37 const [filter, setFilter] = useState<string>('ALL');38 const [error, setError] = useState('');3940 useEffect(() => {41 fetch('/api/wave/invoices')42 .then((r) => r.json())43 .then((data) => {44 if (data.error) throw new Error(data.error);45 setInvoices(data.invoices ?? []);46 })47 .catch((e) => setError(e.message))48 .finally(() => setLoading(false));49 }, []);5051 const now = new Date();52 const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0];5354 const outstanding = invoices.filter((i) => !['PAID', 'DRAFT'].includes(i.status)).reduce((s, i) => s + i.amountDue, 0);55 const overdue = invoices.filter((i) => i.status === 'OVERDUE').reduce((s, i) => s + i.amountDue, 0);56 const paidThisMonth = invoices.filter((i) => i.status === 'PAID' && i.invoiceDate >= thisMonthStart).reduce((s, i) => s + i.total, 0);57 const invoicedThisMonth = invoices.filter((i) => i.invoiceDate >= thisMonthStart).reduce((s, i) => s + i.total, 0);5859 const filtered = filter === 'ALL' ? invoices60 : filter === 'OUTSTANDING' ? invoices.filter((i) => ['SENT', 'VIEWED', 'PARTIAL', 'SAVED'].includes(i.status))61 : filter === 'OVERDUE' ? invoices.filter((i) => i.status === 'OVERDUE')62 : invoices.filter((i) => i.status === 'PAID');6364 if (loading) return <div className="p-8 text-center">Loading Wave invoices...</div>;65 if (error) return <div className="p-8 text-red-600">Error: {error}</div>;6667 return (68 <div className="p-6 max-w-6xl mx-auto">69 <h1 className="text-2xl font-bold mb-6">Wave Invoices</h1>7071 <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">72 {[{label: 'Outstanding', value: outstanding, color: 'text-blue-600'},73 {label: 'Overdue', value: overdue, color: 'text-red-600'},74 {label: 'Paid This Month', value: paidThisMonth, color: 'text-green-600'},75 {label: 'Invoiced This Month', value: invoicedThisMonth, color: 'text-gray-800'},76 ].map(({label, value, color}) => (77 <div key={label} className="border rounded-lg p-4">78 <p className="text-sm text-gray-500">{label}</p>79 <p className={`text-2xl font-bold ${color}`}>{formatCurrency(value)}</p>80 </div>81 ))}82 </div>8384 <div className="flex gap-2 mb-4">85 {['ALL', 'OUTSTANDING', 'OVERDUE', 'PAID'].map((f) => (86 <button key={f} onClick={() => setFilter(f)}87 className={`px-3 py-1 rounded text-sm ${filter === f ? 'bg-blue-600 text-white' : 'bg-gray-100'}`}>88 {f}89 </button>90 ))}91 </div>9293 <div className="overflow-x-auto">94 <table className="w-full text-sm">95 <thead>96 <tr className="border-b">97 <th className="text-left py-2 font-medium">Invoice #</th>98 <th className="text-left py-2 font-medium">Customer</th>99 <th className="text-left py-2 font-medium">Date</th>100 <th className="text-left py-2 font-medium">Due</th>101 <th className="text-right py-2 font-medium">Amount</th>102 <th className="text-left py-2 font-medium">Status</th>103 </tr>104 </thead>105 <tbody>106 {filtered.map((invoice) => (107 <tr key={invoice.id} className="border-b hover:bg-gray-50">108 <td className="py-3">{invoice.invoiceNumber}</td>109 <td className="py-3">{invoice.customerName}</td>110 <td className="py-3">{invoice.invoiceDate}</td>111 <td className="py-3">{invoice.dueDate ?? '—'}</td>112 <td className="py-3 text-right">{formatCurrency(invoice.total, invoice.currency)}</td>113 <td className="py-3">114 <span className={`px-2 py-1 rounded-full text-xs ${STATUS_COLORS[invoice.status] ?? 'bg-gray-100'}`}>115 {invoice.status}116 </span>117 </td>118 </tr>119 ))}120 </tbody>121 </table>122 {filtered.length === 0 && (123 <p className="text-center text-gray-400 py-8">No invoices match this filter.</p>124 )}125 </div>126 </div>127 );128}Pro tip: Wave's invoice amounts are returned as strings in the API response, not numbers. Parse them with parseFloat() before doing arithmetic. The waveQuery function above returns them as-is from the API, so ensure you parse before summing in the summary cards.
Expected result: The Bolt preview shows a Wave invoicing dashboard with summary cards showing outstanding, overdue, and monthly totals, plus a filterable table of invoices with status badges. Data loads from your live Wave account.
Common use cases
Freelancer invoicing dashboard
Build a clean invoicing dashboard for freelancers that shows all Wave invoices with their status (draft, sent, paid, overdue), total outstanding balance, and a one-click button to send a draft invoice to the client. Simpler than navigating Wave's full interface.
Create a Next.js app with a Wave accounting integration. Build an invoicing dashboard that uses Wave's GraphQL API to fetch all invoices for my business, displays them grouped by status (draft, sent, viewed, paid, overdue), shows the total outstanding receivables, and has a Send button that triggers the invoiceSend mutation for draft invoices. Use WAVE_API_TOKEN and WAVE_BUSINESS_ID from .env.
Copy this prompt to try it in Bolt.new
Automatic invoice generation from project completion
When a project is marked complete in your app, automatically create a Wave invoice for the client with the project's line items, then send it immediately. This eliminates manual invoice creation and ensures billing happens the moment work is delivered.
Add Wave integration to my project management app. When a project status changes to 'completed', call the Wave GraphQL API to create an invoice for the associated client using invoiceCreate mutation, add line items for each billable service using invoiceAddLineItems mutation, then immediately send it using invoiceSend mutation. Map project client email to a Wave customer or create one with customerCreate.
Copy this prompt to try it in Bolt.new
Revenue tracking and financial summary widget
Add a financial overview widget to an internal dashboard showing monthly revenue from Wave: total invoiced, total collected, outstanding balance, and a mini chart of monthly trends. Finance-team members see the key numbers without leaving the internal tool.
Build a RevenueWidget React component that fetches invoice data from Wave's GraphQL API via a /api/wave/summary route. Display: total invoiced this month, total payments received this month, outstanding receivables, and overdue amount. Pull invoices for the current month using invoices query filtered by invoiceDate. Show a 3-month comparison using a bar chart.
Copy this prompt to try it in Bolt.new
Troubleshooting
Wave GraphQL API returns errors array with 'Unauthenticated' message
Cause: The WAVE_API_TOKEN environment variable is missing, empty, or incorrect. The Authorization header is being constructed with the wrong token value.
Solution: Verify WAVE_API_TOKEN is set in .env.local and matches exactly what is shown in the Wave Developer portal under your application. The token is a long random string. Add a server-side log to confirm the token is loading: log the first 10 characters. Restart the Next.js dev server after updating .env.local — changes to .env files require a server restart.
1// Debug: add this temporarily to your API route:2console.log('Wave token prefix:', process.env.WAVE_API_TOKEN?.substring(0, 10));GraphQL mutation returns didSucceed: false with inputErrors but no clear message
Cause: A required field in the mutation input is missing, an ID references a non-existent entity, or a field value violates Wave's validation (e.g., invalid currency code, future date required but past date provided).
Solution: Log the full inputErrors array from the mutation response — each error has a code, message, and path indicating which field caused the issue. Common causes: businessId missing, customerId not belonging to your businessId, or line item quantity being 0 or negative.
1// Always log mutation errors:2if (!result.didSucceed) {3 console.error('Wave mutation errors:', JSON.stringify(result.inputErrors, null, 2));4 throw new Error(`Mutation failed: ${result.inputErrors?.[0]?.message}`);5}Invoice creation succeeds but invoiceSend returns an error about missing email
Cause: The Wave customer associated with the invoice does not have an email address on record, or the email was not passed to the invoiceSend mutation's 'to' array.
Solution: Ensure the customer has a valid email in Wave before attempting to send. When creating a customer via customerCreate, always include the email field. For the invoiceSend mutation, explicitly pass the email in the 'to' array even if it is on the customer record — Wave requires it in the mutation.
1// Always pass email explicitly in invoiceSend:2const sendInput = {3 invoiceId: invoice.id,4 to: [{ name: customerName, email: customerEmail }], // Required even if on customer record5 subject: `Invoice ${invoice.invoiceNumber} from YourBusiness`,6 message: 'Thank you for your business. Please find your invoice attached.',7};Wave API calls fail in deployed production environment but work in development
Cause: WAVE_API_TOKEN and WAVE_BUSINESS_ID environment variables are set in .env.local but not in the hosting platform's environment configuration.
Solution: In Netlify, go to Site Settings → Environment Variables and add WAVE_API_TOKEN and WAVE_BUSINESS_ID. In Bolt Cloud, use the Secrets panel. After adding environment variables, trigger a new deployment — the variables are injected at build/runtime and are not retroactively available to existing deployments.
Best practices
- Always proxy Wave API calls through Next.js API routes — never call the Wave GraphQL endpoint from client-side React. Your API token grants full access to your Wave account, including financial data.
- Check the didSucceed boolean on every mutation response before assuming success. Wave returns HTTP 200 even for failed mutations — the error information is in the inputErrors array, not the HTTP status.
- Create a test customer in Wave with your own email address for development. Invoice sending triggers real emails — use test data so development actions do not reach real customers.
- Parse Wave's monetary values (amountDue, total, etc.) with parseFloat() before doing arithmetic. The GraphQL schema returns them as numeric types but they can come through as strings depending on the query structure.
- Use Wave's DRAFT status when creating invoices programmatically. Review the draft in Wave's interface before sending, especially for high-value invoices. Change to SAVED when ready to send.
- Query for existing customers by email before creating new ones to avoid duplicates. Wave does not enforce email uniqueness on customers, so every customerCreate call creates a new record.
- Store your WAVE_BUSINESS_ID as an environment variable, not hardcoded. This makes it easy to switch between Wave businesses (e.g., separate test and production Wave accounts).
Alternatives
QuickBooks is the industry standard in North America with more features and a larger accountant ecosystem, but costs $30+/month — choose it when clients are already on QuickBooks or need advanced reporting.
FreshBooks has better time tracking and a simpler REST API than Wave's GraphQL interface, making it a good choice for freelancers who bill hourly and need integrated time tracking.
Xero dominates in the UK, Australia, and New Zealand and has more robust bank reconciliation and accountant features than Wave, at a cost starting around $15/month.
Zoho Books has a free tier for very small businesses and integrates with 45+ other Zoho apps, making it a stronger choice when users are already in the Zoho ecosystem.
Frequently asked questions
Is Wave's API completely free to use?
Yes. Wave's accounting, invoicing, and reporting features are entirely free, and so is the API. Wave makes money from its paid features (Wave Payroll, Wave Payments) rather than charging for the core accounting platform. You can make unlimited API calls to read and write invoices, customers, and transactions at no cost.
Can I test the Wave integration in Bolt's WebContainer preview?
Yes for API calls — outbound GraphQL queries and mutations to Wave work fine in the WebContainer. You can fetch invoices, create customers, and create invoices during preview. Be careful with invoiceSend during testing since it sends real emails. Create a test customer with your own email address so test invoice sends go to you rather than real customers.
Does Wave support webhooks for real-time notifications?
Wave has limited webhook support in their API. As of 2026, Wave webhooks are primarily available through their premium integrations and may not be available on all plans. For real-time data in your Bolt app, the standard approach is polling — refreshing invoice data on a schedule using setInterval or SWR/React Query with a revalidation interval. Check developer.waveapps.com for current webhook availability.
How do I handle multiple Wave businesses in one Bolt app?
Query the businesses endpoint to list all businesses in your Wave account. Store the selected business ID as a state variable and pass it as the businessId parameter to all subsequent queries and mutations. This allows users to switch between different businesses in your dashboard. In a multi-user app, store each user's preferred Wave business ID in your database.
Can I integrate Wave with other tools — like automatically creating a Wave invoice when a Stripe payment is received?
Yes. After deploying your app, you can receive a Stripe webhook when a payment is made, then use the Wave GraphQL API to create an invoice marked as paid. This creates an accounting record in Wave for every Stripe transaction. The pattern is: Stripe webhook (deployed endpoint receives POST) → create Wave customer if new → create Wave invoice → mark invoice as paid. The entire flow is outbound HTTP calls which are fully compatible with both the WebContainer (for testing) and deployment.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation