Diagnostic LLM APIs
Organization invites sat pending until they expired
The new engineer said she was set up, and she was: the laptop arrived, SSO worked, the repository cloned. Six weeks later somebody notices that the nightly job is running under a colleague's personal key because she asked to borrow it on her second day and never stopped. The invite to the API organization was sent on her first morning, went to a filtered folder, and is still sitting in the list marked pending with an expires_at that passed in April.
GET /v1/organization/invites?limit=100 with an organization admin key, paginated on after. Each organization.invite carries id, email, role ("owner" or "reader"), status ("pending", "accepted", "expired"), expires_at, accepted_at, a sent timestamp, and projects[] with a per-project role.
The test that matters is on the timestamp, not the status. An invite can read status: "pending" while its expires_at is already in the past, so a filter that trusts the status field alone never sees the row at all. Compare expires_at against now and treat that as its own state, separate from the ones the API has already relabelled expired.
Then cross-check the roster. GET /v1/organization/users?limit=100: a pending invite for somebody who already appears there is a stale record rather than a failed onboarding, and it has a one-line repair. What is left is the real list — people who were told they had access and do not.
Read the role and the projects[] grants before you delete anything. An outstanding invite at role: "owner" is an unclaimed grant of full organization control sitting in somebody's mailbox, and a projects[] entry with role: "owner" is the same thing scoped to a project.
The problem in plain words
An invite is fire-and-forget in both directions. Nothing chases the recipient, and nothing tells the sender that it lapsed. The sender closed the onboarding ticket the moment the invite went out, because sending it was the task, and the acceptance is a step that happens in a mailbox they cannot see.
What actually happens next is the operational cost. The person needs to do their job, so they borrow a credential: a colleague's key in an environment file, a service account key pasted into a chat, a project key that ends up in three places. The absence of access does not stop the work, it just routes the work through somebody else's identity, which is how a key ends up authenticating requests that its owner cannot account for.
And there is a second half that has nothing to do with onboarding. An outstanding invite is a standing offer. A pending or lapsed invite at owner for a candidate who was never hired, or an employee who has since left, is a grant of organization control that nobody is watching and that only requires access to one mailbox.
Why it happens
The status field and the clock disagree, and the disagreement is the whole detection. Invites carry an expires_at and the platform relabels them expired on its own, but a record can sit at pending with an expires_at that has already gone by. An audit written as status == "expired" reports a tidy list and misses exactly the rows that are neither live nor cleaned up. The script therefore compares the timestamp itself and reports the two states separately, because a lapsed pending record and an already-relabelled one call for different sentences in the same email.
Not every stale invite is a failed onboarding, and mixing them wastes the reviewer's attention. A pending invite for somebody who is already in GET /v1/organization/users means the person got in another way — a second invite, a different address, SSO provisioning — and the only thing to do is delete the record. The script resolves every invite against the roster first and files those separately, so the list a human reads is people who genuinely do not have access.
An invite is not only a membership, and the deletion withdraws more than people expect. The projects[] array carries per-project roles that take effect on acceptance, so an invite can hand over organization reader and project owner in the same message. The script prints the grants alongside the role for that reason: deleting the invite is correct, and re-sending it later without the project entries silently gives the person less than the first one offered.
The one thing the API cannot tell you is whether the email arrived. There is no delivery status on this object, and no read-only way to distinguish a filtered message from an ignored one. The script never claims to know why an invite went uncollected; it reports how long ago it was sent, when it expired, what it granted, and whether the person is in the org by some other route. The reconstruction that does exist is the audit log, where invite.sent, invite.accepted and invite.deleted give you the history of who offered what and when.
The fix, as a flow
The sender's half of this finishes the moment the invite goes out, because sending it was the ticket. The recipient's half happens in a mailbox nobody else can see. What connects them is a record whose status field and whose clock disagree, which is also the only reason a script can find it: the row still reads pending long after its expiry passed, so a status filter never returns it.
How to fix it
Use an organization admin key, provisioned read-only
/v1/organization/invites and /v1/organization/users both reject a project key. Read scopes are enough; this script sends nothing and deletes nothing.
List every invite, not just the pending ones
GET /v1/organization/invites?limit=100, paginated on after. Do not narrow by status at the API: the accepted rows cost nothing to read and the expired ones are half the cleanup.
Test expires_at against the clock, not status against a string
A record at status: "pending" whose expires_at has passed is the row a status filter never returns. Grade it separately from the rows the platform has already relabelled expired, because one is a lapse and the other is a backlog.
Resolve every invitee against the current roster
GET /v1/organization/users?limit=100. A pending invite for an existing member is a stale record with a one-line repair, and separating those keeps the list a human reads down to people who really have no access.
Sort by what the invite granted, and print the repair
role: "owner" first, then anything with a project owner grant, then the rest. The repair per row is DELETE /v1/organization/invites/{invite_id}, followed for the ones that should still proceed by a fresh invite carrying the same projects[] entries — printed, never performed.
How to check it worked
Delete the stale records, re-send the ones that should proceed, and re-run in a week. The lapsed rows should be gone rather than reclassified, and any invite that is pending again with a fresh expires_at is one to chase by hand, because the API will not tell you whether the second email arrived either.
python3 openai_stale_invite_audit.py --stale-days 14
# 23 invite(s), 14 accepted, 7 finding(s)
# expired-but-still-pending r***@example.com role=owner sent 137 day(s) ago,
# expires_at passed 107 day(s) ago
# grants: proj_ingest=owner, proj_web=member
# repair: this invite still offers full organization control. Delete it first.
# repair: DELETE /v1/organization/invites/invite_01hd
# already-a-member m***@example.com role=reader sent 61 day(s) ago
# repair: this person is already in the roster. Delete the record; there is no
# onboarding problem here.
# pending-stale j***@example.com role=reader sent 29 day(s) ago
# repair: ask whether they ever received it, then delete and re-send with the
# same projects[] entries, or delete and stop.
The full code
Two paged GETs and six pure functions. The sent-timestamp reader, which accepts either name the field goes by; the email mask; the owner-grant test, which looks at the project entries as well as the top-level role; the project-grant list; the roster index; and the classifier, which tests expires_at against the clock before it tests status against a string, because that ordering is the only reason the lapsed-but-pending rows appear at all. No token, key or secret appears anywhere in the output: the invite object carries none, and the script prints ids, masked addresses, roles and dates.
"""Find OpenAI organization invites that lapsed without anybody noticing.
Read only. Two paged GETs against /v1/organization/invites and
/v1/organization/users with an organization admin key. Every request is a GET
and no request body is constructed.
The detection is a timestamp comparison rather than a status filter: an invite
can read status "pending" while its expires_at is already in the past, and a
filter on status alone never returns that row.
Nothing secret is printed. The invite object carries no token, and the output
is ids, masked email addresses, roles and dates.
"""
import argparse
import logging
import os
import sys
import time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("openai_stale_invite_audit")
API = "https://api.openai.com/v1"
DAY = 86400
OWNER = "owner"
FINDINGS = ("expired-but-still-pending", "already-a-member", "pending-stale",
"expired-uncollected")
# Findings in the order a human should read them. An unclaimed grant of
# organization control outranks a tidy-up, whatever the timestamps say.
SEVERITY = {"expired-but-still-pending": 0, "pending-stale": 1,
"expired-uncollected": 2, "already-a-member": 3}
def sent_at(invite):
"""When the invite was sent, as unix seconds. Pure. None when unreadable.
The field goes by two names depending on where you read the reference, so
both are accepted rather than picking a side and reporting every invite as
having been sent at the epoch.
"""
row = invite or {}
for field in ("invited_at", "created_at", "sent_at"):
value = row.get(field)
if value:
try:
return int(value)
except (TypeError, ValueError):
continue
return None
def mask(email):
"""Hide the local part of an email address. Pure. Non-emails pass through."""
text = str(email or "").strip()
if "@" not in text:
return text or "unknown"
local, _, domain = text.partition("@")
if not local:
return text
return local[0] + "***@" + domain
def project_roles(invite):
"""[(project_id, role)] carried by one invite. Pure.
These take effect on acceptance, so they are part of what the invite is
offering and part of what deleting it withdraws.
"""
out = []
for entry in (invite or {}).get("projects") or []:
row = entry or {}
out.append((str(row.get("id") or "unknown"),
str(row.get("role") or "member").strip().lower()))
return out
def owner_grant(invite):
"""Does this invite hand over owner anywhere? Pure.
True for a top-level owner role and for a project entry at owner, because
an org reader with project owner is still an unclaimed grant of control.
"""
row = invite or {}
if str(row.get("role") or "").strip().lower() == OWNER:
return True
return any(role == OWNER for _, role in project_roles(row))
def member_emails(users):
"""Lowercased email addresses on the current roster. Pure."""
out = set()
for user in users or []:
email = str((user or {}).get("email") or "").strip().lower()
if email:
out.add(email)
return out
def classify(invite, members, now, stale_days=14):
"""Classify one invite. Pure. Returns (state, detail).
expires_at is tested against the clock BEFORE status is tested against a
string. A record can sit at "pending" past its own expiry, and that is the
row every status filter misses.
"""
row = invite or {}
status = str(row.get("status") or "").strip().lower()
email = str(row.get("email") or "").strip().lower()
sent = sent_at(row)
age = (int(now) - sent) // DAY if sent else None
if status == "accepted":
return ("accepted", "accepted%s"
% ("" if age is None else ", sent %d day(s) ago" % age))
if email and email in (members or set()):
return ("already-a-member",
"this address is already on the roster%s"
% ("" if age is None else ", invite sent %d day(s) ago" % age))
if status == "expired":
return ("expired-uncollected",
"expired and never cleaned up%s"
% ("" if age is None else ", sent %d day(s) ago" % age))
if status != "pending":
return ("unknown-status",
"status %r is not one this audit recognises" % status)
expires = row.get("expires_at")
try:
expires = int(expires) if expires else None
except (TypeError, ValueError):
expires = None
if expires and expires < int(now):
return ("expired-but-still-pending",
"still reads pending%s, and expires_at passed %d day(s) ago. A "
"filter on status alone never returns this row."
% ("" if age is None else " %d day(s) after it was sent" % age,
(int(now) - expires) // DAY))
if age is not None and age >= stale_days:
return ("pending-stale",
"pending for %d day(s) and not yet past its expires_at" % age)
return ("pending", "sent recently and still live")
def repair_lines(state, invite):
"""The repair for one classified invite. Pure. Printed, never performed."""
row = invite or {}
invite_id = str(row.get("id") or "unknown")
lines = []
if state not in FINDINGS:
return lines
if owner_grant(row):
lines.append("this invite still offers owner rights. Read it before you "
"re-send anything: an uncollected owner grant only needs "
"access to one mailbox.")
if state == "already-a-member":
lines.append("this person is already in the roster. Delete the record; "
"there is no onboarding problem here.")
elif state == "expired-but-still-pending":
lines.append("the record is dead and still listed as pending. Delete it, "
"then decide separately whether to re-send.")
elif state == "pending-stale":
lines.append("ask whether they ever received it. The API has no delivery "
"status and cannot tell a filtered message from an ignored "
"one.")
else:
lines.append("expired and never cleaned up. Delete unless this person is "
"still expected.")
grants = project_roles(row)
if grants and state != "already-a-member":
lines.append("re-send with the same projects[] entries (%s) or the new "
"invite grants less than the first one did."
% ", ".join("%s=%s" % g for g in grants))
lines.append("DELETE /v1/organization/invites/%s" % invite_id)
return lines
def get(session, path, **params):
r = session.get(API + path, params=params, timeout=60)
if r.status_code in (401, 403):
raise SystemExit("%d from OpenAI: /v1/organization/* needs an "
"organization admin key, not a project key"
% r.status_code)
r.raise_for_status()
return r.json()
def paged(session, path, **params):
"""Walk an after/last_id cursor listing."""
params = dict(params)
while True:
page = get(session, path, **params)
data = page.get("data") or []
for item in data:
yield item
if not page.get("has_more") or not data:
return
params["after"] = page.get("last_id") or (data[-1] or {}).get("id")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--stale-days", type=int, default=14,
help="days a pending invite may sit before it is flagged")
args = ap.parse_args()
admin = os.environ.get("OPENAI_ADMIN_KEY")
if not admin:
log.error("set OPENAI_ADMIN_KEY to an organization admin key; a "
"project key cannot read /v1/organization/*")
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + admin})
now = int(time.time())
members = member_emails(paged(s, "/organization/users", limit=100))
invites = list(paged(s, "/organization/invites", limit=100))
graded = [(invite, classify(invite, members, now, args.stale_days))
for invite in invites]
bad = [(invite, state, detail) for invite, (state, detail) in graded
if state in FINDINGS]
accepted = sum(1 for _, (state, _) in graded if state == "accepted")
log.info("%d invite(s), %d accepted, %d finding(s)",
len(invites), accepted, len(bad))
bad.sort(key=lambda row: (0 if owner_grant(row[0]) else 1,
SEVERITY.get(row[1], 9),
str(row[0].get("email") or "")))
for invite, state, detail in bad:
log.warning("%-26s %-22s role=%-7s %s", state,
mask(invite.get("email")),
str(invite.get("role") or "?"), detail)
grants = project_roles(invite)
if grants:
log.warning(" grants: %s",
", ".join("%s=%s" % g for g in grants))
for line in repair_lines(state, invite):
log.warning(" repair: %s", line)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find OpenAI organization invites that lapsed without anybody noticing.
*
* Read only. Two paged GETs against /v1/organization/invites and
* /v1/organization/users. No request body is constructed.
*
* The detection is a timestamp comparison rather than a status filter: an
* invite can read status "pending" while its expires_at is already in the
* past. Nothing secret is printed; the invite object carries no token.
*/
const API = 'https://api.openai.com/v1';
const DAY = 86400;
const OWNER = 'owner';
const FINDINGS = new Set(['expired-but-still-pending', 'already-a-member',
'pending-stale', 'expired-uncollected']);
const SEVERITY = { 'expired-but-still-pending': 0, 'pending-stale': 1,
'expired-uncollected': 2, 'already-a-member': 3 };
/** When the invite was sent, as unix seconds. Pure. Both field names accepted. */
export function sentAt(invite) {
for (const field of ['invited_at', 'created_at', 'sent_at']) {
const value = invite?.[field];
if (!value) continue;
const n = Number(value);
if (Number.isFinite(n)) return Math.trunc(n);
}
return null;
}
/** Hide the local part of an email address. Pure. Non-emails pass through. */
export function mask(email) {
const text = String(email ?? '').trim();
if (!text.includes('@')) return text || 'unknown';
const at = text.indexOf('@');
const local = text.slice(0, at);
if (!local) return text;
return `${local[0]}***${text.slice(at)}`;
}
/** [[project_id, role]] carried by one invite. Pure. */
export function projectRoles(invite) {
return (invite?.projects ?? []).map((entry) => [
String(entry?.id ?? 'unknown'),
String(entry?.role ?? 'member').trim().toLowerCase(),
]);
}
/** Does this invite hand over owner anywhere? Pure. Top level or per project. */
export function ownerGrant(invite) {
if (String(invite?.role ?? '').trim().toLowerCase() === OWNER) return true;
return projectRoles(invite).some(([, role]) => role === OWNER);
}
/** Lowercased email addresses on the current roster. Pure. */
export function memberEmails(users) {
const out = new Set();
for (const user of users ?? []) {
const email = String(user?.email ?? '').trim().toLowerCase();
if (email) out.add(email);
}
return out;
}
/** Classify one invite. Pure. expires_at is tested before status. */
export function classify(invite, members, now, staleDays = 14) {
const row = invite ?? {};
const status = String(row.status ?? '').trim().toLowerCase();
const email = String(row.email ?? '').trim().toLowerCase();
const sent = sentAt(row);
const age = sent === null ? null : Math.floor((Number(now) - sent) / DAY);
if (status === 'accepted') {
return ['accepted', age === null ? 'accepted' : `accepted, sent ${age} day(s) ago`];
}
if (email && (members ?? new Set()).has(email)) {
return ['already-a-member',
'this address is already on the roster'
+ (age === null ? '' : `, invite sent ${age} day(s) ago`)];
}
if (status === 'expired') {
return ['expired-uncollected',
'expired and never cleaned up'
+ (age === null ? '' : `, sent ${age} day(s) ago`)];
}
if (status !== 'pending') {
return ['unknown-status', `status "${status}" is not one this audit recognises`];
}
const expiresRaw = Number(row.expires_at ?? 0);
const expires = Number.isFinite(expiresRaw) && expiresRaw > 0 ? expiresRaw : null;
if (expires && expires < Number(now)) {
return ['expired-but-still-pending',
'still reads pending'
+ (age === null ? '' : ` ${age} day(s) after it was sent`)
+ `, and expires_at passed ${Math.floor((Number(now) - expires) / DAY)} `
+ 'day(s) ago. A filter on status alone never returns this row.'];
}
if (age !== null && age >= staleDays) {
return ['pending-stale',
`pending for ${age} day(s) and not yet past its expires_at`];
}
return ['pending', 'sent recently and still live'];
}
/** The repair for one classified invite. Pure. Printed, never performed. */
export function repairLines(state, invite) {
const row = invite ?? {};
const lines = [];
if (!FINDINGS.has(state)) return lines;
if (ownerGrant(row)) {
lines.push('this invite still offers owner rights. Read it before you re-send '
+ 'anything: an uncollected owner grant only needs access to one mailbox.');
}
if (state === 'already-a-member') {
lines.push('this person is already in the roster. Delete the record; there is '
+ 'no onboarding problem here.');
} else if (state === 'expired-but-still-pending') {
lines.push('the record is dead and still listed as pending. Delete it, then '
+ 'decide separately whether to re-send.');
} else if (state === 'pending-stale') {
lines.push('ask whether they ever received it. The API has no delivery status '
+ 'and cannot tell a filtered message from an ignored one.');
} else {
lines.push('expired and never cleaned up. Delete unless this person is still '
+ 'expected.');
}
const grants = projectRoles(row);
if (grants.length && state !== 'already-a-member') {
lines.push(`re-send with the same projects[] entries (${
grants.map(([id, role]) => `${id}=${role}`).join(', ')}) or the new invite `
+ 'grants less than the first one did.');
}
lines.push(`DELETE /v1/organization/invites/${String(row.id ?? 'unknown')}`);
return lines;
}
async function read(key, path, params) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (r.status === 401 || r.status === 403) {
throw new Error(`${r.status} from OpenAI: /v1/organization/* needs an `
+ 'organization admin key, not a project key');
}
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
return r.json();
}
async function paged(key, path, params) {
const out = [];
const q = { ...params };
for (;;) {
const page = await read(key, path, q);
const data = page.data ?? [];
out.push(...data);
if (!page.has_more || data.length === 0) return out;
q.after = page.last_id ?? data[data.length - 1]?.id;
}
}
async function main() {
const admin = process.env.OPENAI_ADMIN_KEY;
if (!admin) {
console.error('set OPENAI_ADMIN_KEY to an organization admin key; a project '
+ 'key cannot read /v1/organization/*');
process.exitCode = 2;
return;
}
const staleDays = Number(process.env.STALE_DAYS ?? 14);
const now = Math.floor(Date.now() / 1000);
const members = memberEmails(await paged(admin, '/organization/users', { limit: 100 }));
const invites = await paged(admin, '/organization/invites', { limit: 100 });
const graded = invites.map((invite) => [invite, classify(invite, members, now, staleDays)]);
const bad = graded.filter(([, [state]]) => FINDINGS.has(state));
const accepted = graded.filter(([, [state]]) => state === 'accepted').length;
console.log(`${invites.length} invite(s), ${accepted} accepted, ${bad.length} finding(s)`);
bad.sort(([a, [sa]], [b, [sb]]) =>
(ownerGrant(a) ? 0 : 1) - (ownerGrant(b) ? 0 : 1)
|| (SEVERITY[sa] ?? 9) - (SEVERITY[sb] ?? 9)
|| String(a.email ?? '').localeCompare(String(b.email ?? '')));
for (const [invite, [state, detail]] of bad) {
console.warn(`${state.padEnd(26)} ${mask(invite.email).padEnd(22)} `
+ `role=${String(invite.role ?? '?').padEnd(7)} ${detail}`);
const grants = projectRoles(invite);
if (grants.length) {
console.warn(` grants: ${grants.map(([id, role]) => `${id}=${role}`).join(', ')}`);
}
for (const line of repairLines(state, invite)) console.warn(` repair: ${line}`);
}
process.exitCode = bad.length ? 1 : 0;
}
if (import.meta.url === `file://${process.argv[1]}`) await main();
Add a test
The headline test is one invite read twice. The same record, the same dates, once at status: pending and once at status: expired, has to come back as two different states with two different repairs — and the first of those is the row an audit written as a status filter never returns. Around it: an invite for somebody who is already on the roster, which is a stale record rather than a failed onboarding; the owner grant that hides in projects[] under a top-level reader; the sent timestamp read under either field name; the project entries that a re-send has to carry or it grants less than the original; and a fresh invite, which is not a finding at all.
from openai_stale_invite_audit import (classify, mask, member_emails,
owner_grant, project_roles,
repair_lines, sent_at)
NOW = 1_780_000_000
DAY = 86400
ROSTER = member_emails([{"email": "Mel@example.com"}, {"email": "pat@example.com"}])
def invite(iid, email, role="reader", status="pending", sent_days=137,
expires_days=107, projects=None):
return {"id": iid, "email": email, "role": role, "status": status,
"invited_at": NOW - sent_days * DAY,
"expires_at": NOW - expires_days * DAY if expires_days else None,
"projects": projects or []}
def test_a_pending_invite_past_its_expiry_is_the_row_a_status_filter_misses():
# The note in one assertion, and then the same record relabelled. Same
# dates, same grants, two states, two repairs.
row = invite("invite_01hd", "rob@example.com", role="owner")
state, detail = classify(row, ROSTER, NOW)
assert state == "expired-but-still-pending"
assert "filter on status alone" in detail
assert "107 day(s) ago" in detail
relabelled = dict(row, status="expired")
other, detail = classify(relabelled, ROSTER, NOW)
assert other == "expired-uncollected"
assert "never cleaned up" in detail
assert repair_lines(state, row) != repair_lines(other, relabelled)
def test_an_invite_for_somebody_already_on_the_roster_is_not_an_onboarding_failure():
row = invite("invite_01me", "mel@EXAMPLE.com", sent_days=61, expires_days=31)
state, detail = classify(row, ROSTER, NOW)
assert state == "already-a-member"
assert "already on the roster" in detail
lines = repair_lines(state, row)
assert any("no onboarding problem here" in line for line in lines)
assert not any("re-send" in line for line in lines)
def test_an_owner_grant_hides_inside_the_project_entries():
plain = invite("invite_01a", "jo@example.com", role="reader",
projects=[{"id": "proj_web", "role": "member"}])
hidden = invite("invite_01b", "kim@example.com", role="reader",
projects=[{"id": "proj_ingest", "role": "owner"},
{"id": "proj_web", "role": "member"}])
assert owner_grant(plain) is False
assert owner_grant(hidden) is True
assert owner_grant(invite("invite_01c", "x@y.com", role="owner")) is True
assert project_roles(hidden) == [("proj_ingest", "owner"),
("proj_web", "member")]
assert project_roles({}) == []
lines = repair_lines("expired-but-still-pending", hidden)
assert any("offers owner rights" in line for line in lines)
assert any("proj_ingest=owner" in line for line in lines)
def test_a_stale_but_live_invite_is_its_own_state():
row = invite("invite_01j", "jay@example.com", sent_days=29,
expires_days=-1)
row["expires_at"] = NOW + 3 * DAY
state, detail = classify(row, ROSTER, NOW)
assert state == "pending-stale"
assert "29 day(s)" in detail
assert any("delivery status" in line for line in repair_lines(state, row))
def test_a_fresh_invite_and_an_accepted_one_are_not_findings():
fresh = invite("invite_01f", "new@example.com", sent_days=2)
fresh["expires_at"] = NOW + 5 * DAY
assert classify(fresh, ROSTER, NOW)[0] == "pending"
assert repair_lines("pending", fresh) == []
done = invite("invite_01g", "old@example.com", status="accepted")
assert classify(done, ROSTER, NOW)[0] == "accepted"
assert classify({"status": "revoked", "email": "z@x.com"},
ROSTER, NOW)[0] == "unknown-status"
def test_the_sent_timestamp_is_read_under_either_field_name():
assert sent_at({"invited_at": 1_700_000_000}) == 1_700_000_000
assert sent_at({"created_at": 1_700_000_001}) == 1_700_000_001
assert sent_at({"invited_at": None, "created_at": 1_700_000_002}) == 1_700_000_002
assert sent_at({"invited_at": "not a date"}) is None
assert sent_at({}) is None
assert sent_at(None) is None
# A missing timestamp must not make the invite look brand new.
row = {"id": "i", "email": "q@x.com", "role": "reader", "status": "pending",
"expires_at": NOW - DAY}
assert classify(row, ROSTER, NOW)[0] == "expired-but-still-pending"
def test_every_repair_ends_with_the_delete_and_masks_the_address():
row = invite("invite_01hd", "rob@example.com", role="owner")
assert repair_lines("expired-but-still-pending", row)[-1] == \
"DELETE /v1/organization/invites/invite_01hd"
assert mask("rob@example.com") == "r***@example.com"
assert mask(None) == "unknown"
assert member_emails(None) == set()
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify, mask, memberEmails, ownerGrant, projectRoles, repairLines,
sentAt } from './openai-stale-invite-audit.mjs';
const NOW = 1780000000;
const DAY = 86400;
const ROSTER = memberEmails([{ email: 'Mel@example.com' }, { email: 'pat@example.com' }]);
const invite = (id, email, { role = 'reader', status = 'pending', sentDays = 137,
expiresDays = 107, projects = [] } = {}) =>
({ id, email, role, status, invited_at: NOW - sentDays * DAY,
expires_at: expiresDays === null ? null : NOW - expiresDays * DAY, projects });
test('a pending invite past its expiry is the row a status filter misses', () => {
const row = invite('invite_01hd', 'rob@example.com', { role: 'owner' });
const [state, detail] = classify(row, ROSTER, NOW);
assert.equal(state, 'expired-but-still-pending');
assert.match(detail, /filter on status alone/);
assert.match(detail, /107 day\(s\) ago/);
const relabelled = { ...row, status: 'expired' };
const [other, otherDetail] = classify(relabelled, ROSTER, NOW);
assert.equal(other, 'expired-uncollected');
assert.match(otherDetail, /never cleaned up/);
assert.notDeepEqual(repairLines(state, row), repairLines(other, relabelled));
});
test('an invite for somebody already on the roster is not an onboarding failure', () => {
const row = invite('invite_01me', 'mel@EXAMPLE.com', { sentDays: 61, expiresDays: 31 });
const [state, detail] = classify(row, ROSTER, NOW);
assert.equal(state, 'already-a-member');
assert.match(detail, /already on the roster/);
const lines = repairLines(state, row);
assert.ok(lines.some((l) => l.includes('no onboarding problem here')));
assert.ok(!lines.some((l) => l.includes('re-send')));
});
test('an owner grant hides inside the project entries', () => {
const plain = invite('invite_01a', 'jo@example.com',
{ projects: [{ id: 'proj_web', role: 'member' }] });
const hidden = invite('invite_01b', 'kim@example.com',
{ projects: [{ id: 'proj_ingest', role: 'owner' },
{ id: 'proj_web', role: 'member' }] });
assert.equal(ownerGrant(plain), false);
assert.equal(ownerGrant(hidden), true);
assert.equal(ownerGrant(invite('invite_01c', 'x@y.com', { role: 'owner' })), true);
assert.deepEqual(projectRoles(hidden),
[['proj_ingest', 'owner'], ['proj_web', 'member']]);
assert.deepEqual(projectRoles({}), []);
const lines = repairLines('expired-but-still-pending', hidden);
assert.ok(lines.some((l) => l.includes('offers owner rights')));
assert.ok(lines.some((l) => l.includes('proj_ingest=owner')));
});
test('a stale but live invite is its own state', () => {
const row = invite('invite_01j', 'jay@example.com', { sentDays: 29 });
row.expires_at = NOW + 3 * DAY;
const [state, detail] = classify(row, ROSTER, NOW);
assert.equal(state, 'pending-stale');
assert.match(detail, /29 day\(s\)/);
assert.ok(repairLines(state, row).some((l) => l.includes('delivery status')));
});
test('a fresh invite and an accepted one are not findings', () => {
const fresh = invite('invite_01f', 'new@example.com', { sentDays: 2 });
fresh.expires_at = NOW + 5 * DAY;
assert.equal(classify(fresh, ROSTER, NOW)[0], 'pending');
assert.deepEqual(repairLines('pending', fresh), []);
const done = invite('invite_01g', 'old@example.com', { status: 'accepted' });
assert.equal(classify(done, ROSTER, NOW)[0], 'accepted');
assert.equal(classify({ status: 'revoked', email: 'z@x.com' }, ROSTER, NOW)[0],
'unknown-status');
});
test('the sent timestamp is read under either field name', () => {
assert.equal(sentAt({ invited_at: 1700000000 }), 1700000000);
assert.equal(sentAt({ created_at: 1700000001 }), 1700000001);
assert.equal(sentAt({ invited_at: null, created_at: 1700000002 }), 1700000002);
assert.equal(sentAt({ invited_at: 'not a date' }), null);
assert.equal(sentAt({}), null);
assert.equal(sentAt(null), null);
const row = { id: 'i', email: 'q@x.com', role: 'reader', status: 'pending',
expires_at: NOW - DAY };
assert.equal(classify(row, ROSTER, NOW)[0], 'expired-but-still-pending');
});
test('every repair ends with the delete and masks the address', () => {
const row = invite('invite_01hd', 'rob@example.com', { role: 'owner' });
const lines = repairLines('expired-but-still-pending', row);
assert.equal(lines[lines.length - 1],
'DELETE /v1/organization/invites/invite_01hd');
assert.equal(mask('rob@example.com'), 'r***@example.com');
assert.equal(mask(null), 'unknown');
assert.equal(memberEmails(null).size, 0);
});
FAQ
Why not just filter on status == expired and be done?
Because the rows that matter most are not labelled that way. An invite can sit at status pending with an expires_at that has already gone by, so a status filter returns a tidy list and silently omits exactly the records that are neither live nor cleaned up. The script compares expires_at against the clock first and only then looks at the status string, which is why it reports lapsed-but-pending and already-relabelled-expired as two separate states. They also need two different sentences in the email you send afterwards.
What is the actual harm? The person just asks again.
They usually do not. They borrow a credential, because the work is due and the access is not. A colleague's key goes into an environment file, or a service account key goes into a chat message, and from then on requests are authenticating as somebody who cannot account for them. That is the operational cost, and it is quiet. The separate, sharper cost is an outstanding invite at owner for a candidate who was never hired or an employee who has left, which is a standing grant of organization control that only needs access to one mailbox.
Does this apply to Anthropic as well?
Partly, which is why the slug says OpenAI. Anthropic's Admin API does list invites with a status, an invited_at and an expires_at, and the same timestamp-against-status test is worth running there. What has no equivalent is the rest of this note: an OpenAI invite carries a projects[] array of per-project role grants that take effect on acceptance, so it offers more than membership, and OpenAI's audit log reconstructs the history through invite.sent, invite.accepted and invite.deleted. Neither of those is reachable from a read-only Anthropic Admin key, so the script is written against the API that has them.
Can the script tell me whether the invitation email was delivered?
No, and nothing read-only can. There is no delivery status on the invite object and no way to distinguish a message that was filtered from one that was read and ignored. The script reports what is knowable — when it was sent, when it expires or expired, what role and project grants it carries, and whether the invitee is already in the organization by some other route — and never speculates about why it went uncollected. Asking the person is still the fastest step, and the output is written to be pasted into that message.
Is it safe to just delete the whole backlog?
Deleting is safe; re-sending carelessly is not. Deleting a pending invite withdraws an offer that was never accepted, which is what you want for departed people, wrong addresses and abandoned hires. The thing to read before you delete is the projects[] array, because those per-project role grants go with it: a re-issued invite that omits them gives the person less than the original did, and they will be back in a week asking why they cannot see a project. The script prints the grants next to every row for that reason.
Related field notes
- Who already holds the role that invite is offering
- A credential that outlives the access it was minted under
- Whether there is more than one container to grant anybody a role in
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Invites — OpenAI API reference
- Audit logs — OpenAI API reference
- Admin APIs — OpenAI platform docs
- openai-python admin API surface
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.