To use Expensify with V0 by Vercel, create a Next.js API route that calls the Expensify Integration Server API using your partnerUserID and partnerUserSecret stored in Vercel environment variables. V0 generates your expense tracking dashboard UI; the API route handles authenticated Expensify requests to submit expense reports, fetch report data, and manage receipts. Expensify's API uses a JSON command structure with policy-based access control.
Build Custom Expense Tracking Dashboards by Connecting Expensify to Your V0 App
Expensify dominates corporate expense management, but its built-in interface is designed for end users — not for embedding expense data into custom operational dashboards. With Expensify's Integration Server API, you can pull expense report data into your V0-generated Next.js app, build approval workflows tailored to your organization's structure, and display spending analytics that Expensify's own reports do not provide.
Expensify's API differs from most REST APIs you have encountered. It uses a command-based structure where each request specifies a type (like create, get, or update) and an inputSettings object describing what to act on. Authentication uses partnerUserID and partnerUserSecret credentials that are separate from your regular Expensify login. This structure means V0-generated code may not match the Expensify API exactly — the step-by-step code in this guide shows the correct request format.
The most common use cases are reading expense reports for dashboard display and programmatically creating expense entries. If you are building a team expense tracker, a manager approval portal, or a spend analytics view, the Expensify API provides the data layer and your V0-generated frontend provides the custom interface.
Integration method
Expensify integrates with V0-generated Next.js apps through server-side API routes that call the Expensify Integration Server at integrations.expensify.com. Authentication uses partnerUserID and partnerUserSecret credentials from your Expensify developer account. V0 generates your expense dashboard UI; the API route proxies all Expensify API calls so credentials never reach the browser.
Prerequisites
- A V0 account at v0.dev with a Next.js project created
- An Expensify account with API access (available on Control plan for full features)
- Expensify Integration Server credentials — partnerUserID and partnerUserSecret from expensify.com/tools/integrations
- A Vercel account connected to your V0 project for deployment
- Your Expensify Policy ID (found in Expensify Settings → Policies) for creating reports
Step-by-step guide
Get Your Expensify Integration Server Credentials
Get Your Expensify Integration Server Credentials
Expensify uses a separate credential system for API access called Integration Server credentials — these are different from your normal Expensify email and password. To get your credentials, navigate to expensify.com/tools/integrations while logged into your Expensify account. On this page, you will find your partnerUserID (a number that identifies your Expensify account) and you can generate or view your partnerUserSecret. If this is your first time accessing the Integration Server, click 'Generate Integration Server Credentials' or similar. The partnerUserID looks like a long numeric string, and the partnerUserSecret is an alphanumeric secret key. Copy both values immediately and store them in a secure location like a password manager. Expensify's Integration Server API base URL is https://integrations.expensify.com/Integration-Server/ExpensifyIntegrations. All requests are POST requests with a requestJobDescription parameter containing a JSON string. This is unusual compared to REST APIs — instead of different URL paths, all Expensify API actions use the same URL with different command types in the request body. You will also need your Expensify Policy ID to create and query reports. Find it in Expensify → Settings → Policies → click your policy → the URL contains the policy ID (e.g., policyIDXXXXXXXX). For paid Control plans, you can have multiple policies for different departments.
Pro tip: Expensify's Integration Server credentials are account-scoped, not application-scoped. If the person who generated the credentials leaves your organization, you will need to regenerate them. Consider using a shared service account email for the Expensify account that owns the API credentials.
Expected result: You have your partnerUserID, partnerUserSecret, and Policy ID ready. These three values are needed to authenticate API requests and scope them to the correct Expensify policy.
Create the Expensify API Route
Create the Expensify API Route
Create app/api/expensify/route.ts as your main Expensify proxy. Expensify's Integration Server API uses an unusual request format: all requests are POST to the same endpoint, and the request body contains a requestJobDescription parameter with a JSON-encoded command object. This differs significantly from standard REST APIs where different operations use different URLs and HTTP methods. The requestJobDescription object has two key fields: type (the operation type: 'get', 'create', 'update') and credentials (your partnerUserID and partnerUserSecret). Additional fields depend on the command type: inputSettings for what data to fetch or create, outputSettings for formatting the response. For fetching expense reports, the type is 'get' and inputSettings specifies type: 'reportInfos' along with filters for date range, policy, and report status. For creating a report, type is 'create' and inputSettings describes the report structure. Because Expensify returns different response shapes depending on the command type, implement specific handler functions for each operation rather than a single generic proxy. This makes error handling cleaner and lets you transform the response into a consistent shape for your frontend before sending it.
Create an expense reports list component that calls GET /api/expensify/reports with optional status and date filters. Display each report as a table row with report ID, submitter name, total amount formatted as USD currency, submission date, and a status badge. Add filter controls at the top for status (select) and date range (two date inputs). Show a loading skeleton while data loads.
Paste this in V0 chat
1import { NextRequest, NextResponse } from 'next/server';23const EXPENSIFY_API_URL = 'https://integrations.expensify.com/Integration-Server/ExpensifyIntegrations';45function getCredentials() {6 const partnerUserID = process.env.EXPENSIFY_PARTNER_USER_ID;7 const partnerUserSecret = process.env.EXPENSIFY_PARTNER_USER_SECRET;8 if (!partnerUserID || !partnerUserSecret) {9 throw new Error('Expensify credentials not configured in environment variables');10 }11 return { partnerUserID, partnerUserSecret };12}1314async function callExpensify(command: object): Promise<Response> {15 const body = new URLSearchParams({16 requestJobDescription: JSON.stringify(command),17 });1819 return fetch(EXPENSIFY_API_URL, {20 method: 'POST',21 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },22 body: body.toString(),23 });24}2526export async function GET(request: NextRequest) {27 const { searchParams } = new URL(request.url);28 const status = searchParams.get('status') || 'OPEN';29 const startDate = searchParams.get('from') || '2024-01-01';30 const endDate = searchParams.get('to') || new Date().toISOString().split('T')[0];31 const policyID = process.env.EXPENSIFY_POLICY_ID;3233 try {34 const { partnerUserID, partnerUserSecret } = getCredentials();3536 const command = {37 type: 'get',38 credentials: { partnerUserID, partnerUserSecret },39 inputSettings: {40 type: 'reportInfos',41 filters: {42 startDate,43 endDate,44 status,45 ...(policyID && { policyIDList: [policyID] }),46 },47 },48 };4950 const response = await callExpensify(command);5152 if (!response.ok) {53 return NextResponse.json(54 { error: `Expensify API returned ${response.status}` },55 { status: response.status }56 );57 }5859 const data = await response.json();6061 // Expensify returns responseCode 200 inside the JSON even on errors62 if (data.responseCode && data.responseCode !== 200) {63 return NextResponse.json(64 { error: data.responseMessage || 'Expensify API error' },65 { status: 400 }66 );67 }6869 return NextResponse.json(data);70 } catch (error) {71 console.error('Expensify API error:', error);72 return NextResponse.json({ error: 'Failed to fetch expense reports' }, { status: 500 });73 }74}7576export async function POST(request: NextRequest) {77 const body = await request.json();78 const { title, amount, category, date, description, policyID } = body;7980 if (!title || !amount) {81 return NextResponse.json({ error: 'Report title and amount are required' }, { status: 400 });82 }8384 try {85 const { partnerUserID, partnerUserSecret } = getCredentials();86 const effectivePolicyID = policyID || process.env.EXPENSIFY_POLICY_ID;8788 const command = {89 type: 'create',90 credentials: { partnerUserID, partnerUserSecret },91 inputSettings: {92 type: 'report',93 policyID: effectivePolicyID,94 report: {95 title,96 fields: { description: description || '' },97 },98 expenses: [99 {100 date: date || new Date().toISOString().split('T')[0],101 currency: 'USD',102 amount: Math.round(amount * 100), // Expensify uses cents103 category: category || 'General',104 comment: description || '',105 },106 ],107 },108 };109110 const response = await callExpensify(command);111 const data = await response.json();112113 if (data.responseCode && data.responseCode !== 200) {114 return NextResponse.json(115 { error: data.responseMessage || 'Failed to create expense report' },116 { status: 400 }117 );118 }119120 return NextResponse.json({ reportID: data.reportID, success: true }, { status: 201 });121 } catch (error) {122 console.error('Expensify create error:', error);123 return NextResponse.json({ error: 'Failed to create expense report' }, { status: 500 });124 }125}Pro tip: Expensify amounts are always in cents (integers), not dollars. When receiving dollar amounts from your form, multiply by 100 before sending to the API. When displaying amounts from Expensify, divide by 100 to show the dollar value.
Expected result: The API route is ready to proxy Expensify requests. Calling GET /api/expensify/reports locally will return a 500 error until credentials are configured — this is expected behavior.
Handle Expensify Webhooks for Status Updates
Handle Expensify Webhooks for Status Updates
Expensify can notify your app when expense reports change status (submitted, approved, reimbursed) via webhooks. Create app/api/expensify/webhook/route.ts to receive these notifications. Webhooks let your dashboard update in near-real-time when finance approves or rejects a report, without polling the API repeatedly. Expensify sends webhook payloads as form-encoded POST requests with a payload parameter containing JSON. The payload includes the report ID, new status, and the action that triggered the change. Parse the form body, extract the payload JSON, and use the data to update your application state — for example, updating a database record or sending a Slack notification to the report submitter. Register your webhook URL in Expensify by going to Settings → Policies → your policy → Integrations → Add webhook URL. Enter your deployed Vercel URL followed by /api/expensify/webhook. Expensify webhooks only work with publicly accessible URLs — localhost does not work. Use your Vercel Preview URL for testing during development. V0 cannot generate the correct Expensify webhook handler because it uses non-standard form-encoded payloads rather than JSON. The code below shows the correct parsing approach. Note that V0's limitation here is typical: it generates structurally correct code but may use request.json() instead of parsing form-encoded data.
1import { NextRequest, NextResponse } from 'next/server';23export async function POST(request: NextRequest) {4 try {5 // Expensify sends webhooks as form-encoded data, not JSON6 const formData = await request.formData();7 const payloadRaw = formData.get('payload');89 if (!payloadRaw || typeof payloadRaw !== 'string') {10 // Some Expensify webhook versions send JSON directly11 const jsonBody = await request.json().catch(() => null);12 if (!jsonBody) {13 return NextResponse.json({ error: 'Invalid webhook payload' }, { status: 400 });14 }15 // Handle JSON payload16 console.log('Expensify webhook (JSON):', JSON.stringify(jsonBody));17 return NextResponse.json({ received: true });18 }1920 const payload = JSON.parse(payloadRaw);21 const { reportID, status, action } = payload;2223 console.log(`Expensify webhook: report ${reportID} status changed to ${status} via ${action}`);2425 // Add your business logic here:26 // - Update database record27 // - Send Slack/email notification28 // - Trigger downstream workflows2930 // Always return 200 quickly — Expensify retries on non-200 responses31 return NextResponse.json({ received: true });32 } catch (error) {33 console.error('Expensify webhook error:', error);34 // Return 200 anyway to prevent Expensify retry loops35 return NextResponse.json({ received: true });36 }37}Pro tip: Always return HTTP 200 from webhook handlers, even when you encounter an error processing the payload. Returning non-200 causes Expensify to retry the webhook repeatedly, which can create a retry storm that overwhelms your API route.
Expected result: The webhook route is ready to receive Expensify notifications. After registering the URL in Expensify, status changes on expense reports will trigger POST requests to this endpoint.
Add Environment Variables and Deploy
Add Environment Variables and Deploy
Add your Expensify credentials to Vercel environment variables so your deployed API routes can authenticate with the Integration Server. You need three values: EXPENSIFY_PARTNER_USER_ID (your numeric partner user ID), EXPENSIFY_PARTNER_USER_SECRET (your partner user secret), and EXPENSIFY_POLICY_ID (your Expensify policy ID for scoping reports). In Vercel Dashboard → your project → Settings → Environment Variables, add each variable. None of these should have the NEXT_PUBLIC_ prefix — they are all server-only secrets used exclusively in your API routes. After adding all three variables, trigger a redeployment. The safest way is to go to Deployments → your latest deployment → Redeploy (with the 'Use existing Build Cache' option unchecked to ensure a clean build). To verify the integration is working after deployment, call your /api/expensify/reports endpoint from the browser and check for a valid JSON response from Expensify. If you see a 400 error with a message about invalid credentials, double-check the EXPENSIFY_PARTNER_USER_ID value — it should be the numeric ID, not your email address, and partnerUserID is case-sensitive in Expensify's API.
Pro tip: Expensify's Integration Server has rate limits that are not publicly documented. For dashboard pages viewed frequently, add next: { revalidate: 300 } to your GET fetch calls to cache report data for 5 minutes and reduce API calls significantly.
Expected result: All three Expensify environment variables are set in Vercel. After redeployment, the expense reports dashboard loads and displays reports from your Expensify policy.
Common use cases
Team Expense Report Dashboard
Build a manager dashboard that shows pending expense reports across the team, with approval status, total amounts, and submitter details. Useful for finance teams who need a summary view without logging into Expensify directly.
Create an expense report dashboard that calls /api/expensify/reports?status=processing and displays reports in a table with columns: submitter name, report title, total amount (formatted as currency), submitted date, status badge (pending/approved/rejected), and an Approve button. Sort by submitted date descending. Add a summary row showing total pending amount.
Copy this prompt to try it in V0
Expense Submission Form for Field Teams
Create a simplified expense submission form that collects expense details and submits them directly to Expensify as a new report, bypassing the need for field employees to use the full Expensify interface.
Build an expense submission form with fields for expense title, date (date picker), amount (currency input with USD default), category (select with options: Travel, Meals, Equipment, Other), description (textarea), and a file upload for receipt images. On submit, call POST /api/expensify/reports. Show a confirmation with the Expensify report ID when successful.
Copy this prompt to try it in V0
Monthly Spend Analytics by Department
Fetch approved expense reports filtered by date range and policy, then aggregate the data to show department-level spending trends. Combine Expensify report data with your own department/employee mapping for richer analytics.
Create a monthly expense analytics page with a bar chart showing spend by category for the selected month (use a month picker at the top). Call /api/expensify/analytics?month=2024-01. Below the chart, show a breakdown table with category, total amount, and percentage of total spend. Use Recharts for the bar chart.
Copy this prompt to try it in V0
Troubleshooting
Expensify API returns responseCode 410 or 'Invalid credentials'
Cause: The partnerUserID or partnerUserSecret is incorrect. A common mistake is using the Expensify account email address as the partnerUserID instead of the numeric partner user ID from the Integration Server page.
Solution: Go to expensify.com/tools/integrations and copy the partnerUserID exactly as shown — it is a numeric value, not an email. Regenerate the partnerUserSecret if you are unsure whether the current one is correct. Update both values in Vercel environment variables and redeploy.
API route returns data but all monetary amounts show as unusually large numbers
Cause: Expensify stores all amounts in cents (integer). A $50.00 expense is stored as 5000 in the API. If your frontend displays the raw value, it will show 5000 instead of $50.00.
Solution: Divide Expensify amount values by 100 when displaying them, and format using Intl.NumberFormat for proper currency display.
1// Format Expensify cents to dollars2const formatAmount = (cents: number, currency = 'USD') =>3 new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cents / 100);45// Usage: formatAmount(report.total) → '$50.00'Expense report creation succeeds but the report does not appear in Expensify dashboard
Cause: The report was created under a different policy than you are viewing, or the Expensify account associated with the API credentials does not have access to the policy where the report was submitted.
Solution: Verify that EXPENSIFY_POLICY_ID matches the policy you are viewing in Expensify. Check the response data from the create call — it includes a reportID that you can look up directly in Expensify by searching for the report ID. The report may also be in a draft state rather than submitted.
Expensify webhook receives data in development (via ngrok) but not on Vercel deployment
Cause: The webhook URL registered in Expensify still points to a development URL (ngrok or localhost) rather than the Vercel deployment URL.
Solution: Go to Expensify Settings → Policies → your policy → Integrations → webhook URL and update it to your Vercel deployment URL (e.g., https://your-app.vercel.app/api/expensify/webhook). Remember to also update the webhook URL when you create new Vercel deployments with custom domains.
Best practices
- Store Expensify credentials (partnerUserID, partnerUserSecret, policyID) as server-only environment variables without the NEXT_PUBLIC_ prefix — they authenticate corporate expense data and must never reach the browser.
- Always convert amounts when interfacing with Expensify — the API uses integer cents, so divide by 100 for display and multiply by 100 when creating expenses from user-entered dollar amounts.
- Return HTTP 200 from webhook handlers even when encountering processing errors to prevent Expensify from retrying webhooks and creating a flood of duplicate requests.
- Cache GET responses for report listing endpoints with next: { revalidate: 300 } since expense report data does not change frequently and caching significantly reduces API quota usage.
- Use a dedicated Expensify service account (not a personal account) for API credentials so the integration does not break when team members change their passwords or leave the organization.
- Log all Expensify API response codes to Vercel Function Logs — Expensify returns non-200 response codes inside successful HTTP 200 responses, so standard error monitoring will not catch Expensify-level errors without explicit logging.
- Test webhook handling with Expensify's built-in webhook tester (Settings → Policies → Integrations → Test webhook) before relying on it in production.
Alternatives
FreshBooks provides invoicing and accounting features alongside expense tracking, making it a better choice if you need to connect expense data with client billing and revenue reports.
QuickBooks offers deeper accounting integration with expense tracking, making it preferable if your organization uses QuickBooks for accounting and needs expense data to flow directly into the general ledger.
Zoho Books combines expense management with full accounting features at a lower price point, and is a good alternative if you are already in the Zoho ecosystem.
Frequently asked questions
What Expensify plan do I need for API access?
Basic API access (Integration Server) is available on Expensify's Collect and Control plans. However, some advanced API features like custom export templates and policy-level integrations require the Control plan. Check Expensify's current plan comparison at expensify.com/pricing to confirm which features you need.
Can I create expense reports on behalf of other users?
Yes, if your Expensify credentials have policy admin access. When creating a report via the API, you can specify a different submitter email in the report's fields. This is useful for building HR portals where managers submit reports on behalf of employees. The API credentials must belong to a policy admin or domain admin for this to work.
How do I attach receipt images to expenses created via the API?
Expensify's API supports uploading receipt images as base64-encoded strings in the expense object during report creation. Include a receiptFilename and receiptFileData (base64) in the expense. For V0 apps, you typically collect the file via a file input, convert it to base64 in your frontend, and include it in the POST request body to your API route.
Why does Expensify's API use form encoding instead of JSON?
Expensify's Integration Server API predates many modern API conventions and uses application/x-www-form-urlencoded with a JSON string as a parameter value — a legacy design choice. This is the correct and documented request format. Make sure your fetch call uses Content-Type: application/x-www-form-urlencoded and URLSearchParams for the body, not JSON.stringify.
Can V0 generate the correct Expensify integration code automatically?
V0 may generate a structurally reasonable API route but will likely use the wrong request format — JSON body instead of form encoding. The code in this guide shows the correct format with URLSearchParams. If you use V0 to generate the initial code, review the callExpensify function carefully and update the request body format to match what is shown here.
How do I handle Expensify API responses that return 200 HTTP status but contain error codes in the body?
Expensify returns HTTP 200 for all responses, including errors. Check the responseCode field in the JSON body — a value of 200 means success, any other value indicates an error with a corresponding responseMessage. Always check this field in your API route handler and return an appropriate HTTP error status to your frontend when responseCode is not 200.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation