Kipper

Accounting Data in Slack: Build Options Compared

A developer walkthrough for looking up QuickBooks, Xero, and NetSuite invoices, bills, and payments from Slack: the Slack-side code every accounting system shares, then the auth, query, and webhook code specific to each system.

Team Kipper · September 15, 2026 · 26 min read
On this page +

This guide is for developers building a way for their team to look up QuickBooks, Xero, or NetSuite data from inside Slack, without buying a product. It compares three routes, in decreasing order of how much they can do and how much they cost to run: a slash-command bot on Slack’s Bolt framework that answers lookups on demand, triggered alerts from the accounting system’s webhooks, and no-code alerts with Zapier.

The Slack 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 code and setup. After it, one section per accounting system supplies only what differs: authentication and token refresh, the query that answers the lookup, webhook signature verification and event parsing, and that system’s Zapier specifics. QuickBooks, Xero, and NetSuite are each covered in full.

One thing to set expectations: every route here delivers either triggered messages (something changed, a message posts) or commands (a person types a fixed syntax, gets one answer). Neither is a natural-language interface, and the closing section is honest about what that rules out.

API limits, SDK versions, and pricing in this post were verified in September 2026.

What every route shares

The Slack app

Create a Slack app with a bot token carrying the chat:write and commands scopes, register a slash command (the examples use /inv), and for local development enable Socket Mode with an app-level token carrying connections:write. Socket Mode is an outbound WebSocket from your process to Slack, which is why it runs on a laptop with no public URL. For triggered alerts, also create an Incoming Webhook for the channel that should receive them; it gives you a URL that accepts a JSON text payload.

You need Python 3.11 or later with slack_bolt, requests, and flask installed. Node 20 or later with @slack/bolt works too, and a JavaScript version of the bot skeleton is collapsed below.

The slash-command bot

Most teams that build this end up with a slash command, so that is the shape of the example. Someone types /inv 1042, the bot looks the invoice up, and posts its status back. The Slack side never touches the accounting API directly. It calls one function, lookup_invoice, which each accounting section below implements and which returns either None or a dictionary with the same seven keys regardless of system.

import os
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from accounting import lookup_invoice  # QuickBooks or Xero implementation, below

app = App(token=os.environ["SLACK_BOT_TOKEN"])


@app.command("/inv")
def handle_invoice(ack, respond, command):
    ack()  # Slack needs this within 3 seconds; do the lookup afterwards
    parts = command["text"].split()
    if len(parts) != 1:
        respond("Usage: `/inv <invoice number>`")
        return

    inv = lookup_invoice(parts[0])
    if inv is None:
        respond(f"No invoice {parts[0]} found.")
        return

    respond(
        f"*{inv['kind']} {inv['number']}* for {inv['contact']}\n"
        f"Total {inv['currency']} {inv['total']:.2f}  |  {inv['status']}  |  Due {inv['due']}"
    )


if __name__ == "__main__":
    SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start()
Same skeleton in JavaScript (Bolt for JavaScript)
const { App } = require("@slack/bolt");
const { lookupInvoice } = require("./accounting"); // QuickBooks or Xero implementation

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  appToken: process.env.SLACK_APP_TOKEN,
  socketMode: true,
});

app.command("/inv", async ({ command, ack, respond }) => {
  await ack();
  const parts = command.text.trim().split(/\s+/);
  if (parts.length !== 1 || !parts[0]) {
    await respond("Usage: `/inv <invoice number>`");
    return;
  }
  const inv = await lookupInvoice(parts[0]);
  if (!inv) {
    await respond(`No invoice ${parts[0]} found.`);
    return;
  }
  await respond(
    `*${inv.kind} ${inv.number}* for ${inv.contact}\n` +
      `Total ${inv.currency} ${inv.total.toFixed(2)}  |  ${inv.status}  |  Due ${inv.due}`
  );
});

(async () => {
  await app.start();
  console.log("Accounting Slack bot running");
})();

The seven keys lookup_invoice returns are kind (“Invoice” or “Bill”), number, contact, currency, total, status, and due. Currency is returned rather than assumed, because multicurrency companies mix USD, EUR, and GBP on one ledger.

