# How to Integrate Expensify with V0

- Tool: V0
- Difficulty: Intermediate
- Time required: 30 minutes
- Last updated: April 2026

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

## Before you start

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

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

```
import { NextRequest, NextResponse } from 'next/server';

const EXPENSIFY_API_URL = 'https://integrations.expensify.com/Integration-Server/ExpensifyIntegrations';

function getCredentials() {
  const partnerUserID = process.env.EXPENSIFY_PARTNER_USER_ID;
  const partnerUserSecret = process.env.EXPENSIFY_PARTNER_USER_SECRET;
  if (!partnerUserID || !partnerUserSecret) {
    throw new Error('Expensify credentials not configured in environment variables');
  }
  return { partnerUserID, partnerUserSecret };
}

async function callExpensify(command: object): Promise<Response> {
  const body = new URLSearchParams({
    requestJobDescription: JSON.stringify(command),
  });

  return fetch(EXPENSIFY_API_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
  });
}

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const status = searchParams.get('status') || 'OPEN';
  const startDate = searchParams.get('from') || '2024-01-01';
  const endDate = searchParams.get('to') || new Date().toISOString().split('T')[0];
  const policyID = process.env.EXPENSIFY_POLICY_ID;

  try {
    const { partnerUserID, partnerUserSecret } = getCredentials();

    const command = {
      type: 'get',
      credentials: { partnerUserID, partnerUserSecret },
      inputSettings: {
        type: 'reportInfos',
        filters: {
          startDate,
          endDate,
          status,
          ...(policyID && { policyIDList: [policyID] }),
        },
      },
    };

    const response = await callExpensify(command);

    if (!response.ok) {
      return NextResponse.json(
        { error: `Expensify API returned ${response.status}` },
        { status: response.status }
      );
    }

    const data = await response.json();

    // Expensify returns responseCode 200 inside the JSON even on errors
    if (data.responseCode && data.responseCode !== 200) {
      return NextResponse.json(
        { error: data.responseMessage || 'Expensify API error' },
        { status: 400 }
      );
    }

    return NextResponse.json(data);
  } catch (error) {
    console.error('Expensify API error:', error);
    return NextResponse.json({ error: 'Failed to fetch expense reports' }, { status: 500 });
  }
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const { title, amount, category, date, description, policyID } = body;

  if (!title || !amount) {
    return NextResponse.json({ error: 'Report title and amount are required' }, { status: 400 });
  }

  try {
    const { partnerUserID, partnerUserSecret } = getCredentials();
    const effectivePolicyID = policyID || process.env.EXPENSIFY_POLICY_ID;

    const command = {
      type: 'create',
      credentials: { partnerUserID, partnerUserSecret },
      inputSettings: {
        type: 'report',
        policyID: effectivePolicyID,
        report: {
          title,
          fields: { description: description || '' },
        },
        expenses: [
          {
            date: date || new Date().toISOString().split('T')[0],
            currency: 'USD',
            amount: Math.round(amount * 100), // Expensify uses cents
            category: category || 'General',
            comment: description || '',
          },
        ],
      },
    };

    const response = await callExpensify(command);
    const data = await response.json();

    if (data.responseCode && data.responseCode !== 200) {
      return NextResponse.json(
        { error: data.responseMessage || 'Failed to create expense report' },
        { status: 400 }
      );
    }

    return NextResponse.json({ reportID: data.reportID, success: true }, { status: 201 });
  } catch (error) {
    console.error('Expensify create error:', error);
    return NextResponse.json({ error: 'Failed to create expense report' }, { status: 500 });
  }
}
```

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

```
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  try {
    // Expensify sends webhooks as form-encoded data, not JSON
    const formData = await request.formData();
    const payloadRaw = formData.get('payload');

    if (!payloadRaw || typeof payloadRaw !== 'string') {
      // Some Expensify webhook versions send JSON directly
      const jsonBody = await request.json().catch(() => null);
      if (!jsonBody) {
        return NextResponse.json({ error: 'Invalid webhook payload' }, { status: 400 });
      }
      // Handle JSON payload
      console.log('Expensify webhook (JSON):', JSON.stringify(jsonBody));
      return NextResponse.json({ received: true });
    }

    const payload = JSON.parse(payloadRaw);
    const { reportID, status, action } = payload;

    console.log(`Expensify webhook: report ${reportID} status changed to ${status} via ${action}`);

    // Add your business logic here:
    // - Update database record
    // - Send Slack/email notification
    // - Trigger downstream workflows

    // Always return 200 quickly — Expensify retries on non-200 responses
    return NextResponse.json({ received: true });
  } catch (error) {
    console.error('Expensify webhook error:', error);
    // Return 200 anyway to prevent Expensify retry loops
    return NextResponse.json({ received: true });
  }
}
```

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

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

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

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

Prompt example:

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

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

Prompt example:

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

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

Prompt example:

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

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

```
// Format Expensify cents to dollars
const formatAmount = (cents: number, currency = 'USD') =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cents / 100);

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

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

---

Source: https://www.rapidevelopers.com/v0-integrations/expensify
© RapidDev — https://www.rapidevelopers.com/v0-integrations/expensify
