Month-end close checklist

What is still missing before closing a month: receipts, exports to accounting, approvals, failed exports.

Before closing a month, check in one go what still needs a follow-up:

Month-end checklist for 2026-08

OK    Spend without a receipt: 0
CHECK Spend not exported to accounting yet: 3
OK    Invoices still waiting for approval: 0
OK    Invoices whose export failed: 0
OK    Declined card payments: 0
CheckWhat it looks for
Spend without a receiptExpenses of the month whose receipt or invoice is missing or declaredAsInvalid
Spend not exported to accounting yetExpenses of the month still toPrepare or toExport
Invoices still waiting for approvalSupplier invoices issued in the month, approval pending
Invoices whose export failedSupplier invoices issued in the month, export failed
Declined card paymentsCard payments declined during the month — nothing to book, but worth a look

Scopes: experimental:payable-search:read, experimental:invoice:read, experimental:transaction:read, and to find the companies: experimental:company:read with an organisation-level key, or user:read with a company-level key. Set-up: Running the recipes.

With Postman

Each check is one request; send them one after the other.

  • Receipts and exports — Payables → Search Payables, with the month and one more filter:

    {
      "filters": {
        "operator": "and",
        "subfilters": [
          { "field": "payableDate", "operator": "between",
            "value": { "from": "2026-08-01T00:00:00.000Z", "to": "2026-08-31T23:59:59.999Z" } },
          { "field": "documentaryEvidenceStatus", "operator": "=", "value": ["missing", "declaredAsInvalid"] }
        ]
      },
      "limit": 100
    }

    For exports, replace the second filter with { "field": "bookkeepingStatus", "operator": "=", "value": ["toPrepare", "toExport"] }.

  • Invoices — Invoices and intakes → List invoices, with issuedAfter = 2026-08-01, issuedBefore = 2026-08-31, and stage = pendingApproval — or exportStatus = failed.

  • Declined card payments — Requests and transactions → Get Failed Transactions, with startDate = 2026-08-01 and endDate = 2026-08-31.

With an AI assistant

For August 2026: how many expenses are missing a receipt, how many are not exported to accounting yet, which supplier invoices are still waiting for approval, and did any invoice export fail?

The assistant answers the first three checks, but it cannot tell a failed export from one still waiting: both show as not exported. For failed exports, use the List invoices request above or the script, which read the invoices' exportStatus.

With a script

Save it as month_end_checklist.py, then run python3 month_end_checklist.py 2026-08 — or with no month for last month.

"""Month-end close checklist: what is still missing before closing a month.

Usage: python3 month_end_checklist.py [YYYY-MM]   (default: last month)
Needs SPENDESK_CLIENT_ID and SPENDESK_CLIENT_SECRET. With an organisation-level key it covers every
company, or only SPENDESK_COMPANY_ID if set.
Scopes: experimental:payable-search:read, experimental:invoice:read, experimental:transaction:read, and
experimental:company:read (organisation-level key) or user:read (company-level key).
"""
import base64, datetime as dt, json, os, sys, urllib.error, urllib.parse, urllib.request

BASE = os.environ.get("SPENDESK_BASE_URL", "https://public-api.spendesk.com")


def token():
    creds = base64.b64encode(f"{os.environ['SPENDESK_CLIENT_ID']}:{os.environ['SPENDESK_CLIENT_SECRET']}".encode()).decode()
    req = urllib.request.Request(BASE + "/v1/auth/token", data=b"grant_type=client_credentials",
                                 headers={"Authorization": "Basic " + creds, "Content-Type": "application/x-www-form-urlencoded"})
    return json.load(urllib.request.urlopen(req))["access_token"]


TOKEN = token()


def api(method, path, query=None, body=None, company=None):
    url = BASE + path + ("?" + urllib.parse.urlencode(query, doseq=True) if query else "")
    headers = {"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"}
    if company:
        headers["X-Company-Id"] = company
    req = urllib.request.Request(url, method=method, headers=headers, data=json.dumps(body).encode() if body else None)
    return json.load(urllib.request.urlopen(req))


