Skip to main content
RapidDev - Software Development Agency
v0-integrationsNext.js API Route

How to Integrate Expensify with V0

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.

What you'll learn

  • How to authenticate with the Expensify Integration Server API from a Next.js API route
  • How to fetch and display expense reports in a custom team dashboard
  • How to create expense reports programmatically via the Expensify API
  • How to store Expensify API credentials safely in Vercel environment variables
  • How Expensify's command-based API structure differs from standard REST APIs
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate15 min read30 minutesOtherApril 2026RapidDev Engineering Team
TL;DR

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

Next.js API Route

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

1

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.

2

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.

V0 Prompt

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

app/api/expensify/route.ts
1import { NextRequest, NextResponse } from 'next/server';
2
3const EXPENSIFY_API_URL = 'https://integrations.expensify.com/Integration-Server/ExpensifyIntegrations';
4
5function 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}
13
14async function callExpensify(command: object): Promise<Response> {
15 const body = new URLSearchParams({
16 requestJobDescription: JSON.stringify(command),
17 });
18
19 return fetch(EXPENSIFY_API_URL, {
20 method: 'POST',
21 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
22 body: body.toString(),
23 });
24}
25
26export 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;
32
33 try {
34 const { partnerUserID, partnerUserSecret } = getCredentials();
35
36 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 };
49
50 const response = await callExpensify(command);
51
52 if (!response.ok) {
53 return NextResponse.json(
54 { error: `Expensify API returned ${response.status}` },
55 { status: response.status }
56 );
57 }
58
59 const data = await response.json();
60
61 // Expensify returns responseCode 200 inside the JSON even on errors
62 if (data.responseCode && data.responseCode !== 200) {
63 return NextResponse.json(
64 { error: data.responseMessage || 'Expensify API error' },
65 { status: 400 }
66 );
67 }
68
69 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}
75
76export async function POST(request: NextRequest) {
77 const body = await request.json();
78 const { title, amount, category, date, description, policyID } = body;
79
80 if (!title || !amount) {
81 return NextResponse.json({ error: 'Report title and amount are required' }, { status: 400 });
82 }
83
84 try {
85 const { partnerUserID, partnerUserSecret } = getCredentials();
86 const effectivePolicyID = policyID || process.env.EXPENSIFY_POLICY_ID;
87
88 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 cents
103 category: category || 'General',
104 comment: description || '',
105 },
106 ],
107 },
108 };
109
110 const response = await callExpensify(command);
111 const data = await response.json();
112
113 if (data.responseCode && data.responseCode !== 200) {
114 return NextResponse.json(
115 { error: data.responseMessage || 'Failed to create expense report' },
116 { status: 400 }
117 );
118 }
119
120 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.

3

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.

app/api/expensify/webhook/route.ts
1import { NextRequest, NextResponse } from 'next/server';
2
3export async function POST(request: NextRequest) {
4 try {
5 // Expensify sends webhooks as form-encoded data, not JSON
6 const formData = await request.formData();
7 const payloadRaw = formData.get('payload');
8
9 if (!payloadRaw || typeof payloadRaw !== 'string') {
10 // Some Expensify webhook versions send JSON directly
11 const jsonBody = await request.json().catch(() => null);
12 if (!jsonBody) {
13 return NextResponse.json({ error: 'Invalid webhook payload' }, { status: 400 });
14 }
15 // Handle JSON payload
16 console.log('Expensify webhook (JSON):', JSON.stringify(jsonBody));
17 return NextResponse.json({ received: true });
18 }
19
20 const payload = JSON.parse(payloadRaw);
21 const { reportID, status, action } = payload;
22
23 console.log(`Expensify webhook: report ${reportID} status changed to ${status} via ${action}`);
24
25 // Add your business logic here:
26 // - Update database record
27 // - Send Slack/email notification
28 // - Trigger downstream workflows
29
30 // Always return 200 quickly — Expensify retries on non-200 responses
31 return NextResponse.json({ received: true });
32 } catch (error) {
33 console.error('Expensify webhook error:', error);
34 // Return 200 anyway to prevent Expensify retry loops
35 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.

4

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.

V0 Prompt

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.

V0 Prompt

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.

V0 Prompt

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.

typescript
1// Format Expensify cents to dollars
2const formatAmount = (cents: number, currency = 'USD') =>
3 new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cents / 100);
4
5// 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

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.

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.