grantmaking.ai
DatabaseActivity
Resources
grantmaking.ai
DatabaseActivity
Resources
Apply to our funding roundApply
ResourcesView raw Markdown

Grantmaking.ai API — Guide for AI Agents

This document explains how to use the Grantmaking.ai User API. It is written for AI agents and other programmatic clients. Everything here is current as of 2026-06.

Grantmaking.ai is a shared platform to discover, evaluate, and fund high-impact AI safety work. The API gives a token holder read access to the public directory of organizations and projects, write access to the entities they are authorized to edit, and the ability to read/post comments on those entities.

TL;DR quickstart

BASE URL:  https://app.grantmaking.ai/api/v1
AUTH:      Authorization: Bearer xg_...        (required for writes, /me, and comment contents)
ANON:      Read-only GETs work WITHOUT a key (lower rate limit, private data hidden)
FIRST CALL (with key): GET /me                 (tells you what your key can do)
RATE LIMIT: 100 req/60s per token; ~30 req/60s per IP for keyless requests
FORMAT:    JSON in, JSON out. Success → { "data": ... }, error → { "error": "message" }
# No key needed for public reads:
curl "https://app.grantmaking.ai/api/v1/organizations?limit=5"

# With a key:
curl "https://app.grantmaking.ai/api/v1/me" -H "Authorization: Bearer $API_KEY"

Anonymous (keyless) access

These endpoints work without any Authorization header:

  • GET /organizations and GET /organizations/{id}
  • GET /projects and GET /projects/{id}
  • GET /organizations/{id}/comments and GET /projects/{id}/comments — returns only { "data": { "commentCount": N }, "meta": { "message": "…" } } (the number of public comments), never the comment contents.

Anonymous rules:

  • You are treated as a non-admin viewer: entities with private funding return null for their funding fields, exactly as for non-admin tokens.
  • Rate limit is per IP, roughly 30 requests per 60 seconds (enforced at the edge; approximate). 429 responses carry a Retry-After header.
  • Responses are CDN-cached (Cache-Control: public, s-maxage=60, stale-while-revalidate=240), so anonymous data can be up to ~5 minutes stale.
  • Everything else — GET /me, all PATCH/POST/DELETE, and comment contents — requires a bearer token and returns 401 without one. Sending an Authorization header with an invalid token is a 401, not a fallback to anonymous. (The one keyless write is POST /feedback — see Feedback & bug reports below.)

If you need comment contents, write access, or the higher rate limit: sign in at https://app.grantmaking.ai and create an API key yourself on the Settings page — it's self-serve.

Critical gotchas (read these first)

  1. Always use the app.grantmaking.ai host. That's where the API lives; the marketing site at grantmaking.ai is a separate host. If you send a request to a host that redirects to the canonical one (e.g. an http:// URL or another alias), HTTP clients strip the Authorization header when following a cross-host redirect. For token-only endpoints that means a confusing 401; for anonymous-enabled GETs it's worse — the request silently succeeds as anonymous, so an admin token can get a response with private fields nulled and never see an error. Send requests directly to https://app.grantmaking.ai.
  2. Start with GET /me. It tells you whether your token is admin-scoped and, if not, exactly which organizations and projects you can edit — including ready-made apiPath / commentsApiPath values you can call directly. Do not guess at permissions; discover them.
  3. PATCH bodies reject unknown fields. Sending any field not in the allowlists below returns HTTP 400 Unsupported field: .... Send only supported fields.
  4. List responses truncate long text. GET /organizations and GET /projects clip long free-text fields to 2,000 characters (marked with a trailing … and a row-level "truncated": true). Fetch the single-entity endpoint for full text.
  5. Respect HTTP 429. On rate limit you get a Retry-After header in seconds. Wait that long before retrying; do not retry in a tight loop.

Authentication

Read-only GETs on organizations and projects work without a key (see Anonymous access above). For everything else, pass a profile API key (format xg_…) as a bearer token:

Authorization: Bearer xg_...
Content-Type: application/json

Tokens are issued with a fixed scope (admin or non-admin) at creation time and do not expire unless an expiry was set at issuance.