Two Slack-side rules apply whatever the backend. Acknowledge within three seconds or Slack shows the user an error, so ack() comes before the lookup. And command["user_id"] tells you who asked, but Slack will not tell you whether that person should see customer balances. That is your permission model, and you should write it before you invite the sales team.

The webhook receiver

For triggered alerts you can skip the bot. Both QuickBooks and Xero call a URL you host when records change. The receiver below is the same for both: verify the signature, queue the events, return 200 fast, and let a worker do the slow part and post to Slack. The three functions it imports, verify_signature, parse_events, and handle_event, are what differ per system.

# 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__)
SLACK_HOOK = os.environ["SLACK_INCOMING_WEBHOOK_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_slack(text: str) -> None:
    requests.post(SLACK_HOOK, json={"text": text}, 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_slack(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()

Webhooks always need a public HTTPS endpoint, because the accounting system calls you. Expect duplicates: both systems retry on a back-off schedule, so handle_event has to be idempotent. Half a day to a day including deployment, and the least useful route for the person with a question, because it only posts when a record changes.

Zapier

Zapier needs no developer. The pattern is the same for every accounting system it supports: a trigger from the accounting app to a Slack Send Channel Message action for alerts, or a Slack New Mention trigger, a Formatter step to extract an invoice number from the message, a Find step against the accounting app, and a threaded reply for a crude lookup. Triggers and built-in steps such as Formatter and Filter do not count as tasks, so an alert is one task and a lookup is two. 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, so the free plan is out.

Three limits apply whichever system is behind it. A Zap answers the one shape you built; anything that combines records or sums across them needs a Code step, which puts you back in bot territory. The Zapier connection carries whatever scopes the app requests, and both integrations include write actions, so read-only is a matter of which steps you choose. And the Slack trigger passes the requester’s user ID, so a Filter step can restrict who gets an answer, but the list of who may see what is yours to maintain.

From demo to production

The bot skeleton plus one accounting implementation is roughly 100 lines and works for one question shape in one workspace. What it does not include is most of the project:

  • The admin consent flow: the redirect handler, state parameter, and token storage keyed by Slack workspace and accounting company or organization.
  • A retry queue for HTTP 429 and for the accounting API’s occasional 5xx responses.
  • A permission layer mapping Slack users to what they may see.
  • A new command, or a parser branch, for every new question shape. “What does Bright Harbor still owe us?” is not /inv.
  • Hosting, secret management, logging, and an alert when the bot stops answering.
  • Someone who gets paged when the accounting vendor changes something.

Realistic vibe-coding effort, a developer directing an AI assistant: one to two days for a demo, another week or two 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, note the client ID and secret, and set a redirect URI. Production keys require the app’s details, including privacy policy and end-user license URLs. The authorization URL is https://appcenter.intuit.com/connect/oauth2 with scope com.intuit.quickbooks.accounting, and 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 Slack workspace.
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_grant on 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}/query takes a SQL-like string: SELECT * FROM <one entity>, WHERE with =, comparisons, IN, and LIKE, ORDERBY, and paging with STARTPOSITION and MAXRESULTS. Values are quoted as strings even when numeric, so an open-balance filter is Balance > '0'.
  • What it will not do. No JOIN, no GROUP BY, no OR, no column projection, and at most 1,000 rows per page, per Intuit’s query documentation. “Which Cedar & Co invoices are more than 30 days past due?” is a Customer lookup, an Invoice query filtered by that id, then date math and a sort in your code. Totals by Class mean paging every invoice and summing line items yourself.
  • Rate limits. 500 requests per minute per company, 10 concurrent. A slash command 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 invoice lookup

The implementation of lookup_invoice the bot skeleton imports. Intuit’s query endpoint has no parameter binding, so the input is interpolated into the string and the only defense is validating it first. Strip everything outside the shape you expect rather than escaping what you do not want: stripping quotes alone still leaves LIKE, IN, and comparison operators reachable from Slack.

import re


def lookup_invoice(number: str) -> dict | None:
    safe = re.sub(r"[^A-Za-z0-9-]", "", number)  # an invoice number, nothing else
    invoices = qb_query(f"SELECT * FROM Invoice WHERE DocNumber = '{safe}'").get("Invoice", [])
    if not invoices:
        return None
    inv = invoices[0]
    return {
        "kind": "Invoice",
        "number": inv["DocNumber"],
        "contact": inv["CustomerRef"]["name"],
        "currency": inv["CurrencyRef"]["value"],
        "total": float(inv["TotalAmt"]),
        "status": "Paid" if float(inv["Balance"]) == 0 else f"Open, {inv['Balance']} due",
        "due": inv["DueDate"],
    }

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, keyed on the verifier token from the developer portal. Intuit expects a 200 within seconds and retries on a back-off schedule if it does not get one.

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 events for several
    # companies, so keep only the company this bot's token belongs to.
    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, and in the developer portal subscribe only to the entities and operations you need; Payment created, Invoice updated, and Bill created cover most alerting.

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. The integration also includes 50-plus write actions, including Create Invoice and Void Invoice, so choose read steps only.

Recipe 1: paid-invoice alert (two steps, one task per payment)

  1. Trigger: QuickBooks Online, New Paid Invoice. Connect as the QuickBooks admin and pick the company. Test with a recently paid invoice so the sample fields populate.
  2. Action: Slack, Send Channel Message. Channel #billing. Message Text: Invoice {{Doc Number}} for {{Customer Ref Name}} paid, {{Currency Ref Value}} {{Total Amt}}. Set “Send as a bot” to yes and give it a name so the post is not attributed to whoever built the Zap.

Swap the trigger for New Invoice Due to get overdue alerts, or New Bill for the AP side, and the action stays the same.

Recipe 2: invoice lookup by mention (four steps, two tasks per question)

  1. Trigger: Slack, New Mention. Fires when someone writes @zapier invoice 1042 in a channel the connected Zapier user is in. The trigger returns the message Text, the Ts timestamp, and the Channel.
  2. Formatter by Zapier: Text, Extract Pattern. Input: the message Text. Pattern: \b(\d{3,})\b. This pulls the first run of three or more digits out of the message as the invoice number. Formatter steps are free.
  3. Search: QuickBooks Online, Find Invoice. Search by invoice number, using the Formatter output. Leave “Create QuickBooks Online Invoice if it doesn’t exist” unticked; you want a read, not a write.
  4. Action: Slack, Send Channel Message. Channel: the trigger’s Channel. Thread: the trigger’s Ts, so the answer lands under the question. Message Text: Invoice {{Doc Number}} for {{Customer Ref Name}}: {{Currency Ref Value}} {{Total Amt}}, balance {{Balance}}, due {{Due Date}}.

If Find Invoice returns nothing, Zapier stops the Zap at step 3 by default and nothing posts. Add a Paths step after it if you want a “not found” reply instead of silence. To restrict who may ask, add a Filter by Zapier step after the trigger on the User field.

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 for a refresh token. After a Xero admin authorizes it, call GET https://api.xero.com/connections to get the tenantId of each organization they connected.

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 workspace 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 with where, Statuses, ContactIDs, or InvoiceNumbers; page with page and pageSize up to 1,000.
  • Contacts are both customers and suppliers. One /Contacts endpoint, no vendor object.
  • Dates come back twice. DueDate is the legacy /Date(1761868800000+0000)/ format; DueDateString is the readable 2026-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 no GROUP BY or SUM.
  • Refresh tokens expire after 60 days unused. A bot nobody has used for two months has to be re-authorized by the admin.

Xero invoice lookup

Simpler than the QuickBooks version in one respect: the Invoices endpoint filters by the human-readable number directly through InvoiceNumbers, and because bills are invoices too, the same call answers /inv INV-1904 and /inv BILL-0231.

import re


def lookup_invoice(number: str) -> dict | None:
    safe = re.sub(r"[^A-Za-z0-9-]", "", number)  # INV-1904, nothing else
    invoices = xero_get("/Invoices", {"InvoiceNumbers": safe}).get("Invoices", [])
    if not invoices:
        return None
    inv = invoices[0]
    if inv["Status"] == "PAID":
        status = "Paid"
    else:
        status = f"{inv['Status'].title()}, {inv['AmountDue']:.2f} due"
    return {
        "kind": "Bill" if inv["Type"] == "ACCPAY" else "Invoice",
        "number": inv["InvoiceNumber"],
        "contact": inv["Contact"]["Name"],
        "currency": inv["CurrencyCode"],
        "total": float(inv["Total"]),
        "status": status,
        "due": inv["DueDateString"][:10],
    }

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 a 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. The announced_paid set stops the same invoice being posted twice, which matters because later edits to an already-paid invoice fire UPDATE again; in production that is a table keyed on InvoiceID, and general deduplication uses resourceId plus eventDateUtc.

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, New Payment, New Reconciled Payment, Overdue Sales Invoice, New Credit Note, and New Purchase Order poll, so alerts arrive on Zapier’s polling interval rather than the moment the record changes. Find Invoice searches by invoice number or reference, and Find Payment and Find Contact cover the other single-record questions. Tracking Category totals stay out of reach.

Recipe 1: overdue-invoice alert (two steps, one task per invoice)

  1. Trigger: Xero, Overdue Sales Invoice. Pick the organization and set “Days overdue” (7 is a sensible start). It polls, so expect the alert within Zapier’s interval rather than the minute the invoice ticks over.
  2. Action: Slack, Send Channel Message. Channel #studio-billing. Message Text: {{Invoice Number}} for {{Contact Name}} is overdue: {{Currency Code}} {{Amount Due}}, due {{Due Date}}. Send as a bot.

Use New Bill with the same action for supplier bills, and Updated Sales Invoice with a Filter on Status equals PAID for a paid alert, since Xero has no separate payment trigger and New Payment polls.

Recipe 2: invoice lookup by mention (four steps, two tasks per question)

  1. Trigger: Slack, New Mention. Someone writes @zapier INV-1904 in a channel the connected Zapier user is in.
  2. Formatter by Zapier: Text, Extract Pattern. Input: the message Text. Pattern: \b[A-Z]{2,4}-\d{3,}\b, which matches Xero-style numbers such as INV-1904 or BILL-0231.
  3. Search: Xero, Find Invoice. Organization: yours. Search field: invoice number, using the Formatter output. Because bills are invoices in Xero, this finds both.
  4. Action: Slack, Send Channel Message. Channel and Thread from the trigger, so the answer sits under the question. Message Text: {{Type}} {{Invoice Number}} for {{Contact Name}}: {{Currency Code}} {{Total}}, {{Status}}, amount due {{Amount Due}}, due {{Due Date}}.

Zapier stops at step 3 when nothing matches; add a Paths step for a “not found” reply. Restrict who may ask with a Filter on the trigger’s User field.

NetSuite

NetSuite changes the shape of the accounting half more than Xero does. The query language is better, the authentication is heavier, there are no webhooks at all, and the constraint that matters is not requests per minute but concurrent requests per account. 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 as TO_DATE. “Unpaid invoices over 60 days for Riverton Labs, 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 transaction rows with type = 'CustInvc', vendor bills 'VendBill', customer payments 'CustPymt', sales orders 'SalesOrd'. entity covers customers and vendors. Amounts in transaction currency are the foreign* 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. A bot that fans out queries competes with your billing sync for the same slots.
  • 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 invoice lookup

Because bills and invoices share the transaction table, one query serves both, and the join to currency returns the symbol directly. One caveat: foreignamountunpaid is populated for A/R-style transactions, so a vendor bill can come back null, which is why the code below coerces it before comparing:

def lookup_invoice(number: str) -> dict | None:
    safe = re.sub(r"[^A-Za-z0-9-]", "", number)  # INV1042 or a bill's tranid, nothing else
    rows = suiteql(f"""
        SELECT t.tranid, t.type, BUILTIN.DF(t.status) AS status, t.duedate,
               t.foreigntotal AS total, t.foreignamountunpaid AS unpaid,
               c.symbol AS currency, BUILTIN.DF(t.entity) AS contact
        FROM transaction t
        JOIN currency c ON c.id = t.currency
        WHERE t.tranid = '{safe}' AND t.type IN ('CustInvc', 'VendBill')
    """)
    if not rows:
        return None
    r = rows[0]
    unpaid = float(r["unpaid"] or 0)
    return {
        "kind": "Bill" if r["type"] == "VendBill" else "Invoice",
        "number": r["tranid"],
        "contact": r["contact"],
        "currency": r["currency"],
        "total": float(r["total"]),
        "status": r["status"] if unpaid == 0 else f"{r['status']}, {unpaid:.2f} due",
        "due": r["duedate"],
    }

NetSuite alerts without webhooks

NetSuite has no webhooks, so the shared webhook receiver does not apply. There are two ways to get a triggered message out, and they are different in kind.

Inside NetSuite: a SuiteScript user event. An afterSubmit script deployed on the record you care about can post to Slack’s incoming webhook URL the moment the record saves, with no server of yours involved. A NetSuite developer deploys this in an afternoon.

/**
 * @NApiVersion 2.1
 * @NScriptType UserEventScript
 * Deploy on Customer Payment. Posts to a Slack incoming webhook 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://hooks.slack.com/services/...';

  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({ text }),
    });
  };

  return { afterSubmit };
});

It runs synchronously inside the save, costs 10 governance units per https.post against a user event limit of 1,000, and, like every customization, needs a look at each NetSuite release. If the save must never wait on Slack, have the user event schedule a Map/Reduce script through N/task and post from there.

Outside NetSuite: poll. If you do not want code in the account, a job that runs SuiteQL every few minutes for payments created since the last run is what every non-SuiteScript integration does. It plugs into the shared post_to_slack function instead of the receiver. It is creation-only on purpose: a poller keyed on lastmodifieddate would announce a payment again every time someone edited its memo, so if you also want edit alerts, query them separately, label them as updates, and filter for the fields that matter:

import time
from datetime import datetime, timedelta, timezone


def poll_payments(post):
    """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

Polling latency is the interval you choose. Concurrency governance limits how many requests are in flight at once, not how many run per minute, so a one-minute poll is allowed; five minutes is an operational choice that keeps the job invisible to the rest of the account’s integrations.

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), the last of which turns any saved search into an alert and is the most useful. Searches include Find Record by field, and, unusually, there is a Run SuiteQL Query action, so a Zap can answer a joined, grouped question that would need a Code step for QuickBooks or Xero. 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 Zap steps, one task per record)

First, in NetSuite, create a saved search, for example Customer Payments created today, and note its ID. Then build the Zap:

  1. Trigger: NetSuite, New Record (Saved Search). Pick the record type and the saved search. It deduplicates on Internal ID, so a search that returns the same record twice will misbehave.
  2. Action: Slack, Send Channel Message. Channel #finance. Message Text: Payment {{Document Number}} received from {{Customer}}, {{Amount}}. Send as a bot.

Recipe 2: lookup with SuiteQL (four steps, two tasks per question)

  1. Trigger: Slack, New Mention. Someone writes @zapier INV1042.
  2. Formatter by Zapier: Text, Extract Pattern. Input: message Text. Pattern: \b[A-Z]{2,6}-?\d{3,}\b, matching INV1042 or SO-11844.
  3. Action: NetSuite, Run SuiteQL Query. Query: SELECT tranid, BUILTIN.DF(status) AS status, foreignamountunpaid AS unpaid, duedate, BUILTIN.DF(entity) AS customer FROM transaction WHERE tranid = '{{Formatter output}}'. Because the number is interpolated into SQL, keep the Formatter pattern strict so nothing else can reach the query.
  4. Action: Slack, Send Channel Message. Channel and Thread from the trigger. Message Text: {{tranid}} for {{customer}}: {{status}}, {{unpaid}} due, due {{duedate}}.

Every Zap run is a request against the account like any other, so a saved-search trigger polling a busy record type shows up in your integration governance view even when the tasks are cheap.

What none of these routes handle

All three 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 thread is another endpoint, another download, and another Slack file upload. 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.

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 Slack, with permissions, audit logging, and invoice PDFs already built. An accounting admin authorizes it, you install the Slack app, and people ask in the channel they already have open. 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 Slack connector, Xero Slack connector, and NetSuite Slack connector pages have the details and example questions. If the list under “From demo to production” looks like your next quarter, those are the pages to compare against.

Ask your finance data anything.

Kipper connects NetSuite to the tools your team already uses. NetSuite plans start at $1,000/month with up to 20 active users included, plus $25/month per additional active user.