def companies(need_id=False):
    """The companies to cover, as (id, name, X-Company-Id header).

    Organisation-level key: every company of the organisation, or only SPENDESK_COMPANY_ID if set.
    Company-level key: the key's own company (no header needed)."""
    only = os.environ.get("SPENDESK_COMPANY_ID")
    try:
        found, page = [], 1
        while True:
            result = api("GET", "/v2/companies", {"page": page, "pageSize": 30})
            found += [(c["id"], c["name"], c["id"]) for c in result["data"]]
            if page * 30 >= result["meta"]["pagination"]["total"]:
                return [c for c in found if not only or c[0] == only]
            page += 1
    except urllib.error.HTTPError:
        if only:  # organisation-level key without experimental:company:read
            return [(only, only, only)]
        own = api("GET", "/v1/users", {"pageSize": 1})["data"][0]["companyId"] if need_id else None
        return [(own, "", None)]

today = dt.date.today()
month = sys.argv[1] if len(sys.argv) > 1 else (today.replace(day=1) - dt.timedelta(days=1)).strftime("%Y-%m")
first = dt.date.fromisoformat(month + "-01")
last = (first + dt.timedelta(days=32)).replace(day=1) - dt.timedelta(days=1)


def search(extra_filter, header):
    """Payables of the month matching one more filter, every page."""
    found, cursor = [], None
    while True:
        body = {"filters": {"operator": "and", "subfilters": [
            {"field": "payableDate", "operator": "between",
             "value": {"from": f"{first}T00:00:00.000Z", "to": f"{last}T23:59:59.999Z"}},
            extra_filter]}, "limit": 100}
        if cursor:
            body["cursor"] = cursor
        page = api("POST", "/v1/payables/search", body=body, company=header)
        found += page["payables"]
        cursor = page.get("nextCursor")
        if not cursor:
            return found


def invoices(company_id, query):
    """Invoices issued in the month matching the query, every page."""
    found, cursor = [], None
    while True:
        page = api("GET", f"/v2/companies/{company_id}/invoices",
                   dict(query, issuedAfter=str(first), issuedBefore=str(last), limit=100, **({"cursor": cursor} if cursor else {})))
        found += page["data"]
        cursor = page["meta"]["pagination"]["nextCursor"]
        if not cursor:
            return found


print(f"Month-end checklist for {month}")
for company_id, company_name, header in companies(need_id=True):
    checks = {
        "Spend without a receipt": search({"field": "documentaryEvidenceStatus", "operator": "=", "value": ["missing", "declaredAsInvalid"]}, header),
        "Spend not exported to accounting yet": search({"field": "bookkeepingStatus", "operator": "=", "value": ["toPrepare", "toExport"]}, header),
        "Invoices still waiting for approval": invoices(company_id, {"stage": ["pendingApproval"]}),
        "Invoices whose export failed": invoices(company_id, {"exportStatus": ["failed"]}),
        "Declined card payments": api("GET", "/v1/transactions/failed", {"startDate": str(first), "endDate": str(last)}, company=header)["data"],
    }
    print(f"\n== {company_name}" if company_name else "")
    for label, items in checks.items():
        print(f"{'OK' if not items else 'CHECK':<6}{label}: {len(items)}")

Good to know

  • toPrepare means the expense is still being prepared in Spendesk (accounting fields, receipt); toExport means it is ready and waits for the export. See Exporting accounting data for the export itself.
  • Invoices are counted by issue date, expenses by their date in Spendesk: an invoice issued at the end of a month can be booked in the next one.
  • To see the detail behind a CHECK, print the list instead of its length — for example for p in checks["Spend without a receipt"]: print(p["id"], p.get("description")).
  • Several companies: with an organisation-level key, the script runs the checklist for every company, one block per company. Set SPENDESK_COMPANY_ID to check only one.

The requests are Search Payables, List invoices and Get Failed Transactions.

Last checked against the Spendesk demo environment on 29 September 2026.