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| Check | What it looks for |
|---|---|
| Spend without a receipt | Expenses of the month whose receipt or invoice is missing or declaredAsInvalid |
| Spend not exported to accounting yet | Expenses of the month still toPrepare or toExport |
| Invoices still waiting for approval | Supplier invoices issued in the month, approval pending |
| Invoices whose export failed | Supplier invoices issued in the month, export failed |
| Declined card payments | Card 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, andstage=pendingApproval— orexportStatus=failed. -
Declined card payments — Requests and transactions → Get Failed Transactions, with
startDate=2026-08-01andendDate=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
toPreparemeans the expense is still being prepared in Spendesk (accounting fields, receipt);toExportmeans 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 examplefor 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_IDto 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.