Getting a key is self-serve. Any signed-in user can create one at https://app.grantmaking.ai/settings (API Keys panel): name it, optionally set an expiry, and copy the xg_… value — it is shown once at creation and only a hash is stored. The key's scope matches the account: admin accounts get admin-scoped keys, everyone else gets a non-admin key whose editable set is derived from their email domain (see GET /me). If you are an AI agent without an account, ask the human you work for to create a key from their Settings page.

All auth failures return HTTP 401 with { "error": "..." }:

SituationMessage
No Authorization headerMissing Authorization header
Header isn't Bearer <token>Invalid Authorization header format. Expected: Bearer <token>
Token not foundInvalid API token
Token expiredAPI token has expired
Token revokedAPI token has been revoked

Permission model

  • Admin-scoped tokens can read and edit any organization or project.
  • Non-admin tokens can edit an organization (and its projects) when the email domain on the token's profile matches the organization's website domain. Everything else is read-only.
  • All valid tokens can read the public directory (GET /organizations, GET /projects) and edit their own profile (PATCH /me/profile).

Discovery: GET /me

Always call this first. The response shape:

{
  "data": {
    "profile": { "id": "…", "email": "…", "name": "…" /* … */ },
    "token": { "id": "…", "isAdmin": false },
    "access": {
      "filtered": true, // false → admin: can edit everything
      "canEditAnyOrganization": false,
      "canEditAnyProject": false,
      "editableOrganizations": [
        // null when filtered: false
        {
          "id": "…",
          "name": "…",
          "descriptionShort": "…",
          "websiteUrl": "…",
          "apiPath": "/api/v1/organizations/<id>",
          "commentsApiPath": "/api/v1/organizations/<id>/comments",
        },
      ],
      "editableProjects": [
        /* same shape, plus orgId + organization */
      ],
    },
    "capabilities": {
      /* per-area flags, e.g. commentsRequireEditableEntity */
    },
  },
}
  • access.filtered: false → the key is admin-scoped; editableOrganizations and editableProjects are null because admins can edit everything.
  • access.filtered: true → only the listed entities are writable. Use the provided apiPath / commentsApiPath values directly.

Endpoint reference

All paths are relative to https://app.grantmaking.ai/api/v1.

Method & pathPurposeAccess
GET /meWho am I + what can I editAny valid token
PATCH /me/profileEdit your own profile (and linked public person)Any valid token
GET /organizationsPaginated list of organizationsAnonymous or any valid token
GET /organizations/{id}Read one organization (full text)Anonymous or any valid token
PATCH /organizations/{id}Edit an organizationAdmin, or domain match
GET /organizations/{id}/commentsList comments (anon: count only)Anonymous (count) / admin, domain match
POST /organizations/{id}/commentsPost a comment / replyAdmin, or domain match
DELETE /organizations/{id}/comments/{cid}Delete a comment (soft delete)Admin, or domain-match + own
GET /projectsPaginated list of projectsAnonymous or any valid token
GET /projects/{id}Read one project (full text)Anonymous or any valid token
PATCH /projects/{id}Edit a projectAdmin, or parent-org domain match
GET /projects/{id}/commentsList comments (anon: count only)Anonymous (count) / admin, domain match
POST /projects/{id}/commentsPost a comment / replyAdmin, or parent-org domain match
DELETE /projects/{id}/comments/{cid}Delete a comment (soft delete)Admin, or domain-match + own
POST /feedbackSend feedback or a bug reportPublic (no key needed)

Comments support create / list / delete only (no edit), and exist only on organizations and projects (not people or funds).

Feedback & bug reports

Found a bug, or have feedback about the API or the platform? POST /feedback is the one write that works without a key — built so an AI agent (or a person) can fire off a quick note or bug ticket with zero setup. We read these.

Request body (JSON):

FieldRequiredNotes
messageyesThe feedback or bug report. Max 8,000 characters.
emailnoA contact address, if you'd like a reply.
sourcenoWhere this came from, e.g. a URL or "API". Max 500 characters.
  • No Authorization header required. Sending a valid token simply attributes the feedback to your account; it is never required.
  • Rate limited. A few submissions per minute per IP. Separately, the number of notification emails we send is globally capped per hour — so during a flood your submission is still stored even when it doesn't trigger an email. 429 responses carry a Retry-After header (seconds).
  • Unknown fields are rejected with 400 Unsupported field: ....
