Track OpenAI API costs: from the Costs API to your desk

Understand OpenAI API costs, reporting periods and token usage. Build a reliable cost summary and see the native TickrMeter display option.

Updated on
Track OpenAI API costs: from the Costs API to your desk

To track OpenAI API costs, use the organization Costs API at GET /v1/organization/costs, authenticated with an Admin API key. Choose a reporting period, follow every page, and total the reported amounts by currency. For a quick check without code, start with the OpenAI API usage dashboard.

This guide builds a small Python report you can reconcile against that dashboard. Then, if you'd like the useful number somewhere you can actually see it, TickrMeter offers a native organization connection for your desk.

Which number are you trying to track?

Organization API spend, token activity and personal subscription allowance answer different questions. Giving them all a dollar sign would make the display tidy and the answer wrong.

Question Use this reading
What API costs were reported for this period? Costs API amounts, with their currency and reporting dates.
How many completion tokens or requests did we use? Completions Usage API activity, with the relevant filters.
How much personal Codex allowance remains? Codex's subscription usage view and reset windows.

The Completions Usage API counts completion activity. It doesn't describe every billed service. Its input-token total already includes cached input; adding cached tokens again double-counts that activity. A local token-price estimate also shouldn't replace provider cost reporting.

If you meant your personal coding allowance, use our Codex usage guide. The report below reads organization API costs.

1. Prepare the account and reporting period

You'll need Python 3, access to the relevant OpenAI organization, and an Admin API key that can read its costs. Follow the official Admin API instructions to obtain the appropriate key. A normal project API key isn't interchangeable with an Admin key.

Supply the key to the Python process as OPENAI_ADMIN_KEY using your usual secret manager or environment setup. Keep it out of the script, screenshots and AI prompts. OpenAI's production guidance recommends environment variables or secret management instead of hard-coded credentials.

Choose completed UTC dates for your first comparison. For example, October 1–13 uses a start of 2026-10-01 and an exclusive end of 2026-10-14. The ending date isn't included. This avoids quietly counting an extra day.

The Costs API reference defines daily buckets and pagination. This example reads the whole organization without project or line-item filters, keeping the comparison straightforward.

2. Run a complete Python cost report

Save the code below as openai_costs.py. It uses Python's standard library, so there's no package installation. With the environment variable available, run:

python openai_costs.py 2026-10-01 2026-10-14

The script follows pagination, rejects repeated days, and keeps monetary arithmetic in Decimal. It returns a separate total for each currency. Rerunning it recomputes the period; it doesn't add another copy to a stored total.

Show the complete Python example
"""Read reported organization costs for a complete UTC date range.

Usage: python openai_costs.py 2026-10-01 2026-10-14
Provide OPENAI_ADMIN_KEY through your process environment. The end is exclusive.
Documentation-based example; validated offline, not with a live account.
"""

import argparse
import json
import os
import re
import sys
import time
from datetime import date, datetime, timezone
from decimal import Decimal
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import HTTPRedirectHandler, Request, build_opener

ENDPOINT = "https://api.openai.com/v1/organization/costs"
DAY = 86400


class CostsError(Exception):
    pass


class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def utc_boundary(value):
    if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
        raise CostsError("Use YYYY-MM-DD dates.")
    try:
        day = date.fromisoformat(value)
        return int(datetime(day.year, day.month, day.day, tzinfo=timezone.utc).timestamp())
    except ValueError:
        raise CostsError("Invalid calendar date.") from None


def api_reader(key):
    opener = build_opener(NoRedirect())

    def read_page(params, timeout):
        request = Request(ENDPOINT + "?" + urlencode(params), headers={
            "Authorization": "Bearer " + key, "Accept": "application/json"})
        try:
            with opener.open(request, timeout=timeout) as response:
                raw = response.read(2 * 1024 * 1024 + 1)
            if len(raw) > 2 * 1024 * 1024:
                raise CostsError("Response too large; no total returned.")
            return json.loads(raw, parse_float=Decimal)
        except HTTPError as error:
            raise CostsError(f"OpenAI returned HTTP {error.code}; no total returned.") from None
        except (URLError, TimeoutError, OSError):
            raise CostsError("Request failed or timed out; no total returned.") from None
        except (ValueError, UnicodeError):
            raise CostsError("Invalid JSON response; no total returned.") from None

    return read_page


