Skip to main content
RapidDev - Software Development Agency
bolt-ai-integrationsBolt Chat + API Route

How to Integrate Bolt.new with Wave

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.

What you'll learn

  • How to get a Wave API token and understand Wave's GraphQL API structure
  • How to write GraphQL queries and mutations for invoices, customers, and transactions
  • How to build a Next.js API route that proxies GraphQL requests to Wave
  • How to create and send invoices programmatically through the Wave API
  • How to build an invoicing dashboard that displays Wave financial data in a custom UI
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate19 min read30 minutesOtherApril 2026RapidDev Engineering Team
TL;DR

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 Chat + API Route

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

1

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.

Bolt.new Prompt

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

lib/wave.ts
1// .env.local
2WAVE_API_TOKEN=your_wave_api_token_here
3WAVE_BUSINESS_ID=your_wave_business_id_uuid
4
5// lib/wave.ts
6const WAVE_ENDPOINT = 'https://gql.waveapps.com/graphql/public';
7
8interface GraphQLResponse<T> {
9 data: T;
10 errors?: Array<{ message: string; locations?: unknown[]; path?: string[] }>;
11}
12
13export async function waveQuery<T>(
14 operation: string,
15 variables: Record<string, unknown> = {}
16): Promise<T> {
17 const apiToken = process.env.WAVE_API_TOKEN;
18
19 if (!apiToken) {
20 throw new Error('WAVE_API_TOKEN is not set in environment variables');
21 }
22
23 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 });
31
32 if (!response.ok) {
33 throw new Error(`Wave API HTTP error: ${response.status} ${response.statusText}`);
34 }
35
36 const result: GraphQLResponse<T> = await response.json();
37
38 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 }
42
43 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.

2

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.

Bolt.new Prompt

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

app/api/wave/invoices/route.ts
1// app/api/wave/invoices/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { waveQuery } from '@/lib/wave';
4
5const GET_INVOICES = `
6 query GetInvoices($businessId: ID!, $page: Int!, $pageSize: Int!) {
7 business(id: $businessId) {
8 invoices(page: $page, pageSize: $pageSize) {
9 pageInfo {
10 currentPage
11 totalPages
12 totalCount
13 }
14 edges {
15 node {
16 id
17 invoiceNumber
18 status
19 invoiceDate
20 dueDate
21 amountDue {
22 value
23 currency { symbol }
24 }
25 amountPaid {
26 value
27 }
28 total {
29 value
30 }
31 customer {
32 name
33 email
34 }
35 memo
36 lineItems {
37 description
38 quantity
39 unitPrice {
40 value
41 }
42 subtotal {
43 value
44 }
45 }
46 }
47 }
48 }
49 }
50 }
51`;
52
53export 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);
57
58 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 }
63
64 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 });
72
73 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 }));
88
89 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.

3

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.

Bolt.new Prompt

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

app/api/wave/invoices/create/route.ts
1// app/api/wave/invoices/create/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { waveQuery } from '@/lib/wave';
4
5const CREATE_CUSTOMER = `
6 mutation CreateCustomer($input: CustomerCreateInput!) {
7 customerCreate(input: $input) {
8 didSucceed
9 inputErrors { code message path }
10 customer { id name email }
11 }
12 }
13`;
14
15const CREATE_INVOICE = `
16 mutation CreateInvoice($input: InvoiceCreateInput!) {
17 invoiceCreate(input: $input) {
18 didSucceed
19 inputErrors { code message path }
20 invoice {
21 id
22 invoiceNumber
23 viewUrl
24 status
25 }
26 }
27 }
28`;
29
30const ADD_LINE_ITEMS = `
31 mutation AddLineItems($input: InvoiceAddLineItemsInput!) {
32 invoiceAddLineItems(input: $input) {
33 didSucceed
34 inputErrors { code message path }
35 invoice { id total { value } }
36 }
37 }
38`;
39
40const SEND_INVOICE = `
41 mutation SendInvoice($input: InvoiceSendInput!) {
42 invoiceSend(input: $input) {
43 didSucceed
44 inputErrors { code message path }
45 }
46 }
47`;
48
49export 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 };
59
60 const businessId = process.env.WAVE_BUSINESS_ID!;
61
62 try {
63 // Step 1: Create customer
64 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 );
75
76 if (!customerData.customerCreate.didSucceed) {
77 return NextResponse.json({ error: 'Failed to create customer' }, { status: 400 });
78 }
79 const customerId = customerData.customerCreate.customer.id;
80
81 // Step 2: Create invoice
82 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 );
95
96 if (!invoiceData.invoiceCreate.didSucceed) {
97 return NextResponse.json({ error: 'Failed to create invoice' }, { status: 400 });
98 }
99 const invoice = invoiceData.invoiceCreate.invoice;
100
101 // Step 3: Add line items
102 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 });
112
113 // Step 4: Optionally send the invoice
114 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 }
124
125 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.

4

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.

Bolt.new Prompt

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

components/WaveInvoiceDashboard.tsx
1// components/WaveInvoiceDashboard.tsx
2'use client';
3
4import { useEffect, useState } from 'react';
5
6interface 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}
19
20const 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};
29
30function formatCurrency(amount: number, symbol = '$'): string {
31 return `${symbol}${amount.toFixed(2)}`;
32}
33
34export 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('');
39
40 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 }, []);
50
51 const now = new Date();
52 const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0];
53
54 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);
58
59 const filtered = filter === 'ALL' ? invoices
60 : 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');
63
64 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>;
66
67 return (
68 <div className="p-6 max-w-6xl mx-auto">
69 <h1 className="text-2xl font-bold mb-6">Wave Invoices</h1>
70
71 <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>
83
84 <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>
92
93 <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.

Bolt.new Prompt

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.

Bolt.new Prompt

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.

Bolt.new Prompt

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.

typescript
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.

typescript
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.

typescript
1// Always pass email explicitly in invoiceSend:
2const sendInput = {
3 invoiceId: invoice.id,
4 to: [{ name: customerName, email: customerEmail }], // Required even if on customer record
5 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

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.