curl -X POST "https://app.grantmaking.ai/api/v1/feedback" \
  -H "Content-Type: application/json" \
  -d '{"message":"GET /projects 500s when limit=0","email":"you@example.com","source":"API"}'

Success → 201 { "data": { "id": "<uuid>" } }.

Response and error conventions

Success responses wrap the payload in data:

// Lists (truncated text fields):
{ "data": [ /* rows, each with "truncated": bool */ ], "meta": { "total": 142, "limit": 50, "offset": 0 } }

// Single entity (full text, no "truncated" flag):
{ "data": { /* object */ } }

// PATCH:
{ "data": { "id": "…", "updated": true } }

// POST .../comments → HTTP 201:
{ "data": { "id": "…", "content": "…", "visibility": "public", /* … */ } }

// DELETE .../comments/{cid}:
{ "data": { "id": "…", "deleted": true, "mode": "soft-deleted" } }

Errors are always { "error": "message" } with an appropriate status:

StatusMeaning
400Malformed JSON, invalid UUID, unknown field, or failed validation
401Auth failure (see table above)
403Valid token, but not authorized for this entity or field
404Entity/comment not found, hidden, or not visible to you
409Profile patch needs a linked public person that doesn't exist
429Rate limited — honor the Retry-After header (seconds)
500Server error — safe to retry once after a short delay

Token-authenticated responses carry Cache-Control: private, no-store; do not cache them. Anonymous 200s carry Cache-Control: public, s-maxage=60, stale-while-revalidate=240 (plus Vary: Authorization) and may be served from the CDN, up to ~5 minutes stale.

Rate limiting

  • With a token: 100 requests per rolling 60 seconds, per token.
  • Anonymous (no token): roughly 30 requests per 60 seconds, per IP, enforced at the edge.

Exceeding either returns HTTP 429 { "error": "Too many requests" } with a Retry-After header in whole seconds.

Agent guidance:

  • Use pagination (limit=100) instead of many small requests.
  • On 429, sleep for Retry-After seconds, then resume.
  • For bulk reads of the full directory, ~6 requests fetch all organizations and ~13 fetch all projects at limit=100 — comfortably inside the limit, even anonymously.
  • The anonymous budget is shared across all your keyless requests. CDN-cache hits (repeat list/entity GETs) may be served at the edge without consuming it, but non-cached endpoints like .../comments always count — a mixed workload can hit 429 sooner than the headline number suggests.

Reading the directory

Both list endpoints share a common set of search, filter, and sort params (below). limit/offset are clamped; every other listed param returns 400 on an invalid value (unknown enum, malformed date/UUID, unsupported sort column). Unknown query keys (e.g. utm_source) are ignored. meta.total reflects the filtered count. Filters compose with AND.

Shared params (organizations + projects):

ParamTypeBehavior
limit1–100 (default 50)Clamped, not rejected (limit=0 → 1, limit=500 → 100).
offset≥ 0 (default 0)Clamped.
qstring (≤ 100 chars)Case-insensitive substring match on name only (%/_/\ are matched literally).
locationstring (≤ 100 chars)Case-insensitive substring match on the location field.
descriptionContainsstring (≤ 100 chars)Case-insensitive substring match across the description fields (OR). Orgs: descriptionShort/descriptionMedium/descriptionFull; projects add theoryOfChange. Blank → ignored.
tagsslug (multi, ≤ 25)Filter to entities carrying the given tag slugs (see GET /tags). Combine with tagMatch. Present-but-empty (tags=) → 400. Unknown slugs are not an error (see tagMatch).
tagMatchany | allDefault any. any = entity has at least one of the slugs; all = entity has every slug. Other value → 400.
isActivelyFundraisingtrue/false/1/0Exact match.
updatedAfterISO datetime or YYYY-MM-DDupdatedAt >= value. The main incremental-sync filter.
updatedBeforeISO datetime or YYYY-MM-DDupdatedAt < value; a date-only value means the next UTC midnight (end-exclusive).
sortname | createdAt | updatedAtDefault name. Any other value → 400. id is always appended as a stable tiebreaker.
orderasc | descDefault asc.

Multi-value params (orgType, status, orgIds, tags) accept either repeated keys (?status=active&status=paused) or comma-separated values (?status=active,paused).

