Docs · Integrations

Send your expenses to Google Sheets, Zapier and Make with webhooks

A webhook sends each expense to a link you choose the moment it lands in Expense AI. Point it at Zapier or Make to fill a Google Sheet, notify your team, or update your accounting tools, with no copying and pasting. Webhooks are free for everyone.

Updated

What you can do with webhooks

  • Keep a live expense spreadsheet. Every expense you scan, upload, forward, import or add through the AI assistant becomes a new row in Google Sheets or Excel, with a link to the receipt.
  • Share spending with a partner or bookkeeper. Post each expense to Slack, email or a shared sheet.
  • Feed your accounting or budgeting tools. Send expenses to Airtable, Notion, QuickBooks or anything Zapier and Make connect to.
  • Build your own reports. Store expenses in your own database and chart them however you like.

Set up a webhook in the app

You need the latest version of Expense AI on iPhone or Android, and a webhook link from Zapier, Make or your own app. The guides below show where to find that link.

  1. 1
    Open Settings → Integrations → Webhooks.
  2. 2
    Tap Add webhook link and paste your link.
  3. 3
    Choose what to send: new, edited and deleted expenses.
  4. 4
    Tap Send a test. We send a sample expense so the other side has real fields to map.
  5. 5
    Tap Save. From now on, expenses are sent as they happen.

The webhook screen shows each delivery from the last 30 days, whether it worked, and lets you resend one, pause the webhook, or change which events it sends.

Add expenses to Google Sheets with Zapier

Start with a Google Sheet that has a header row, for example Date, Merchant, Total, Currency, Category and Receipt.

  1. 1
    In Zapier, create a Zap. For the trigger, choose Webhooks by Zapier and the Catch Hook event.
  2. 2
    Optional: set Pick off a child key to data, so the expense fields appear at the top level and are easier to map.
  3. 3
    Click Continue, then copy the webhook URL Zapier gives you.
  4. 4
    In Expense AI, add the link and tap Send a test, as described above.
  5. 5
    Back in Zapier, click Test trigger. You'll see the sample expense.
  6. 6
    Add an action: Google Sheets → Create Spreadsheet Row. Pick your sheet and map each column, for example date to Date, merchant to Merchant and file_url to Receipt.
  7. 7
    Publish the Zap, then save the webhook in the app.

Add expenses to Google Sheets with Make

  1. 1
    In Make, create a scenario. Add the Webhooks → Custom webhook module, click Add, name it and save.
  2. 2
    Copy the address Make shows. Make is now waiting for the first request so it can learn the fields.
  3. 3
    In Expense AI, add the link and tap Send a test. Make should report that it determined the data structure.
  4. 4
    Add Google Sheets → Add a Row, pick your sheet and map the fields under data.
  5. 5
    Save the scenario and turn it on, set to run immediately as data arrives.

Use your own app, Pipedream or n8n

Any https link that accepts a JSON POST works. We send these headers with every request:

HeaderWhat it holds
Content-Typeapplication/json
ExpenseAI-SignatureProves the request came from us. See Verify signatures.
ExpenseAI-Event-IdA unique id for this event. Use it to skip duplicates.
ExpenseAI-Event-TypeThe event, for example expense.created.
ExpenseAI-Delivery-Attempt1 for the first try, then 2, 3 and so on for retries.
User-AgentExpenseAI-Webhooks/1.0

Reply with any 2xxstatus within 10 seconds. We don't follow redirects, so use the final link. If you have slow work to do, reply first and process the expense afterwards.

Events

EventSent when
expense.createdAn expense is added: scanned, uploaded, forwarded by email, imported from a statement, entered by hand, or added through the AI assistant.
expense.updatedAn expense is edited. Quick edits in a row are combined, so you get one update with the latest values.
expense.deletedAn expense is deleted. Only the id is sent, so you can remove the matching row.

Events can arrive out of order. When it matters, compare data.updated_at and keep the newest.

What we send

