Accounting Data in Microsoft Teams: Build Options Compared
A developer walkthrough for looking up QuickBooks, Xero, and NetSuite invoices, bills, and payments from Microsoft Teams: the Teams-side registration, bot, flow, and webhook code every accounting system shares, then the code specific to each system.
On this page +
This guide is for developers building a way for their team to look up QuickBooks, Xero, or NetSuite data from inside Microsoft Teams, without buying a product. It compares four routes: a bot on the Teams SDK that answers lookups on demand, a Power Automate flow on an accounting connector, triggered alerts from the accounting system’s webhooks into a Teams workflow, and no-code alerts with Zapier.
The Teams half of each route is the same whichever accounting system sits behind it, so the guide is organized that way. The first section is the shared registration, code, and configuration. After it, one section per accounting system supplies only what differs: authentication and token refresh, the query behind the lookup, the Power Automate connector, webhook signature verification and event parsing, and that system’s Zapier specifics. QuickBooks, Xero, and NetSuite are each covered in full.
Set expectations first: every route here delivers triggered messages (something changed, a message posts) or commands (a person types a fixed syntax, gets one answer). None is a natural-language interface, and the closing section is honest about what that rules out.
API limits, SDK status, and licensing in this post were verified in September 2026.
What every route shares
Register the app and bot
Microsoft ended long-term support for the Bot Framework SDK in December 2025 (existing bots keep running, they just get no updates or support tickets) and now steers new bots toward the Teams SDK or the Microsoft 365 Agents SDK, both of which ship for Python, C#, and TypeScript. The Teams SDK is the Teams-specific one and the shorter path for a bot that only needs to live in Teams. You need Python 3.12 or later and Node 20 or later (the Teams CLI is an npm package even for Python projects). No Azure subscription is needed: the Teams SDK registers a Teams-managed bot by default, and Azure only enters if the bot needs an OAuth connection or single sign-on.
The setup has three parts before any accounting code runs:
| Part | Where | What you do |
|---|---|---|
| App and bot registration | Teams CLI or the Teams Developer Portal (dev.teams.microsoft.com) | Create the Teams app and a Teams-managed bot, and set its scopes, commands, and messaging endpoint. This issues the app ID and secret your code authenticates with. |
| Server-side code | Your hosting | Run a server with a public HTTPS endpoint that receives every message Teams forwards, calls the accounting API, and sends the reply. |
| Tenant approval | Teams admin center | A Teams admin uploads the packaged app to your organization’s own app catalog, where it appears under “Built for your org,” and the tenant policy has to allow custom apps. No Teams Store listing is needed for an internal bot. |
The Teams CLI does the registration and writes the credentials into the project. The order matters: sign in first, have a public HTTPS tunnel running before you register (the registration needs the endpoint URL), then create the app.
npm install -g @microsoft/teams.cli
teams project new python accounting-bot --template echo
cd accounting-bot
python -m venv .venv && source .venv/bin/activate
pip install -e .
teams login # signs in to your Microsoft 365 tenant
teams status # confirms custom app upload is allowed for you
# In a second terminal, start a tunnel to port 3978 (Visual Studio dev tunnels
# or ngrok) and copy its https host.
teams app create \
--name "Accounting Bot" \
--endpoint https://<tunnel-host>/api/messages \
--env .env
teams app create registers a Teams app and a Teams-managed bot and writes CLIENT_ID, CLIENT_SECRET, and TENANT_ID into .env. It also prints an “Install in Teams” link. Add --azure only if the bot will need Microsoft OAuth or single sign-on later; a Teams-managed bot can be migrated to Azure afterwards.
Then open the app in the Teams Developer Portal to finish the parts the CLI leaves to you: fill in the basic information (name, developer, privacy and terms URLs; the portal will not export a package without them), tick the scopes (Personal, Team, Group chat), add a command such as bills due before so it appears in the compose box, update the messaging endpoint when you move from the tunnel to real hosting, and download the app package for the admin rollout.
The bot handler
The scaffold already has the FastAPI server, the /api/messages route, and the environment variables for the bot’s credentials. Replace its echo handler with one that reads a command out of the message and answers with an Adaptive Card. The example handles “bills due before YYYY-MM-DD.” The Teams side never touches the accounting API; it calls one function, bills_due_before, which each accounting section implements and which returns a list of dictionaries with the same four keys regardless of system.
import asyncio
import os
import re
from datetime import date
from microsoft_teams.apps import App
from microsoft_teams.cards import AdaptiveCard, TextBlock
from accounting import bills_due_before # QuickBooks or Xero implementation, below
# skip_auth=True is for local testing with the Agents Playground only. It turns
# off inbound request authentication, so never set LOCAL_DEV in production.
app = App(skip_auth=os.getenv("LOCAL_DEV") == "1")
@app.on_message
async def handle_message(ctx):
# In a channel the text begins with the bot's mention, e.g. "<at>Accounting Bot</at> bills due ..."
text = re.sub(r"<at>.*?</at>", "", ctx.activity.text or "").strip().lower()
if not text.startswith("bills due before "):
await ctx.send("Try: `bills due before YYYY-MM-DD`")
return
try:
due = date.fromisoformat(text.split()[-1]) # validate before it reaches any query
except ValueError:
await ctx.send("That date needs to be YYYY-MM-DD.")
return
# bills_due_before uses blocking `requests`, so run it off the event loop
# or the bot stops answering everyone else for the length of the API call.
bills = await asyncio.to_thread(bills_due_before, due)
await ctx.send(AdaptiveCard(
body=[
TextBlock(text=f"{len(bills)} bills due by {due.isoformat()}", weight="Bolder"),
*[
TextBlock(wrap=True,
text=f"{b['vendor']} {b['currency']} {b['amount_due']:.2f} due {b['due']}")
for b in bills
],
]
))
Run it with LOCAL_DEV=1 python src/main.py for the Playground, or plain python src/main.py behind the tunnel when talking to Teams. It listens on port 3978. For local testing without a tunnel, Microsoft’s Agents Playground talks to the endpoint directly; it sends unauthenticated requests, which is why the handler only skips authentication when LOCAL_DEV is set:
npm install -g @microsoft/m365agentsplayground
agentsplayground -e http://localhost:3978/api/messages -c emulator
Notes on the handler, whatever the backend:
- Mentions. In a channel the incoming text includes the bot’s @mention as an
<at>tag, which is why the handler strips it before thestartswithcheck. In a personal chat there is no mention and the regex is a no-op. - Adaptive Cards render differently on desktop, web, and mobile Teams clients. Keep to
TextBlockwithwrap=Trueand simple layouts, and test on a phone before you announce the bot. - Who asked.
ctx.activity.from_carries the user’s Teams identity, including the Entra object ID. Deciding whether that person may see vendor bills is your permission model, and the handler above has none. - No Socket Mode equivalent. The endpoint has to be reachable from Microsoft’s side even during development, hence the tunnel.
Roll out to the tenant
For your own testing, if your app setup policy allows custom apps, the “Install in Teams” link from teams app create adds the bot for you alone, with no approval step. For the team, send the package to a Teams admin. In the Teams admin center they upload it under Manage apps, where it appears as “Built for your org,” and the admin can scope who may install it. Users then add the bot from the Teams app store’s “Built for your org” section, or the admin pins it for a group. Publishing to the Teams Store is only for distributing the app to other companies, and that route goes through Partner Center and Microsoft’s app validation, which takes weeks.
The Power Automate flow
If your company lives in Microsoft 365, the instinct is to skip code and use Power Automate. The flow shape is the same for every accounting system: a Recurrence trigger every Monday at 8:00, a connector action that lists open bills, a Select step to shape vendor, amount, and due date into text lines, and Post card in a chat or channel (the Teams connector) with an Adaptive Card built from those lines. That is an afternoon of work once the connector exists.
What differs per system is the connector, and in both cases it is Premium. The flow owner needs a Power Automate Premium license, priced by Microsoft at $15 per user per month billed yearly as of September 2026, and so does anyone who runs a premium flow on demand. A scheduled flow runs under its owner’s license, so a weekly summary costs one seat. The alternative is a Process license attached to the flow itself, at $150 per month billed yearly.
What Power Automate cannot do well is answer a question. A flow triggered by a Teams message that parses free text and decides which API call to make is fragile at best, and even a keyword-matched command is awkward to express in a flow. Power Automate is a triggered-message tool. Use it for the weekly AP summary, not for “did they pay?”
Webhooks into a Teams workflow
For change alerts without a bot, both QuickBooks and Xero call a URL you host when records change. On the Teams side, the old Office 365 connector incoming webhooks are on the way out (Microsoft has moved the retirement date more than once, currently around April 30, 2026). The replacement is a Workflow: in the target channel choose Workflows, pick the “Post to a channel when a webhook request is received” template, and copy the URL it gives you. That URL accepts a JSON body carrying an Adaptive Card.
The receiver is the same for both systems: verify the signature, queue the events, return 200 fast, and let a worker do the slow part. The three imported functions are what differ per system and are supplied in the accounting sections.
# Local demonstration. In production, replace queue.Queue with a durable queue
# (SQS, Pub/Sub, a database table) written BEFORE the 200 goes out, and add
# retries and idempotency, or a restart loses acknowledged events for good.
import os, queue, threading
import requests
from flask import Flask, request
from accounting import verify_signature, parse_events, handle_event # per system, below
app = Flask(__name__)
TEAMS_HOOK = os.environ["TEAMS_WORKFLOW_URL"]
events = queue.Queue()
@app.post("/webhook")
def webhook():
if not verify_signature(request.headers, request.data):
return "", 401 # Xero's validation requires 401 here, not 400
for event in parse_events(request.get_json()):
events.put(event)
return "", 200 # both Intuit and Xero time out in seconds; do the work later
def post_to_teams(text: str) -> None:
card = {
"type": "message",
"attachments": [{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {"type": "AdaptiveCard", "version": "1.5",
"body": [{"type": "TextBlock", "wrap": True, "text": text}]},
}],
}
requests.post(TEAMS_HOOK, json=card, timeout=5).raise_for_status()
def worker():
while True:
event = events.get()
try:
message = handle_event(event) # text to post, or None to ignore
if message:
post_to_teams(message)
except Exception as exc: # one bad event must not kill the worker
print(f"webhook job failed: {exc}")
finally:
events.task_done()
threading.Thread(target=worker, daemon=True).start()
Both systems retry on a back-off schedule, so handle_event has to be idempotent. Half a day to a day including deployment.
Zapier
Zapier needs no developer. The pattern is the same for every accounting system it supports: a trigger from the accounting app to a Send Channel Message action for Microsoft Teams for alerts, one task per alert. Lookups from a Teams channel use a New Channel Message trigger, a Filter step early in the Zap to discard messages without an invoice number (the trigger fires on every message in the channel), a Formatter step to extract the number, a Find step against the accounting app, and a reply, at two tasks a lookup. Plans start at $19.99 a month billed yearly for 750 tasks as of September 2026, and QuickBooks, Xero, and NetSuite are all premium apps.
The same three limits apply whichever system is behind it: a Zap answers the one shape you built and anything that combines or sums needs a Code step; the connection carries whatever scopes the app requests and both integrations include write actions; and the Teams trigger passes the sender’s identity so a Filter can restrict who gets an answer, but the permission list is yours to maintain.
From demo to production
The handler plus one accounting implementation is about 60 lines and answers one message shape. Production adds the same list every bot needs: the admin consent flow and per-tenant token storage, retry handling for the accounting API’s rate limits, a permission layer mapping Teams users to what they may see, a new parser branch for every new question shape, hosting and secret management, and someone who owns it when the accounting vendor or Microsoft changes something. Microsoft’s own Bot Framework end of support in 2025 is the recent example: the SDK still runs but gets no updates or support, so every bot on it should plan a migration.
Realistic vibe-coding effort, a developer directing an AI assistant: two to three days for a demo, another two weeks or so for something a finance lead would let the sales team use, then a permanent maintenance line item.
QuickBooks
Register an app on the Intuit developer portal with a client ID and secret. The authorization URL is https://appcenter.intuit.com/connect/oauth2 with scope com.intuit.quickbooks.accounting; the callback gives you an access token, a refresh token, and the company’s realmId.
QuickBooks API and token refresh
import os
import requests
QB_BASE = "https://quickbooks.api.intuit.com/v3/company"
TOKEN_URL = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
# In production these live in a database, keyed by Teams tenant.
tokens = {
"access_token": os.environ["QB_ACCESS_TOKEN"],
"refresh_token": os.environ["QB_REFRESH_TOKEN"],
"realm_id": os.environ["QB_REALM_ID"],
}
def refresh_access_token():
"""Access tokens last 60 minutes. Store the NEW refresh token every time;
Intuit issues a new one roughly every 24 hours and force-expires the
previous one the moment it does, so persist it or the next call fails."""
resp = requests.post(
TOKEN_URL,
auth=(os.environ["QB_CLIENT_ID"], os.environ["QB_CLIENT_SECRET"]),
headers={"Accept": "application/json"},
data={"grant_type": "refresh_token",
"refresh_token": tokens["refresh_token"]},
timeout=10,
)
resp.raise_for_status()
body = resp.json()
tokens["access_token"] = body["access_token"]
tokens["refresh_token"] = body["refresh_token"]
def qb_query(sql: str) -> dict:
url = f"{QB_BASE}/{tokens['realm_id']}/query"
headers = {"Authorization": f"Bearer {tokens['access_token']}",
"Accept": "application/json"}
resp = requests.get(url, headers=headers,
params={"query": sql, "minorversion": "75"}, timeout=10)
if resp.status_code == 401:
refresh_access_token()
headers["Authorization"] = f"Bearer {tokens['access_token']}"
resp = requests.get(url, headers=headers,
params={"query": sql, "minorversion": "75"}, timeout=10)
resp.raise_for_status()
return resp.json()["QueryResponse"]
Facts that shape everything built on this:
- Token refresh. Access tokens expire after 60 minutes. Intuit rotates the refresh token roughly every 24 hours, and when it issues a new one the previous one is force-expired immediately, not left as a grace window. A bot that stores the old one gets
invalid_granton its next refresh and needs the customer to reauthorize. Intuit’s note on refresh token validity is worth reading twice. - The query endpoint.
GET /v3/company/{realmId}/querytakes a SQL-like string:SELECT * FROM <one entity>,WHEREwith=, comparisons,IN, andLIKE,ORDERBY, and paging withSTARTPOSITIONandMAXRESULTS. Values are quoted as strings even when numeric, so an open-balance filter isBalance > '0'. - What it will not do. No
JOIN, noGROUP BY, noOR, no column projection, and at most 1,000 rows per page, per Intuit’s query documentation. A weekly AP summary grouped by vendor means paging every open bill and summing in your code. - Rate limits. 500 requests per minute per company, 10 concurrent. A scheduled weekly flow is nowhere near that. A channel of 40 people asking on the first of the month, with a bot that fans out several queries per question, can be.
QuickBooks bills due lookup
The implementation of bills_due_before the handler imports. In QuickBooks a bill is its own entity, and the date comparison works because QuickBooks dates are ISO strings.
from datetime import date
def bills_due_before(due: date) -> list[dict]:
result = qb_query(
f"SELECT * FROM Bill WHERE Balance > '0' AND DueDate <= '{due.isoformat()}' "
f"ORDERBY DueDate MAXRESULTS 20"
)
return [
{"vendor": b["VendorRef"]["name"],
"currency": b["CurrencyRef"]["value"],
"amount_due": float(b["Balance"]),
"due": b["DueDate"]}
for b in result.get("Bill", [])
]
QuickBooks in Power Automate
As of September 2026 there is no first-party QuickBooks connector in Power Automate. Microsoft’s connector catalog lists only QuickBooks Time, an independent-publisher connector for the time-tracking product, not accounting. Reading invoices or bills means building a custom connector that wraps Intuit’s endpoints and OAuth flow:
- In Power Automate, go to Custom connectors and create one from blank. Set the host to
quickbooks.api.intuit.comand the base URL to/v3/company. - Under Security, choose OAuth 2.0 with the Generic OAuth 2 identity provider. Authorization URL
https://appcenter.intuit.com/connect/oauth2, token URL and refresh URLhttps://oauth.platform.intuit.com/oauth2/v1/tokens/bearer, scopecom.intuit.quickbooks.accounting, and your Intuit client ID and secret. Power Automate shows you a redirect URL after you save; add it to the Intuit app. - Under Definition, add an action such as
ListOpenBillswith aGETrequest to/{realmId}/query, a path parameter forrealmId, and a query parameter namedquery. Import a sample response so the flow designer knows the fields. - Create a connection, sign in as the QuickBooks admin, and test the action with
SELECT * FROM Bill WHERE Balance > '0' ORDERBY DueDate MAXRESULTS 20.
The connector handles token refresh for you, which is one real advantage over the bot route. Drop ListOpenBills into the shared flow shape and the Monday summary is done.
QuickBooks webhooks
QuickBooks webhooks notify your endpoint when an Invoice, Payment, Bill, or Customer changes. Since Intuit’s July 2026 cutover the payload is a CloudEvents array, one event per changed record, with intuitaccountid identifying the company and intuitentityid the record. The signature is base64 HMAC-SHA256 of the raw body in an intuit-signature header.
import base64, hashlib, hmac
VERIFIER = os.environ["QB_WEBHOOK_VERIFIER"].encode()
def verify_signature(headers, raw_body: bytes) -> bool:
digest = base64.b64encode(hmac.new(VERIFIER, raw_body, hashlib.sha256).digest()).decode()
return hmac.compare_digest(headers.get("intuit-signature", ""), digest)
def parse_events(body) -> list:
# CloudEvents: a JSON array; one notification can carry several companies.
return [e for e in body if e["intuitaccountid"] == tokens["realm_id"]]
def handle_event(event) -> str | None:
if event["type"] != "qbo.payment.created.v1":
return None
payment = qb_query(f"SELECT * FROM Payment WHERE Id = '{event['intuitentityid']}'")["Payment"][0]
return (f"Payment received: {payment['CurrencyRef']['value']} {payment['TotalAmt']} "
f"from {payment['CustomerRef']['name']}")
Key idempotency on the CloudEvents id.
QuickBooks on Zapier
Zapier’s QuickBooks integration has instant triggers such as New Invoice, New Payment, New Paid Invoice, Updated Invoice, New Invoice Due, and New Bill, and search steps such as Find Invoice, Find Customer, and Find Payment. It also includes 50-plus write actions, so choose read steps only.
Recipe 1: paid-invoice alert (two steps, one task per payment)
- Trigger: QuickBooks Online, New Paid Invoice. Connect as the QuickBooks admin and pick the company.
- Action: Microsoft Teams, Send Channel Message. Team and Channel: the finance channel. Message:
Invoice {{Doc Number}} for {{Customer Ref Name}} paid, {{Currency Ref Value}} {{Total Amt}}. The Zapier app has to be added to the team, and the connected Microsoft account has to be a member of it.
Swap New Paid Invoice for New Invoice Due or New Bill and the action stays the same.
Recipe 2: invoice lookup from a channel (five steps, two tasks per question)
- Trigger: Microsoft Teams, New Channel Message. Team and Channel: the one people will ask in. This fires on every message in the channel, including the Zap’s own replies, which is why the next step exists.
- Filter by Zapier. Continue only if the message
Textcontainsinvoiceand does not contain the reply prefix you use in step 5 (for exampleInvoice lookup:). Add a condition on the sender if only certain people may ask. - Formatter by Zapier: Text, Extract Pattern. Input: message
Text. Pattern:\b(\d{3,})\b. - Search: QuickBooks Online, Find Invoice. Search by invoice number, using the Formatter output. Leave the “create if it doesn’t exist” option unticked.
- Action: Microsoft Teams, Send Channel Message. Same Team and Channel. Message:
Invoice lookup: {{Doc Number}} for {{Customer Ref Name}}, {{Currency Ref Value}} {{Total Amt}}, balance {{Balance}}, due {{Due Date}}. Zapier’s Teams app does have a Reply to Channel Message action, but its reply trigger targets one preselected message rather than whichever message asked, formatting in replies is unreliable, and private channels are unsupported. Posting to the channel with a prefix is what ties the answer to the question.
Filter and Formatter steps are free, so a lookup costs two tasks: the Find and the reply.
Xero
Create an app at developer.xero.com with a client ID and secret, and request the accounting.transactions.read and accounting.contacts.read scopes plus offline_access. After a Xero admin authorizes it, call GET https://api.xero.com/connections for the tenantId of each connected organization.
Xero API and token refresh
import os
import requests
XERO_TOKEN_URL = "https://identity.xero.com/connect/token"
XERO_API = "https://api.xero.com/api.xro/2.0"
# In production these live in a database, keyed by tenant and organization.
xero = {
"access_token": os.environ["XERO_ACCESS_TOKEN"],
"refresh_token": os.environ["XERO_REFRESH_TOKEN"],
"tenant_id": os.environ["XERO_TENANT_ID"],
}
def xero_refresh():
"""Access tokens last 30 minutes. Xero rotates the refresh token on every
use; the old one stays valid for 30 minutes only as a recovery window if the
response was lost, so persist the new one immediately."""
resp = requests.post(
XERO_TOKEN_URL,
auth=(os.environ["XERO_CLIENT_ID"], os.environ["XERO_CLIENT_SECRET"]),
data={"grant_type": "refresh_token",
"refresh_token": xero["refresh_token"]},
timeout=10,
)
resp.raise_for_status()
body = resp.json()
xero["access_token"] = body["access_token"]
xero["refresh_token"] = body["refresh_token"]
def xero_get(path: str, params: dict) -> dict:
headers = {"Authorization": f"Bearer {xero['access_token']}",
"xero-tenant-id": xero["tenant_id"], # which organization
"Accept": "application/json"}
resp = requests.get(f"{XERO_API}{path}", headers=headers, params=params, timeout=10)
if resp.status_code == 401:
xero_refresh()
headers["Authorization"] = f"Bearer {xero['access_token']}"
resp = requests.get(f"{XERO_API}{path}", headers=headers, params=params, timeout=10)
resp.raise_for_status()
return resp.json()
Xero-specific facts:
- Rate limits are per organization. 60 calls a minute, 5,000 a day, and 5 concurrent, each per connected organization, with HTTP 429 beyond that. A bot serving one organization has a smaller budget than the QuickBooks equivalent.
- Invoices are both AR and AP.
Type=="ACCREC"is a sales invoice,Type=="ACCPAY"is a supplier bill, and both come from/Invoices. Filter withwhere,Statuses,ContactIDs, orInvoiceNumbers; page withpageandpageSizeup to 1,000. - Contacts are both customers and suppliers. One
/Contactsendpoint, no vendor object. - Dates come back twice.
DueDateis the legacy/Date(1761868800000+0000)/format;DueDateStringis the readable2026-10-31T00:00:00. Use the string form. - Tracking Categories live on line items, at
LineItems[].Tracking, and Xero allows two active categories per organization. A total by Tracking Category means fetching invoices with line items and summing in your code, and the API has noGROUP BYorSUM. - Refresh tokens expire after 60 days unused. A bot nobody has used for two months has to be re-authorized by the admin.
Xero bills due lookup
In Xero a bill is an invoice with Type=="ACCPAY", and AUTHORISED is the approved-but-unpaid status, so the whole question is one where filter:
from datetime import date
def bills_due_before(due: date) -> list[dict]:
where = (f'Type=="ACCPAY" AND Status=="AUTHORISED" '
f'AND DueDate<DateTime({due.year},{due.month},{due.day})')
bills = xero_get("/Invoices", {"where": where, "order": "DueDate ASC",
"page": 1}).get("Invoices", [])[:20]
return [
{"vendor": b["Contact"]["Name"],
"currency": b["CurrencyCode"],
"amount_due": float(b["AmountDue"]),
"due": b["DueDateString"][:10]}
for b in bills
]
Xero in Power Automate
Unlike QuickBooks, Xero has a connector in Microsoft’s catalog, though not a first-party one. “Xero Accounting - Magnetism” is a Premium connector published by Magnetism Solutions, in preview as of September 2026, and licensed separately by Magnetism after a seven-day trial. It exposes List Xero records with where, order, and top parameters, Get a Xero record by ID, a trigger for Invoice or Contact create and update events, and a raw Send an HTTP request to Xero action for anything else. Its own throttle is 100 calls a minute per connection, on top of Xero’s limits. There is also SureXeroLite, an independent-publisher connector with a narrower surface.
For the shared flow shape, the list action is List Xero records with record type Invoices and where set to Type=="ACCPAY" AND Status=="AUTHORISED". The connector’s trigger handles “post when an invoice for this contact is updated.”
Xero webhooks
Xero webhooks differ from Intuit’s in four ways that matter to the code. They fire for Invoice, Contact, and Credit Note create and update events, plus Prepayment and Overpayment events that Xero added in August and September 2026. The signature arrives in an x-xero-signature header as base64 HMAC-SHA256 of the raw body, keyed on the webhook key from the developer portal. Xero requires the 200 within five seconds, and during its “intent to receive” validation it requires a 401 for a bad signature, not a 400, which is why the shared receiver returns 401. And there is no payment event: a payment is an Invoice UPDATE whose status has become PAID.
import base64, hashlib, hmac
XERO_WEBHOOK_KEY = os.environ["XERO_WEBHOOK_KEY"].encode()
announced_paid = set() # invoice IDs already posted; use a table in production
def verify_signature(headers, raw_body: bytes) -> bool:
digest = base64.b64encode(hmac.new(XERO_WEBHOOK_KEY, raw_body, hashlib.sha256).digest()).decode()
return hmac.compare_digest(headers.get("x-xero-signature", ""), digest)
def parse_events(body) -> list:
# Xero wraps events: {"events": [...], "firstEventSequence": ..., "lastEventSequence": ...}
return [e for e in body["events"] if e["tenantId"] == xero["tenant_id"]]
def handle_event(event) -> str | None:
if event["eventCategory"] != "INVOICE" or event["eventType"] != "UPDATE":
return None
inv = xero_get(f"/Invoices/{event['resourceId']}", {})["Invoices"][0]
if inv["Type"] != "ACCREC" or inv["Status"] != "PAID" or inv["InvoiceID"] in announced_paid:
return None
announced_paid.add(inv["InvoiceID"])
return f"{inv['InvoiceNumber']} for {inv['Contact']['Name']} paid, {inv['CurrencyCode']} {inv['Total']:.2f}"
Every edit to an invoice fires an UPDATE, and Xero retries with back-off, so expect duplicates. Deduplicate on resourceId plus eventDateUtc, and keep a record of which paid invoices you have already announced, because later edits to a paid invoice fire UPDATE again.
Xero on Zapier
Xero is a premium app on Zapier. Only two of its triggers are instant, New Sales Invoice and Updated Sales Invoice; New Bill, Overdue Sales Invoice, New Payment, and the rest poll. Find Invoice searches by invoice number or reference.
Recipe 1: overdue-invoice alert (two steps, one task per invoice)
- Trigger: Xero, Overdue Sales Invoice. Pick the organization and set “Days overdue.” It polls, so the alert arrives within Zapier’s interval.
- Action: Microsoft Teams, Send Channel Message. Team and Channel: the client or finance channel. Message:
{{Invoice Number}} for {{Contact Name}} is overdue: {{Currency Code}} {{Amount Due}}, due {{Due Date}}.
New Bill with the same action covers supplier bills. For a paid alert, use Updated Sales Invoice (one of the two instant triggers) with a Filter on Status equals PAID, since Xero has no payment trigger that fires instantly.
Recipe 2: invoice lookup from a channel (five steps, two tasks per question)
- Trigger: Microsoft Teams, New Channel Message on the channel people ask in.
- Filter by Zapier. Continue only if
Textmatches the pattern below and does not start with your reply prefix. - Formatter by Zapier: Text, Extract Pattern. Input:
Text. Pattern:\b[A-Z]{2,4}-\d{3,}\b, matchingINV-1904orBILL-0231. - Search: Xero, Find Invoice. Organization: yours. Search by invoice number, using the Formatter output. Bills are invoices in Xero, so this finds both.
- Action: Microsoft Teams, Send Channel Message. Message:
Invoice lookup: {{Type}} {{Invoice Number}} for {{Contact Name}}, {{Currency Code}} {{Total}}, {{Status}}, amount due {{Amount Due}}, due {{Due Date}}.
NetSuite
NetSuite changes the accounting half more than Xero does: a better query language, heavier authentication, no webhooks at all, and concurrency governance instead of rate limits. You need an Integration record (Setup > Integration > Manage Integrations) with OAuth 2.0 and the client credentials grant enabled, an X.509 certificate you generate and upload under OAuth 2.0 Client Credentials Setup, mapped to a dedicated role that has “Log in using OAuth 2.0 Access Tokens” and view permission on transactions, entities, and currencies, and your account ID. Install PyJWT and cryptography alongside the packages from the shared section.
The code below signs with PS256, which NetSuite accepts only for RSA keys of 3072 or 4096 bits; a 2048-bit or EC certificate is rejected at upload or at token time. NetSuite also caps certificate validity at two years. Generate a compatible pair with:
openssl req -x509 -newkey rsa:4096 -sha256 -days 730 -nodes \
-sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:64 \
-keyout netsuite-key.pem -out netsuite-cert.pem -subj "/CN=accounting-bot"
Upload netsuite-cert.pem to NetSuite and keep netsuite-key.pem on the server.
NetSuite API and authentication
import os
import re
import time
import jwt # PyJWT, with the cryptography package for PS256
import requests
ACCOUNT = os.environ["NS_ACCOUNT_ID"] # e.g. 1234567, or 1234567_SB1 for a sandbox
HOST = f"https://{ACCOUNT.lower().replace('_', '-')}.suitetalk.api.netsuite.com"
TOKEN_URL = f"{HOST}/services/rest/auth/oauth2/v1/token"
SUITEQL_URL = f"{HOST}/services/rest/query/v1/suiteql"
# OAuth 2.0 client credentials, the machine-to-machine flow. In NetSuite,
# Setup > Integration > OAuth 2.0 Client Credentials Setup maps your X.509
# certificate to one integration, one user, and one role; the role needs
# "Log in using OAuth 2.0 Access Tokens" and view permission on the records
# queried. The private key stays on your server. Oracle blocks new
# token-based-authentication (OAuth 1.0a) integrations from NetSuite 2027.1.
CLIENT_ID = os.environ["NS_CLIENT_ID"] # the integration's client id
CERTIFICATE_ID = os.environ["NS_CERTIFICATE_ID"] # shown in NetSuite after upload
PRIVATE_KEY = open(os.environ["NS_PRIVATE_KEY_PATH"]).read()
_token = {"value": None, "expires": 0}
def access_token() -> str:
"""Access tokens last 60 minutes and there is no refresh token. Sign a new
JWT assertion and ask for another one when the current one is near expiry."""
if _token["value"] and time.time() < _token["expires"] - 60:
return _token["value"]
now = int(time.time())
assertion = jwt.encode(
{"iss": CLIENT_ID, "scope": ["rest_webservices"], "aud": TOKEN_URL,
"iat": now, "exp": now + 300},
PRIVATE_KEY, algorithm="PS256", headers={"kid": CERTIFICATE_ID},
)
resp = requests.post(TOKEN_URL, data={
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": assertion,
}, timeout=10)
resp.raise_for_status()
body = resp.json()
_token.update(value=body["access_token"], expires=now + int(body["expires_in"]))
return _token["value"]
def suiteql(query: str) -> list[dict]:
"""Run a SuiteQL query and return every row. NetSuite pages at up to 1,000."""
rows, offset = [], 0
while True:
resp = requests.post(
SUITEQL_URL, params={"limit": 1000, "offset": offset},
headers={"Authorization": f"Bearer {access_token()}",
"Prefer": "transient", "Content-Type": "application/json"},
json={"q": query}, timeout=30,
)
resp.raise_for_status()
body = resp.json()
rows.extend(body["items"])
if not body.get("hasMore"):
return rows
offset += 1000
Facts that shape everything built on this:
- SuiteQL is real SQL. Joins,
GROUP BY, aggregates,BUILTIN.DF()to turn an internal id into its display text, and Oracle date functions such asTO_DATE. “Open bills by vendor, total and count” is one query. This is the opposite of QuickBooks and Xero, where the sum happens in your code. - One table for every transaction. Invoices are
transactionrows withtype = 'CustInvc', vendor bills'VendBill', customer payments'CustPymt', sales orders'SalesOrd'.entitycovers customers and vendors. Amounts in transaction currency are theforeign*columns. - Governance, not rate limits. NetSuite caps concurrent requests per account by service tier: Standard 5, Premium 15, Enterprise and Ultimate 20, plus 10 per SuiteCloud Plus license, shared with every other integration in the account. Development and partner accounts stay at 5 however many licenses you add, which is the ceiling you will actually hit while building. Too many at once is HTTP 429.
- Role is everything. The token’s role decides which records and subsidiaries SuiteQL can see. Scope it to exactly what the bot needs.
- Three authentication flavors, one right answer. Client credentials, shown above, is the machine-to-machine flow: no user login, no refresh token, a fresh 60-minute token whenever you need one. The authorization-code flow suits apps a person signs into, but its refresh tokens expire after seven days and new integrations need PKCE from 2027.1. Token-based authentication (OAuth 1.0a) still works for integrations that already exist, but Oracle blocks creating new ones from NetSuite 2027.1, so do not start a new bot on it.
NetSuite bills due lookup
A vendor bill is a transaction row of type VendBill, and unpaid means foreignamountunpaid > 0:
from datetime import date
def bills_due_before(due: date) -> list[dict]:
# FETCH FIRST in the query, not a [:20] slice afterwards: suiteql() pages
# until hasMore is false, so slicing in Python would pull every open bill
# in the account (1,000 rows a request) and throw nearly all of them away.
rows = suiteql(f"""
SELECT BUILTIN.DF(t.entity) AS vendor, c.symbol AS currency,
t.foreignamountunpaid AS unpaid, t.duedate
FROM transaction t
JOIN currency c ON c.id = t.currency
WHERE t.type = 'VendBill' AND t.foreignamountunpaid > 0
AND t.duedate <= TO_DATE('{due.isoformat()}', 'YYYY-MM-DD')
ORDER BY t.duedate
FETCH FIRST 20 ROWS ONLY
""")
return [
{"vendor": r["vendor"], "currency": r["currency"],
"amount_due": float(r["unpaid"]), "due": r["duedate"]}
for r in rows
]
NetSuite in Power Automate
Microsoft’s connector catalog has no NetSuite connector, first-party or third-party, as of September 2026, so this is a custom connector like the QuickBooks one. Power Automate’s Generic OAuth 2 security type handles the authorization-code flow, not a signed client assertion, so the connector uses NetSuite’s authorization-code flow rather than the client credentials flow the bot uses.
- In NetSuite, on the Integration record, enable OAuth 2.0 with the Authorization Code Grant (PKCE is required for new integrations from 2027.1), add the redirect URL Power Automate will give you, and note the client ID and secret. The connecting user’s role needs “Log in using OAuth 2.0 Access Tokens” and the record permissions.
- In Power Automate, create a custom connector from blank. Host
<account>.suitetalk.api.netsuite.com, base URL/services/rest. - Security: OAuth 2.0, Generic OAuth 2. Authorization URL
https://<account>.app.netsuite.com/app/login/oauth2/authorize.nl, token and refresh URLhttps://<account>.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token, scoperest_webservices. - Definition: an action
RunSuiteQLas aPOSTto/query/v1/suiteqlwith aPrefer: transientheader and a JSON body with aqstring. Import a sample response. - Test with
SELECT BUILTIN.DF(entity) AS vendor, foreignamountunpaid, duedate FROM transaction WHERE type = 'VendBill' AND foreignamountunpaid > 0 ORDER BY duedate.
NetSuite’s OAuth 2.0 refresh tokens expire after seven days, and the refresh response does not issue a replacement, so the connection has to be re-authorized interactively every week. No flow can automate that; the only way around it is a small proxy service that authenticates with the client credentials flow and exposes SuiteQL to the connector. Before relying on this for an integration created after NetSuite 2027.1, confirm that Power Automate’s connector emits PKCE, which those integrations require. Licensing is the same as any custom connector: Premium for the flow owner at $15 per user per month billed yearly, or a Process license at $150 a month. Drop RunSuiteQL into the shared flow shape for the Monday summary.
NetSuite alerts without webhooks
NetSuite has no webhooks, so the shared receiver does not apply. Two routes to a triggered message:
Inside NetSuite: a SuiteScript user event. Deployed on Customer Payment, it posts an Adaptive Card to the Teams workflow URL the moment the record saves, with no server of yours.
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* Deploy on Customer Payment. Posts to a Teams workflow URL the moment a payment is saved.
*/
define(['N/https', 'N/record'], (https, record) => {
// Store the URL in a script parameter rather than in code; shown inline for brevity.
const HOOK_URL = 'https://prod-00.westus.logic.azure.com/workflows/...';
const afterSubmit = (ctx) => {
if (ctx.type !== ctx.UserEventType.CREATE) return;
// ctx.newRecord is a standard-mode record; on CREATE, getText() throws
// unless setText() was called first. Load the saved record instead.
const pay = record.load({ type: record.Type.CUSTOMER_PAYMENT, id: ctx.newRecord.id });
const text = `Payment received: ${pay.getText({ fieldId: 'currency' })} ` +
`${pay.getValue({ fieldId: 'payment' })} from ${pay.getText({ fieldId: 'customer' })}`;
https.post({
url: HOOK_URL,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'message',
attachments: [{
contentType: 'application/vnd.microsoft.card.adaptive',
content: { type: 'AdaptiveCard', version: '1.5',
body: [{ type: 'TextBlock', wrap: true, text }] },
}],
}),
});
};
return { afterSubmit };
});
It runs synchronously inside the save and costs 10 governance units per https.post. If the save must never wait on Teams, have it schedule a Map/Reduce script through N/task and post from there.
Outside NetSuite: poll. A job that runs SuiteQL every few minutes for payments created since the last run and hands each one to the shared post_to_teams function. It is creation-only on purpose: keying on lastmodifieddate would re-announce a payment whenever someone edited it, so edit alerts, if wanted, are a separate query labeled as updates:
import time
from datetime import datetime, timedelta, timezone
def poll_payments(post): # pass post_to_teams from the shared receiver
"""Replaces the webhook receiver for NetSuite: query what changed, post it, repeat."""
# SuiteQL evaluates TO_DATE in the session time zone of the user the
# integration is mapped to. Set that user's time zone to UTC (Home > Set
# Preferences) or the window below shifts by the offset and misses records.
FMT = "%Y-%m-%d %H:%M:%S"
cursor = datetime.now(timezone.utc) - timedelta(minutes=5) # persist this in production
# Payment ids already posted. This set only has to cover the overlap window,
# but as written it grows for the life of the process. In production it is a
# table with the ids aged out past the overlap, not an unbounded set.
announced = set()
while True:
# Fix the upper bound BEFORE the query, and overlap the lower bound by a
# minute, so a payment committed while the query runs is caught next time
# instead of falling between two windows. Dedup makes the overlap safe.
until = datetime.now(timezone.utc)
lower = cursor - timedelta(minutes=1)
rows = suiteql(f"""
SELECT t.id, t.tranid, BUILTIN.DF(t.entity) AS customer,
t.foreigntotal AS amount, c.symbol AS currency
FROM transaction t JOIN currency c ON c.id = t.currency
WHERE t.type = 'CustPymt'
AND t.createddate > TO_DATE('{lower.strftime(FMT)}', 'YYYY-MM-DD HH24:MI:SS')
AND t.createddate <= TO_DATE('{until.strftime(FMT)}', 'YYYY-MM-DD HH24:MI:SS')
""")
for r in rows:
if r["id"] in announced: # creation-only: an edit is not a new payment
continue
announced.add(r["id"])
post(f"Payment received: {r['currency']} {float(r['amount']):.2f} from {r['customer']}")
cursor = until
time.sleep(300) # five minutes is an operational choice, not a NetSuite limit
Concurrency governance limits requests in flight at once, not requests per interval, so a tighter poll is allowed; five minutes is an operational choice, not a NetSuite floor.
NetSuite on Zapier
NetSuite is a premium app on Zapier. All three triggers poll: New Record, New or Updated Record, and New Record (Saved Search). Searches include Find Record, and there is a Run SuiteQL Query action, so a Zap can answer a joined question directly. Connection is through Zapier’s NetSuite Automation SuiteApp login or custom token-based authentication credentials plus your account ID; the app has nowhere to supply the certificate that NetSuite’s client credentials flow needs, so it stays on TBA. What that means once NetSuite 2027.1 blocks new TBA integrations is a question for Zapier.
Recipe 1: saved-search alert (two steps, one task per record)
- Trigger: NetSuite, New Record (Saved Search) on a saved search such as Sales Orders pending fulfillment created today. It deduplicates on Internal ID.
- Action: Microsoft Teams, Send Channel Message. Team and Channel: the plant or sales channel. Message:
New order {{Document Number}} for {{Customer}}, {{Amount}}, status {{Status}}.
Recipe 2: lookup with SuiteQL (five steps, two tasks per question)
- Trigger: Microsoft Teams, New Channel Message on the channel people ask in.
- Filter by Zapier. Continue only if
Textmatches the pattern below and does not start with your reply prefix. - Formatter by Zapier: Text, Extract Pattern. Pattern:
\b[A-Z]{2,6}-?\d{3,}\b, matchingSO-11844orINV1042. - Action: NetSuite, Run SuiteQL Query.
SELECT tranid, BUILTIN.DF(status) AS status, BUILTIN.DF(entity) AS customer, foreigntotal AS total FROM transaction WHERE tranid = '{{Formatter output}}'. Keep the Formatter pattern strict, since the value is interpolated into SQL. - Action: Microsoft Teams, Send Channel Message.
Lookup: {{tranid}} for {{customer}}, {{status}}, total {{total}}.
What none of these routes handle
All four routes share most of the same ceilings. They answer commands or fire on changes, not questions in the asker’s own words. They have no permission model until you build one. On QuickBooks and Xero, totals and joins are paging loops in your code; on NetSuite, SuiteQL does them in the query, but inside a concurrency pool shared with every other integration in the account, and with no webhooks to tell you anything changed. Getting the actual invoice PDF into the chat is another endpoint, another download, and another Teams file upload with its own SharePoint permissions. And none of it stays finished: Intuit rotates refresh tokens, retires minor versions, and changed the webhook payload format wholesale in 2026; Xero added new webhook categories the same year; NetSuite ships two releases a year that every SuiteScript has to be checked against; Microsoft ended support for a whole bot SDK in 2025.
Where Kipper fits
Everything above is the build path. Kipper is the buy path for the same problem: a read-only layer over QuickBooks, Xero, or NetSuite that answers plain-English questions in Microsoft Teams, with permissions, audit logging, and invoice PDFs already built. An accounting admin authorizes it, a Teams admin adds the app, and people ask in their department channel. Pricing starts at $200 a month for QuickBooks and Xero and $1,000 a month for NetSuite, and you pay only for active users: people who ask at least one billable finance question that returns an answer that month. The QuickBooks Teams connector, Xero Teams connector, and NetSuite Teams connector pages have the details and example questions. If the registration table and the “From demo to production” list look like your next quarter, those are the pages to compare against.