Unknown tag slugs (not in the vocabulary returned by GET /tags) are never a 400 — they simply narrow results: ignored under tagMatch=any, and they make tagMatch=all yield an empty set (an entity cannot have a slug that does not exist). Example: ?tags=mech-interp,governance&tagMatch=all returns only entities tagged with both mech-interp and governance.

No funding filters or sort. There is intentionally no way to filter or sort on annualBudget, fundingGoal, fundingRaisedToDate, or any other funding amount/JSON field. Result inclusion/order on a redacted private amount would leak it. Private-funding rows still appear in results when they match a public filter — their funding fields just come back null.

GET /organizations

Returns non-hidden organizations. Supports all shared params above, plus:

ParamTypeBehavior
orgTypeenum (multi)IN (...) over org_type (nonprofit, research_org, academic, …). Unknown value → 400.

Responses also include read-only fields not listed in the PATCH tables — e.g. logoUrl, createdAt, updatedAt (and squareLogoUrl on projects). Treat any field you don't recognize as read-only.

Long text fields (descriptionShort, descriptionMedium, descriptionFull, theoryOfChange) are truncated to 2,000 characters in list responses; rows with clipped fields carry "truncated": true. GET /organizations/{id} returns full text (404 if not found or hidden).

Funding privacy: when an org has isFundingPrivate: true, the fields annualBudget, monthlyBurnRate, currentRunwayMonths, fundingGoal, and fundingRaisedToDate come back as null to non-admin tokens. A null there does not mean the data doesn't exist — do not "fix" it by writing values.

GET /projects

Same pagination, truncation, and shared filter/sort params as organizations, plus:

ParamTypeBehavior
orgIdUUIDFilter to one organization's projects. Malformed → 400 Invalid orgId format.
orgIdsUUID (multi)IN (...) over org_id; up to 100 ids. Mutually exclusive with orgId — sending both → 400.
statusenum (multi)IN (...) over status (active, completed, paused, cancelled, upcoming). Unknown → 400.

Project rows carry orgId only — there is no embedded organization object in GET /projects or GET /projects/{id}. The embedded organization (with name, apiPath, etc.) appears only inside /me's editableProjects. To resolve an org, fetch GET /organizations/{orgId}.

Funding privacy: when a project has isFundingPrivate: true, non-admin tokens see fundingGoals, fundingAmountRequested, fundingRaisedToDate, annualBudget, monthlyBurnRate, and currentRunwayMonths as null.

GET /tags

Returns the full controlled tag vocabulary with usage counts per entity type, so you know which tags slugs to pass to GET /organizations?tags=… or GET /projects?tags=…. Anonymous-readable and CDN-cacheable. Ordered by label ascending, then slug.

ParamTypeBehavior
entityTypeorganization | project | person | fundOptional. Returns only tags used by that entity type (count > 0). Invalid value → 400.
curl "https://app.grantmaking.ai/api/v1/tags"
{
  "data": [
    {
      "slug": "mech-interp",
      "label": "Mechanistic Interpretability",
      "description": "…",
      "counts": { "organizations": 12, "projects": 30, "persons": 5, "funds": 1, "total": 48 }
    }
  ],
  "meta": { "total": 1 }
}

Editing entities

curl -X PATCH "https://app.grantmaking.ai/api/v1/organizations/$ORG_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"descriptionShort":"Updated short description"}'

Rules that apply to all PATCH endpoints:

  • Only allowlisted fields may be sent; any other key → 400 (Unsupported field: …).
  • A body with zero recognized fields → 400 (At least one supported field is required).
  • name, when present, must be a non-empty string.
  • Setting a nullable field to null clears it. Omitting a field leaves it unchanged. Send only the fields you intend to change.

Organization fields (PATCH /organizations/{id})

FieldType / rules
namestring, required if present
descriptionShort, descriptionMedium, descriptionFull, theoryOfChangestring or null
websiteUrl, linkedinUrlvalid http/https URL or null
location, fundingStage, fiscalSponsor, trackRecordstring or null
orgTypeknown org type string, or null to clear
foundedDateYYYY-MM-DD or null
teamSize, currentRunwayMonthsinteger ≥ 0 or null
annualBudget, monthlyBurnRate, fundingGoal, fundingRaisedToDatedecimal ≥ 0 or null
isActivelyFundraising, isFundingPrivateboolean
donationLinksarray of { platform, url } (url must be valid http/https), or null