def collect_costs(start_date, end_date, read_page):
    start, end = utc_boundary(start_date), utc_boundary(end_date)
    if not 0 < end - start <= 366 * DAY:
        raise CostsError("Choose an increasing date range of at most 366 days.")
    params = {"start_time": start, "end_time": end, "bucket_width": "1d", "limit": 31}
    totals, seen_days, seen_cursors = {}, set(), set()
    empty_days = 0
    deadline = time.monotonic() + 120
    for _ in range(100):
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise CostsError("Collection time limit reached; no total returned.")
        page = read_page(dict(params), min(15, remaining))
        if (not isinstance(page, dict) or page.get("object") != "page"
                or "error" in page or not isinstance(page.get("data"), list)
                or type(page.get("has_more")) is not bool or "next_page" not in page):
            raise CostsError("Incomplete page response; no total returned.")
        for bucket in page["data"]:
            if not isinstance(bucket, dict) or bucket.get("object") != "bucket":
                raise CostsError("Invalid cost bucket; no total returned.")
            a, b, rows = bucket.get("start_time"), bucket.get("end_time"), bucket.get("results")
            if (type(a) is not int or type(b) is not int or a % DAY or b != a + DAY
                    or a < start or b > end or a in seen_days or not isinstance(rows, list)):
                raise CostsError("Missing, repeated or unexpected day; no total returned.")
            seen_days.add(a)
            empty_days += not rows
            for row in rows:
                if not isinstance(row, dict) or row.get("object") != "organization.costs.result":
                    raise CostsError("Unexpected result type; no total returned.")
                amount = row.get("amount")
                if not isinstance(amount, dict):
                    raise CostsError("Missing amount; no total returned.")
                currency, value = amount.get("currency"), amount.get("value")
                if (not isinstance(currency, str) or not re.fullmatch(r"[a-z]{3}", currency)
                        or isinstance(value, bool) or not isinstance(value, (int, Decimal))):
                    raise CostsError("Missing or invalid currency/value; no total returned.")
                value = Decimal(value)
                if not value.is_finite():
                    raise CostsError("Non-finite amount; no total returned.")
                totals[currency] = totals.get(currency, Decimal(0)) + value
        cursor = page["next_page"]
        if not page["has_more"]:
            if cursor is not None:
                raise CostsError("Conflicting pagination state; no total returned.")
            break
        if not isinstance(cursor, str) or not cursor or cursor in seen_cursors:
            raise CostsError("Missing or repeated page cursor; no total returned.")
        seen_cursors.add(cursor)
        params["page"] = cursor
    else:
        raise CostsError("Page limit reached; no total returned.")
    if seen_days != set(range(start, end, DAY)) or not totals:
        raise CostsError("Incomplete period or no reported cost values; total unavailable.")
    return {
        "period_start_inclusive_utc": start_date + "T00:00:00Z",
        "period_end_exclusive_utc": end_date + "T00:00:00Z",
        "reported_totals": {currency: format(value, "f") for currency, value in sorted(totals.items())},
        "days_without_cost_rows": empty_days,
        "collected_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
    }


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("start_date")
    parser.add_argument("end_date", help="Exclusive end date, UTC")
    args = parser.parse_args()
    key = os.environ.get("OPENAI_ADMIN_KEY", "").strip()
    if not key:
        print("OPENAI_ADMIN_KEY is missing; no request made.", file=sys.stderr)
        return 1
    try:
        report = collect_costs(args.start_date, args.end_date, api_reader(key))
    except CostsError as error:
        print(str(error), file=sys.stderr)
        return 1
    print(json.dumps(report, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())

An example result might contain the following fields. These values are invented to show the format:

{
  "period_start_inclusive_utc": "2026-10-01T00:00:00Z",
  "period_end_exclusive_utc": "2026-10-14T00:00:00Z",
  "reported_totals": {"usd": "12.40"},
  "days_without_cost_rows": 0,
  "collected_at_utc": "2026-10-14T09:30:00Z"
}

The collection time tells you when the script read the report. It doesn't prove that every recent charge has finished appearing. Keep the reporting dates beside the amount.

For this example, every requested day must have a bucket. An absent day, malformed amount, broken page cursor or failed request produces an error instead of a reassuring zero. Empty buckets are counted separately; an entirely empty period remains unavailable. Explicit numeric zero is accepted.

This code has been checked with synthetic responses, including pagination, multiple currencies and failures. It hasn't been run against a live billing account.

3. Reconcile before relying on the number

  1. Open the API usage dashboard for the same organization.
  2. Use the same dates and UTC boundaries. Remove project or other filters for this example.
  3. Compare costs in the same currency. Don't compare the money total with a token chart.
  4. If the figures differ, check scope and reporting freshness before changing the calculation.

For your own dashboard, keep each successful report with its period and collection time. Replace the displayed period total on the next successful run. On failure, retain the last successful value with a stale or error label; don't overwrite it with zero. The sample prints a report once. Scheduling and persistent storage are separate choices.

TickrMeter showing OpenAI API spend of $18.42 today and $238.65 this month.
TickrMeter showing OpenAI API spend: $18.42 today and $238.65 this month.

4. Put API spend on TickrMeter

You can use TickrMeter's native AI Usage connection without running this Python report. In Apps, open AI Usage and choose OpenAI API — organization usage in the account connection. Enter the appropriate Admin API key in that connection form, then choose the spend value offered for your display.

The native spend views use USD totals for today or month to date, with the setup's configured timezone. Match that timezone and period when comparing with the provider. The Python report above supports its own completed UTC range and separate currency totals.

Check the preview's account, period and amount before sending it to your TickrMeter. Keep the period and USD label with the number. TickrMeter handles the provider connection for this route; a personal Codex helper and an MCP connection aren't prerequisites for organization costs. The integration documentation explains the available connections.

Provider reporting and screen updates happen separately, so treat this as a useful view of reported spend. For a custom metric or layout, the custom display API is another option. Start with the native connection when it already shows what you need.

Does this include my ChatGPT subscription?

No. This tutorial reads organization API costs. Personal Codex allowance belongs to a separate connection and has its own windows and resets.

Can a desk display stop API overspending?

The display helps you notice spend. Configure spending controls and alerts with the API provider if you need enforcement or notifications; showing a number doesn't change those settings.

Explore the TickrMeter AI usage display to choose between personal allowance and organization spend.

Updated on
Subheading

Heading

Some description