Every request has the same envelope: an event id, the type, the api_version of the format, when it was created_at, and the expense in data. The summary fields come first and are flat, so one expense maps neatly to one spreadsheet row.

expense.created
{
  "id": "evt_3f9a1c2b7d4e8f60a1b2c3d4",
  "type": "expense.created",
  "api_version": "2026-11-01",
  "created_at": "2026-11-04T09:12:41.000Z",
  "data": {
    "id": "9b2f6c1e-4a7d-4f3b-8e21-5c0d9a7b3e10",
    "merchant": "Corner Market",
    "total": 48.75,
    "currency": "USD",
    "date": "2026-11-04T08:55:00.000-05:00",
    "category": "Groceries",
    "tags": ["family"],
    "note": "Weekly shop",
    "payment_method": "card",
    "source": "image",
    "receipt_number": "INV-2231",
    "tax_total": 3.61,
    "items_count": 2,
    "file_url": "https://files.expenseai.com/Xq3v9TfL0aB7mK2pR8sW1yZ4cD6eH5jN",
    "file_type": "image",
    "created_at": "2026-11-04T09:12:40.000Z",
    "updated_at": "2026-11-04T09:12:40.000Z",
    "store": {
      "name": "Corner Market",
      "address": "120 Main St",
      "phone": null,
      "city": "Austin",
      "country": "US"
    },
    "card": {
      "label": "Everyday card",
      "last4": "4242",
      "brand": "visa",
      "type": "debit",
      "currency": "USD"
    },
    "tax": [{ "type": "Sales tax", "rate": "8.25", "amount": 3.61 }],
    "items": [
      {
        "name": "Coffee beans 1kg",
        "quantity": 1,
        "unit": "bag",
        "unit_price": 24.5,
        "total": 24.5,
        "category": "Groceries",
        "sub_category": "Pantry"
      },
      {
        "name": "Oat milk",
        "quantity": 3,
        "unit": null,
        "unit_price": 6.88,
        "total": 20.64,
        "category": "Groceries",
        "sub_category": null
      }
    ]
  }
}
expense.deleted
{
  "id": "evt_8c1d2e3f4a5b6c7d8e9f0a1b",
  "type": "expense.deleted",
  "api_version": "2026-11-01",
  "created_at": "2026-11-05T14:02:10.000Z",
  "data": {
    "id": "9b2f6c1e-4a7d-4f3b-8e21-5c0d9a7b3e10",
    "deleted_at": "2026-11-05T14:02:09.000Z"
  }
}

Expense fields

FieldDescription
idThe expense id. It stays the same across created, updated and deleted events.
merchantWhere you spent, or null if unknown.
totalThe amount as a number, rounded to the currency's decimals.
currencyThe ISO 4217 code, for example USD or EUR.
dateWhen the purchase happened, in ISO 8601 format.
categoryThe main category, based on where most of the money went.
tagsYour tags, as a list.
noteYour note, or null.
payment_methodFor example card or cash.
sourceHow the expense was added: image (scanned or uploaded photo), pdf, mail (forwarded email), imported (statement import), manual_entry (entered by hand) or ai (added through the AI assistant).
receipt_numberThe receipt or invoice number, if printed.
tax_totalTotal tax. Details are in tax.
items_countHow many line items are in items.
file_urlA link to the receipt image or PDF, or null. See Receipt files.
file_typeimage, pdf or null.
storeName, address, phone, city and country, where known.
cardThe card you linked to the expense: label, last 4 digits, brand and type. Never the full number.
itemsEach line item with name, quantity, unit, unit price, total, category and sub-category.
created_atWhen the expense was added to Expense AI.
updated_atWhen it was last changed.

Test events

Tests carry "test": true at the top level and use your latest expense, or a sample if you have none. They always include a sample receipt file, so you can map file_urlstraight away. Filter them out in your app if you don't want them stored.

Versioning

api_versionis a date. Your webhook stays on the version it was created with, so new fields never break your sheet. We may add fields within a version, so ignore ones you don't recognize.

file_url is a permanent link to the receipt image or PDF on files.expenseai.com. It opens without signing in, so Zapier, Make and Google Sheets can show or download it.

  • The link is long and unguessable, and doesn't reveal anything about the expense.
  • It stops working as soon as you delete the expense.
  • Treat it like a password: anyone with the link can open the receipt.

To show the receipt in Google Sheets, use =IMAGE(F2) on the cell with the link (images only).

Verify signatures

Zapier and Make users can skip this. If you run your own app, check the signature so nobody else can send you fake expenses.

Copy your signing secret from the webhook screen in the app. It starts with whsec_. Each request carries this header:

Header
ExpenseAI-Signature: t=1793782361,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  1. 1
    t is the time we signed it, in Unix seconds. v1 is the signature.
  2. 2
    Join t, a dot and the raw request body: {t}.{body}. Use the exact text you received, not re-encoded JSON.
  3. 3
    Compute HMAC-SHA256 of that string with your signing secret, as hex.
  4. 4
    Compare it to v1 with a constant-time check, and reject requests where t is more than 5 minutes old.
Node.js
import crypto from "node:crypto";

// rawBody must be the exact text we sent, before any JSON parsing.
export function verifyExpenseAI(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((part) => {
      const i = part.indexOf("=");
      return [part.slice(0, i).trim(), part.slice(i + 1).trim()];
    })
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp) || !parts.v1) return false;

  // Reject anything older than 5 minutes to stop replays.
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: keep the raw body for this route.
app.post("/expense-ai", express.text({ type: "application/json" }), (req, res) => {
  const ok = verifyExpenseAI(
    req.body,
    req.get("ExpenseAI-Signature") ?? "",
    process.env.EXPENSEAI_WEBHOOK_SECRET
  );
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  // ...save event.data, keyed by event.data.id
  res.sendStatus(200);
});
Python
import hashlib, hmac, time

def verify_expense_ai(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    try:
        timestamp = int(parts["t"])
        signature = parts["v1"]
    except (KeyError, ValueError):
        return False

    if abs(time.time() - timestamp) > 300:  # older than 5 minutes
        return False

    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Retries and pausing

If your link doesn't reply with a 2xxwithin 10 seconds, we try again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. That's 7 tries over about 21 hours.

If every delivery fails for 24 hours, or an expense runs out of retries, we pause the webhook so nothing piles up, and tell you by push notification and email. Once the other side is working again, tap Resume in the app. We resend everything that failed in the last 24 hours.

Frequently asked questions

Are webhooks free?

Yes. Webhooks are included for everyone on Expense AI, free or paid. Zapier or Make may charge for their side, depending on your plan with them.

Can Expense AI add expenses to Google Sheets automatically?

Yes. Connect a webhook to Zapier or Make, and add a Google Sheets step that creates a row for each new expense. Every expense you scan, upload, forward, import or add through the AI assistant then lands in your sheet within seconds.

Will my old expenses be sent?

No. A webhook sends expenses from the moment you connect it. To bring older expenses into a spreadsheet, export them from the app first, then let the webhook keep the sheet up to date.

How many webhooks can I have?

One per account for now. Point it at Zapier, Make or your own app, and fan out from there to as many tools as you like.

Why did my webhook pause?

If every delivery fails for 24 hours, or one expense runs out of retries, we pause the webhook and tell you by push notification and email. Fix the link or turn the Zap or scenario back on, then tap Resume. We resend what was missed in the last 24 hours.

Is my financial data safe?

We only send to https links, never to private networks. Every request is signed so your app can prove it came from us, and receipt files sit behind long, unguessable links that stop working when you delete the expense.

Can I get the same expense twice?

Occasionally, for example if your app was slow to reply and we retried. Each event has a unique id in the ExpenseAI-Event-Id header, and each expense keeps the same data.id, so you can skip or update rows you already have.

Need a hand?

Send us a note from Settings → Feedback in the app, or visit support.expenseai.com.