Non-admin keys cannot set websiteUrl → HTTP 403. The website domain determines who can edit the org, so changing it would change permissions.

Project fields (PATCH /projects/{id})

FieldType / rules
namestring, required if present
descriptionShort, descriptionMedium, descriptionFull, theoryOfChange, expectedDurationstring or null
websiteUrl, linkedinUrlvalid http/https URL or null
location, fundingStage, fiscalSponsor, trackRecordstring or null
statusone of active, completed, paused, cancelled, upcoming
startDate, endDateYYYY-MM-DD or null (endDate cannot precede startDate)
teamSize, currentRunwayMonthsinteger ≥ 0 or null
annualBudget, monthlyBurnRate, fundingRaisedToDatedecimal ≥ 0 or null
isActivelyFundraising, isFundingPrivateboolean
fundingGoalsobject { minimum?, goal?, stretch? }, each a number ≥ 0, or null
orgIdUUID or null
donationLinksarray of { platform, url }, or null

Non-admin keys cannot set orgId → HTTP 403. Edit rights on a project derive from its parent organization, so reassigning it is admin-only.

Profile fields (PATCH /me/profile)

Writes to two records: your account profile and, where applicable, your linked public person.

FieldWritten toNotes
nameprofile + personrequired if present
bioprofile + person
displayNameprofileauto-filled from name if you set name without displayName
titleAndOrgprofilemax 120 characters
personalWebsiteUrl, linkedinUrl, twitterUrlpersonvalid http/https URL or null
lesswrongHandle, eaForumHandle, locationpersonstring or null

Person-only fields (everything except name / bio / displayName / titleAndOrg) require a linked public profile; without one they return HTTP 409 (No linked public profile found).

Comments

Anonymous callers may GET .../comments but receive only the number of public comments:

{
  "data": { "commentCount": 4 },
  "meta": { "message": "Comment contents are not available without an API key. …" },
}

Reading comment contents and posting comments requires being an authorized editor of the entity (admin token, or matching email domain). Other valid tokens get 403.

What you see in GET .../comments depends on the token:

  • Admin / reviewer / funder tokens see all public + all restricted comments.
  • Other authorized editors see all public comments plus only the restricted comments they themselves authored.

Posting

curl -X POST "https://app.grantmaking.ai/api/v1/organizations/$ORG_ID/comments" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content":"Comment text","parentCommentId":null,"visibility":"public"}'
  • content — required, max 10,000 characters.
  • parentCommentId — a parent comment's UUID to reply; null/omit for a top-level comment.
  • visibility — "public" (default) or "restricted" (private note visible per the rules above).
  • Replies inherit restriction: replying to a restricted comment forces the reply to restricted regardless of what you send. The response echoes the stored visibility — check it.

Deleting

DELETE .../comments/{commentId}. Non-admins may delete only their own comments. Any comment you can't act on (missing, already deleted, different entity, not yours) returns 404 Comment not found — the endpoint never reveals comments you can't act on.

Deletes are always soft: the row remains as a tombstone (content: "[deleted]", author masked, deletedAt set) so threads don't break.

Recommended agent workflow

  1. No API key? You can still do step 2 (read the directory) anonymously at the lower rate limit. Skip /me, writes, and comment contents.
  2. GET /me — learn your scope and editable entities.
  3. To survey the ecosystem: page through GET /organizations and GET /projects with limit=100, then fetch single entities for any rows marked truncated: true that you need in full.
  4. To update data: confirm the entity is in your editable set (or that you're admin), PATCH only the changed fields, and verify the { "updated": true } response.
  5. To leave notes for humans: POST a comment; use "restricted" for internal/reviewer-only notes and "public" otherwise.
  6. Back off on any 429 using Retry-After; treat 5xx as retryable once.

Windows / PowerShell note

On Windows PowerShell, curl aliases Invoke-WebRequest, which doesn't accept -X/-H/-d and mangles UTF-8 bodies. Use the real binary curl.exe and pass JSON bodies from a UTF-8 file:

curl.exe -X POST "https://app.grantmaking.ai/api/v1/organizations/$ORG_ID/comments" `
  -H "Authorization: Bearer $API_KEY" `
  -H "Content-Type: application/json; charset=utf-8" `
  --data-binary "@body.json"