Integrating Wave with Lovable uses Edge Functions to call Wave's GraphQL API with OAuth2 authentication. Store your Wave OAuth credentials in Cloud Secrets, implement the authorization code flow for each user, and make GraphQL mutations and queries to manage businesses, customers, invoices, and transactions. Wave's GraphQL-only API requires specific query structure but enables flexible data fetching. Setup takes 45 minutes.
Why integrate Wave with Lovable?
Wave is unique in the accounting software market: it's completely free for core accounting, invoicing, and expense tracking features. Wave monetizes through optional payment processing (2.9% + $0.60 per card transaction) and payroll services, but the accounting and invoicing features have no subscription cost. This makes Wave the natural choice for early-stage founders, bootstrapped businesses, and freelancers who need solid accounting software without a monthly bill.
From a developer perspective, Wave offers something unusual: a GraphQL-only API. Unlike QuickBooks' REST API or FreshBooks' REST endpoints, Wave's entire data model is accessible through a single GraphQL endpoint at https://gql.waveapps.com/graphql/public. This means you write queries to fetch exactly the fields you need and mutations to create or update data — no over-fetching, no multiple endpoint calls for related data. If you're familiar with GraphQL from building Lovable apps with Supabase's PostgREST, Wave's API will feel natural.
The integration is particularly valuable for building client portals that show customers their invoices and payment history without requiring them to log into Wave directly, or for building internal dashboards that aggregate accounting data across multiple Wave businesses (common for bookkeepers managing multiple clients). Wave's OAuth2 flow supports multi-tenant apps where each user connects their own Wave account, enabling these use cases.
Integration method
Wave has no native Lovable connector. Integration requires Supabase Edge Functions to handle Wave's OAuth2 authorization code flow and proxy all GraphQL API requests. Wave uses GraphQL exclusively — there are no REST endpoints. Store OAuth credentials in Cloud Secrets, implement the token exchange and refresh flow, and send GraphQL queries and mutations through the Edge Function to the Wave API endpoint at https://gql.waveapps.com/graphql/public.
Prerequisites
- A Lovable project with Cloud enabled
- A Wave account — sign up free at waveapps.com
- A Wave application registered at developer.waveapps.com to get OAuth2 credentials
- A callback URL configured for your app
- Basic familiarity with GraphQL query syntax
Step-by-step guide
Register a Wave developer application
Register a Wave developer application
Go to developer.waveapps.com and log in with your Wave account credentials. Click 'Create an Application'. Fill in your application name, description, and website URL. Set the Redirect URL to your Lovable app's OAuth callback path (e.g., https://your-app.lovable.app/auth/wave/callback). After creating the application, you'll see your Client ID and Client Secret on the application detail page. Copy both — the Client Secret is shown only on creation and cannot be retrieved again (only regenerated). Wave's OAuth2 scopes are straightforward. The main scope is 'account:* business:*' which grants access to all business data the user owns. For read-only integrations, use 'account:read business:read'. For creating invoices and managing customers, you need 'business:write' included. Wave's authorization URL is https://authorization.waveapps.com/oauth2/authorize. The token endpoint is https://api.waveapps.com/oauth2/token. The GraphQL API endpoint (used for all data operations) is https://gql.waveapps.com/graphql/public.
Pro tip: Wave's developer documentation at developer.waveapps.com includes an interactive GraphQL explorer where you can test queries and mutations against a sandbox using sample data. Spend 10 minutes exploring the schema before writing your Edge Function queries — it's much faster than debugging query errors at runtime.
Expected result: Your Wave application is registered with Client ID and Client Secret saved. The redirect URL is configured. You can see the application in the Wave developer portal.
Store Wave credentials and implement OAuth flow
Store Wave credentials and implement OAuth flow
Open your Lovable project, click '+', select 'Cloud', and expand Secrets. Add WAVE_CLIENT_ID, WAVE_CLIENT_SECRET, and WAVE_REDIRECT_URI as encrypted environment variables. For the OAuth2 authorization flow, build a 'Connect Wave' button that redirects users to Wave's authorization URL with the required parameters: client_id, redirect_uri, response_type=code, and scope. Include a state parameter (random UUID stored in Supabase against the user_id) to prevent CSRF attacks. At the callback URL, extract the authorization code from the query parameters, verify the state parameter matches the stored value, then call a Wave OAuth Edge Function that exchanges the code for tokens. POST to https://api.waveapps.com/oauth2/token with grant_type=authorization_code, code, client_id, client_secret, and redirect_uri. Store the returned access_token and refresh_token in a wave_tokens Supabase table. Wave access tokens expire after 24 hours and refresh tokens expire after 30 days. Implement token refresh using grant_type=refresh_token before making API calls when the token is within 1 hour of expiry.
Create a Supabase Edge Function at supabase/functions/wave-oauth/index.ts. Handle: action='exchange' with code and user_id — POST to Wave token URL; fetch user's Wave businesses via GraphQL query after token exchange; store access_token, refresh_token, expires_at, and default business_id in wave_tokens table using service role key. action='get-token' with user_id — return valid (auto-refreshed if needed) access_token and business_id from wave_tokens.
Paste this in Lovable chat
1// supabase/functions/wave-oauth/index.ts2import { serve } from "https://deno.land/std@0.168.0/http/server.ts";3import { createClient } from "https://esm.sh/@supabase/supabase-js@2";45const CLIENT_ID = Deno.env.get("WAVE_CLIENT_ID") ?? "";6const CLIENT_SECRET = Deno.env.get("WAVE_CLIENT_SECRET") ?? "";7const REDIRECT_URI = Deno.env.get("WAVE_REDIRECT_URI") ?? "";8const TOKEN_URL = "https://api.waveapps.com/oauth2/token";9const GQL_URL = "https://gql.waveapps.com/graphql/public";10const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization" };1112serve(async (req) => {13 if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });14 const supabase = createClient(Deno.env.get("SUPABASE_URL") ?? "", Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "");15 const body = await req.json();1617 try {18 if (body.action === "exchange") {19 const { code, user_id } = body;20 const res = await fetch(TOKEN_URL, {21 method: "POST",22 headers: { "Content-Type": "application/x-www-form-urlencoded" },23 body: new URLSearchParams({ grant_type: "authorization_code", code, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, redirect_uri: REDIRECT_URI }),24 });25 const data = await res.json();26 if (!data.access_token) throw new Error(data.error_description ?? "Token exchange failed");2728 // Fetch businesses29 const gqlRes = await fetch(GQL_URL, {30 method: "POST",31 headers: { "Authorization": `Bearer ${data.access_token}`, "Content-Type": "application/json" },32 body: JSON.stringify({ query: `query { businesses(page: 1, pageSize: 10) { edges { node { id name } } } }` }),33 });34 const gqlData = await gqlRes.json();35 const businessId = gqlData.data?.businesses?.edges?.[0]?.node?.id ?? "";3637 const expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString();38 await supabase.from("wave_tokens").upsert({ user_id, access_token: data.access_token, refresh_token: data.refresh_token, expires_at: expiresAt, business_id: businessId }, { onConflict: "user_id" });39 return new Response(JSON.stringify({ success: true, business_id: businessId }), { headers: { ...corsHeaders, "Content-Type": "application/json" } });40 }4142 if (body.action === "get-token") {43 const { user_id } = body;44 const { data: token } = await supabase.from("wave_tokens").select("*").eq("user_id", user_id).single();45 if (!token) throw new Error("No Wave connection found.");4647 if (new Date(token.expires_at) < new Date(Date.now() + 3_600_000)) {48 const res = await fetch(TOKEN_URL, {49 method: "POST",50 headers: { "Content-Type": "application/x-www-form-urlencoded" },51 body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: token.refresh_token, client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),52 });53 const data = await res.json();54 const expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString();55 await supabase.from("wave_tokens").update({ access_token: data.access_token, refresh_token: data.refresh_token, expires_at: expiresAt }).eq("user_id", user_id);56 return new Response(JSON.stringify({ access_token: data.access_token, business_id: token.business_id }), { headers: { ...corsHeaders, "Content-Type": "application/json" } });57 }58 return new Response(JSON.stringify({ access_token: token.access_token, business_id: token.business_id }), { headers: { ...corsHeaders, "Content-Type": "application/json" } });59 }6061 return new Response(JSON.stringify({ error: "Unknown action" }), { status: 400, headers: corsHeaders });62 } catch (err) {63 return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });64 }65});Pro tip: Wave uses UUIDs for all resource IDs (businessId, customerId, invoiceId). These are formatted as 'QnVzaW5lc3M6...' (base64-encoded strings). Always store these complete strings as IDs — never try to parse or transform them.
Expected result: The wave-oauth Edge Function handles token exchange and refresh. After OAuth consent, the user's Wave access token and default business ID are stored in Supabase.
Create the Wave GraphQL data Edge Function
Create the Wave GraphQL data Edge Function
All Wave data operations use GraphQL through a single endpoint. Unlike REST APIs where each resource type has its own URL, you send all queries and mutations to https://gql.waveapps.com/graphql/public with the query or mutation in the request body. Create a central Wave data Edge Function that accepts a query/mutation string and variables object, obtains the user's access token via wave-oauth, and POSTs the GraphQL request to Wave. This pattern works for any Wave operation rather than hardcoding specific operations in separate functions. Key Wave GraphQL operations: Query businesses to list all businesses and get the businessId needed for other operations. Query invoices(businessId, page, pageSize) to list invoices with their status, amounts, and customer info. Mutation invoiceCreate(input: {businessId, customerId, ...}) to create new invoices. Query customers(businessId) to list customers. Mutation customerCreate(input: {businessId, name, email, ...}) to add new customers. Because Wave's responses nest deeply — invoices are inside businesses, which are inside the user — structure your GraphQL queries to fetch exactly what you need. Avoid fetching entire business trees when you only need invoice summaries. Use pagination (page and pageSize arguments) to avoid timeouts on accounts with large transaction histories.
Create a Supabase Edge Function at supabase/functions/wave-gql/index.ts. It should: get user's access_token and business_id from wave-oauth; accept a GraphQL query string and variables in the POST body; POST the query to https://gql.waveapps.com/graphql/public with Authorization Bearer header; return the GraphQL response data. Add error handling for GraphQL errors (data.errors array). Support pre-built action shortcuts: action='list-invoices' runs the invoices query with the user's business_id; action='list-customers' runs the customers query.
Paste this in Lovable chat
Pro tip: Wave's GraphQL API returns errors in two places: HTTP-level errors (4xx/5xx status) and GraphQL-level errors in data.errors[]. Always check both. A successful HTTP 200 response can still contain GraphQL errors if your query syntax is wrong or references invalid IDs.
Expected result: The wave-gql Edge Function accepts GraphQL queries and returns Wave data. Calling it with action='list-invoices' returns all invoices for the connected Wave business.
Build the Wave accounting dashboard
Build the Wave accounting dashboard
With the GraphQL Edge Function working, build the Wave accounting dashboard. The key difference from REST-based dashboards is that you compose your GraphQL queries to fetch exactly the data shape your UI needs rather than making multiple sequential API calls. For an invoicing overview, write a single GraphQL query that fetches the business name, total outstanding receivables, recent invoices with customer names and amounts, and invoice status counts all in one request. This is one of GraphQL's main advantages — your Edge Function makes one network call instead of four. For customer management, build a customer list that fetches customers with their invoice counts and total billed amounts. The Wave GraphQL schema allows querying customer invoices as a nested field within the customer query — ask Lovable to write the query that fetches customers with their associated invoice summaries. For creating invoices, build a form that lets users select a customer (from the Wave customers query), add line items with description, quantity, and unit price, set a due date, and click 'Create Invoice'. The form sends a invoiceCreate GraphQL mutation with the appropriate variables. After creation, Wave returns the invoiceId and invoice number.
Build a Wave accounting dashboard with three views: 1) Invoices — list all invoices from Wave with status badges (draft/sent/viewed/paid/overdue), client name, amount, due date; add a Create Invoice button that opens a form to select customer, add line items, and call invoiceCreate mutation. 2) Customers — list all Wave customers with name, email, and count of invoices; add a New Customer button using customerCreate mutation. 3) Summary — show total outstanding invoices, total paid this month, and number of overdue invoices from GraphQL query data. Use the wave-gql Edge Function for all data operations.
Paste this in Lovable chat
Pro tip: Wave's GraphQL invoice status values are: DRAFT, SAVED, OVERDUE, PARTIAL, PAID, SENT, VIEWED. Map these to human-readable labels and color codes in your frontend: OVERDUE → red, PARTIAL → yellow, PAID → green, DRAFT/SAVED → gray, SENT/VIEWED → blue.
Expected result: The Wave dashboard shows real invoices and customers from the connected Wave account. Creating a new invoice via the form runs the GraphQL mutation and the new invoice appears in Wave.
Common use cases
Free invoicing portal for small business customers
A customer self-service portal shows clients their invoice history, outstanding balances, and payment status from Wave. The Edge Function runs GraphQL queries against Wave's API to fetch invoices for the authenticated customer. Customers can see all their invoices with amounts, due dates, and payment status without needing a Wave account.
Build a client invoice portal. Create an Edge Function that runs a Wave GraphQL query to fetch all invoices for a specific customer by email address. Return invoice number, issue date, due date, status (draft/sent/viewed/paid/overdue), subtotal, taxes, and total for each invoice. Build a page where logged-in customers can see their invoice history sorted by date, with outstanding invoices highlighted. Show total amount outstanding at the top.
Copy this prompt to try it in Lovable
Automated invoice creation from project completions
A project management app automatically creates Wave invoices when a project milestone is marked complete. An Edge Function runs a Wave GraphQL mutation to create an invoice for the client associated with the project, with line items from the project's deliverables and hours logged. The invoice is created as a draft for the business owner to review before sending.
When a project is marked complete, automatically create a Wave invoice draft. The Edge Function should use the GraphQL invoiceCreate mutation with the businessId from the user's Wave account, the customerId from the associated client record, an invoiceDueDate of 30 days from today, and line items from the project's deliverables stored in Supabase. After creation, save the Wave invoiceId to the Supabase project record and show a success message with a link to review the invoice in Wave.
Copy this prompt to try it in Lovable
Bookkeeper dashboard for multiple Wave businesses
A bookkeeping tool lets accountants manage multiple Wave businesses from one dashboard. After connecting all client Wave accounts through OAuth, the Edge Function queries each business's transactions, invoices, and balances. A unified view shows which clients have outstanding invoices, recent transactions needing categorization, and cash balance by business.
Build a multi-business bookkeeper dashboard. After connecting multiple Wave accounts via OAuth, the dashboard shows a card for each connected business with: business name, total outstanding receivables (sum of unpaid invoice amounts), current bank balance from connected accounts, and count of uncategorized transactions. Clicking a business card opens a detail view with their last 30 days of transactions from Wave's transactions GraphQL query.
Copy this prompt to try it in Lovable
Troubleshooting
GraphQL query returns 'Unauthorized' or null data with no error message
Cause: The access token is missing, expired, or the Authorization header format is incorrect. Wave requires the header to be exactly 'Authorization: Bearer {token}' with a space after 'Bearer'.
Solution: Check Cloud Logs for the exact Authorization header being sent. Verify the token from wave-oauth is being passed correctly to the GraphQL request. Confirm the wave_tokens table has a valid token for the user_id being queried. Test with a simple 'query { businesses { edges { node { id } } } }' query to isolate whether it's an auth issue or a query issue.
1headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json" }invoiceCreate mutation returns error 'Customer not found'
Cause: The customerId passed to the mutation must be the Wave-format ID (a base64-encoded string like 'Q3VzdG9tZXI6...'), not a simple integer or your app's customer ID. Using an integer customer ID causes this error.
Solution: Fetch the correct Wave customer ID from the Wave customers GraphQL query before creating an invoice. Store Wave customer IDs in your Supabase table alongside your own customer records when you sync Wave data. Always use the Wave ID (the id field in GraphQL responses) for subsequent Wave operations.
OAuth state parameter mismatch error after Wave authorization
Cause: The state parameter generated before redirecting to Wave doesn't match the state returned in the callback URL. This can happen if the state is stored in browser memory (which is lost on redirect) or if there are multiple redirect attempts.
Solution: Store the OAuth state value in Supabase (not browser memory) against the user_id before redirecting to Wave. In the callback handler, retrieve the stored state from Supabase and compare it to the state query parameter. Clear the stored state after a successful match. This ensures the state persists through the browser redirect.
Best practices
- Use GraphQL fragments to define reusable field selections — define an InvoiceFields fragment once and include it in both list and detail queries rather than repeating the same fields.
- Cache Wave business IDs and customer IDs in Supabase to avoid re-fetching them on every operation — these IDs don't change and re-fetching them adds unnecessary latency.
- Wave's free tier has no API rate limits published, but implement respectful polling — don't refresh Wave data more than once per minute in automated workflows.
- Handle Wave's pagination explicitly in your Edge Functions for accounts with many invoices — Wave paginates at 10 items by default. Use the page and pageSize arguments and check the pageInfo.hasNextPage field to detect when more data is available.
- Store OAuth refresh tokens securely with the same RLS policies as access tokens — refresh tokens are long-lived and grant the same access as access tokens when used at the token endpoint.
- Build a clear 'Disconnect Wave' flow that deletes the wave_tokens record and informs users that previously synced data remains in your app (you own the Supabase data, Wave just provided it).
- Wave is completely free for invoicing and accounting but charges for payment processing. If you're building payment features, clearly communicate in your UI that clicking 'Pay Now' via Wave incurs Wave's processing fees.
Alternatives
FreshBooks offers a more polished invoicing experience with time tracking and project features but requires a paid subscription; choose FreshBooks when users need more than Wave's free features.
QuickBooks provides enterprise-grade accounting with payroll and tax features for growing businesses that have outgrown Wave's capabilities.
Xero is a strong alternative to Wave in the UK, Australia, and New Zealand markets where it has better regional accounting compliance than Wave.
Frequently asked questions
Is Wave's API truly free to use?
Yes — Wave's accounting and invoicing features are free, and access to the API for those features is also free. Wave earns revenue from optional payment processing (credit card fees) and payroll services. There are no API subscription costs, no per-request charges for the accounting API, and no limits on the number of invoices or customers you can manage through the API.
Why does Wave use GraphQL instead of REST?
Wave adopted GraphQL because it allows clients to request exactly the data they need in a single request — instead of calling separate endpoints for customers, invoices, and payments, one GraphQL query can fetch a customer with their invoices and payment totals. This reduces over-fetching and eliminates waterfall requests. For a Lovable app, this means your Edge Function can often make a single Wave API call instead of multiple REST calls for complex dashboard data.
Can I process payments through Wave from my Lovable app?
Wave's payment processing (Wave Payments) is primarily configured through the Wave dashboard rather than the API. The API supports invoice creation with payment links that direct customers to Wave's payment page. For custom payment flows where users pay directly within your Lovable app, you would need to integrate Stripe or another payment processor separately rather than routing through Wave's payment infrastructure.
Does Wave work for businesses outside the US and Canada?
Wave's free accounting features work globally — the API supports any currency and any business location. However, Wave's paid features (Wave Payments for card processing and Wave Payroll) are only available in the US and Canada. Businesses in other countries can use Wave for invoicing and accounting via the API, but they'll need a separate payment processor integration for accepting online payments.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation