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.
- 1Open Settings → Integrations → Webhooks.
- 2Tap Add webhook link and paste your link.
- 3Choose what to send: new, edited and deleted expenses.
- 4Tap Send a test. We send a sample expense so the other side has real fields to map.
- 5Tap 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.
- 1In Zapier, create a Zap. For the trigger, choose Webhooks by Zapier and the Catch Hook event.
- 2Optional: set Pick off a child key to
data, so the expense fields appear at the top level and are easier to map. - 3Click Continue, then copy the webhook URL Zapier gives you.
- 4In Expense AI, add the link and tap Send a test, as described above.
- 5Back in Zapier, click Test trigger. You'll see the sample expense.
- 6Add an action: Google Sheets → Create Spreadsheet Row. Pick your sheet and map each column, for example
dateto Date,merchantto Merchant andfile_urlto Receipt. - 7Publish the Zap, then save the webhook in the app.
Add expenses to Google Sheets with Make
- 1In Make, create a scenario. Add the Webhooks → Custom webhook module, click Add, name it and save.
- 2Copy the address Make shows. Make is now waiting for the first request so it can learn the fields.
- 3In Expense AI, add the link and tap Send a test. Make should report that it determined the data structure.
- 4Add Google Sheets → Add a Row, pick your sheet and map the fields under
data. - 5Save 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:
| Header | What it holds |
|---|---|
Content-Type | application/json |
ExpenseAI-Signature | Proves the request came from us. See Verify signatures. |
ExpenseAI-Event-Id | A unique id for this event. Use it to skip duplicates. |
ExpenseAI-Event-Type | The event, for example expense.created. |
ExpenseAI-Delivery-Attempt | 1 for the first try, then 2, 3 and so on for retries. |
User-Agent | ExpenseAI-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
| Event | Sent when |
|---|---|
expense.created | An expense is added: scanned, uploaded, forwarded by email, imported from a statement, entered by hand, or added through the AI assistant. |
expense.updated | An expense is edited. Quick edits in a row are combined, so you get one update with the latest values. |
expense.deleted | An 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.
{
"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
}
]
}
}{
"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
| Field | Description |
|---|---|
id | The expense id. It stays the same across created, updated and deleted events. |
merchant | Where you spent, or null if unknown. |
total | The amount as a number, rounded to the currency's decimals. |
currency | The ISO 4217 code, for example USD or EUR. |
date | When the purchase happened, in ISO 8601 format. |
category | The main category, based on where most of the money went. |
tags | Your tags, as a list. |
note | Your note, or null. |
payment_method | For example card or cash. |
source | How 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_number | The receipt or invoice number, if printed. |
tax_total | Total tax. Details are in tax. |
items_count | How many line items are in items. |
file_url | A link to the receipt image or PDF, or null. See Receipt files. |
file_type | image, pdf or null. |
store | Name, address, phone, city and country, where known. |
card | The card you linked to the expense: label, last 4 digits, brand and type. Never the full number. |
items | Each line item with name, quantity, unit, unit price, total, category and sub-category. |
created_at | When the expense was added to Expense AI. |
updated_at | When 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.
Receipt files
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:
ExpenseAI-Signature: t=1793782361,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd- 1
tis the time we signed it, in Unix seconds.v1is the signature. - 2Join
t, a dot and the raw request body:{t}.{body}. Use the exact text you received, not re-encoded JSON. - 3Compute HMAC-SHA256 of that string with your signing secret, as hex.
- 4Compare it to
v1with a constant-time check, and reject requests wheretis more than 5 minutes old.
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);
});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.