# Grant'd API — Reference

Unofficial reference for the REST API at `https://grantd.com.au`. See [AUTH.md](AUTH.md) for how to obtain credentials and [AUDIT.md](AUDIT.md) for access-model notes.

## Stack fingerprint

- **WordPress** on WP Engine (fronted by Cloudflare)
- **Paid Memberships Pro** for content gating
- **Yoast SEO** for sitemaps
- **Easy Digital Downloads** + **User Registration Pro** for signup/billing
- Custom theme at `/wp-content/themes/grantd/`

Grant catalog: **7,105 grants** (verified via `X-WP-Total` on `/wp/v2/grant`, 2026-04-23).

## Base URL

```
https://grantd.com.au/wp-json/
```

All routes are rooted at this base. The API is cleanly namespaced:

| Namespace | Purpose |
|---|---|
| `wp/v2` | Standard WordPress REST — grants as a custom post type |
| `grantd/v1` | Site-specific API — the rich grant list, user dashboard, saved searches, calendar, exports |
| `pmpro/v1` | Paid Memberships Pro — membership tiers, user subscription status |
| `yoast/v1` | Yoast full-text search (authenticated) |

Full namespace list (from `GET /wp-json/`):

```
wp/v2, pmpro/v1, oembed/1.0, wpe/cache-plugin/v1, wpe_sign_on_plugin/v1,
user-registration/v1, yoast/v1, edd/webhooks/v1, edd/v3, edd,
grantd/v1, wp-site-health/v1, wp-block-editor/v1, wp-abilities/v1
```

## Auth modes

Three auth modes are supported. See [AUTH.md](AUTH.md) for setup.

| Mode | Header | Use case |
|---|---|---|
| Anonymous | — | Public surface only: title/URL index, taxonomy vocab, pricing |
| App Password | `Authorization: Basic base64(user:app_pw)` | Recommended for agents and integrators |
| Cookie + Nonce | `Cookie: wordpress_logged_in_*=…` + `X-WP-Nonce: <nonce>` | Same-session as a logged-in browser; fallback if app passwords are disabled |

Response headers worth watching:

| Header | Meaning |
|---|---|
| `X-WP-Total` | Total items matching the query |
| `X-WP-TotalPages` | Page count at current `per_page` |
| `Link` | `rel="next"` / `rel="prev"` for cursor pagination |
| `Access-Control-Allow-Origin` | Reflects the request Origin (CORS wide open, see AUDIT.md) |

No rate-limit headers are advertised. Cloudflare's `__cf_bm` bot-management layer fronts the site, so keep scripted traffic below ~2 req/s per IP.

---

## Public endpoints (no auth)

### `GET /wp/v2/grant`

The WordPress custom-post-type grant list. **Only title/URL/date fields are populated without auth** — the body and ACF fields are paywalled.

**Query parameters:**

| Param | Type | Notes |
|---|---|---|
| `per_page` | int | 1–100, default 10 |
| `page` | int | 1-based |
| `search` | string | Case-insensitive title/slug match |
| `orderby` | enum | `date`, `modified`, `title`, `slug`, `id` |
| `order` | enum | `asc`, `desc` |
| `after` / `before` | ISO-8601 | Published-date window |
| `modified_after` / `modified_before` | ISO-8601 | Ideal for incremental sync |
| `slug` | string | Exact slug match (repeatable) |
| `include` / `exclude` | int list | ID filters |
| `_fields` | string | Comma list; slim the response |
| `_embed` | flag | Expand `_links` inline |

**Response shape (unauthenticated):**

```json
{
  "id": 43426,
  "date_gmt": "2026-04-22T14:28:27",
  "modified_gmt": "2026-04-22T14:28:27",
  "slug": "2026-nursing-fellowship-grant",
  "link": "https://grantd.com.au/grant/2026-nursing-fellowship-grant/",
  "title": { "rendered": "2026 Nursing Fellowship Grant" },
  "content": { "rendered": "", "protected": false },
  "acf": [],
  "grant_funding_need": [],
  "grant_industry":      [],
  "grant_lga":           [],
  "grant_location":      [],
  "grant_organisation_type": [],
  "grant_sdg":           [],
  "grant_sector":        [],
  "class_list": ["post-43426", "grant", "type-grant", "status-publish", "pmpro-has-access"],
  "_links": { "…": "…" }
}
```

Verified empirically: `content.rendered` is empty on 0 / 100 grants in a single anonymous page.

**Examples:**

```bash
# Slim title+URL index
curl 'https://grantd.com.au/wp-json/wp/v2/grant?per_page=100&_fields=id,slug,title,link,modified_gmt'

# Incremental sync (last 24h)
curl 'https://grantd.com.au/wp-json/wp/v2/grant?per_page=100&modified_after=2026-04-22T00:00:00&_fields=id,slug,title,link,modified_gmt'

# Title search
curl 'https://grantd.com.au/wp-json/wp/v2/grant?search=nursing&_fields=id,title,link'
```

### `GET /wp/v2/grant/<id>`

Single grant. Same empty-body response as the list when unauthenticated.

### `GET /wp/v2/search`

Lightweight generic search returning `{ id, title, url, type, subtype }`.

```bash
curl 'https://grantd.com.au/wp-json/wp/v2/search?search=nursing&subtype=grant&per_page=10'
```

| Param | Notes |
|---|---|
| `search` | Query string |
| `type` | `post` (default) |
| `subtype` | `grant`, `article`, `page`, etc. Default `any`. |
| `per_page`, `page` | Pagination |

### Taxonomies

All seven grant taxonomies are enumerable without auth. **Note:** `count` is `0` on every term in anonymous responses, because the term-to-post assignments are paywalled along with the grant body.

| Route | Vocabulary | Terms |
|---|---|---:|
| `GET /wp/v2/grant_industry` | Industries (ANZSIC-style) | 25 |
| `GET /wp/v2/grant_sector` | Sectors | 23 |
| `GET /wp/v2/grant_funding_need` | Funding needs | 21 |
| `GET /wp/v2/grant_sdg` | UN SDGs | 17 |
| `GET /wp/v2/grant_organisation_type` | Org types | 13 |
| `GET /wp/v2/grant_location` | AU states + national | 10 |
| `GET /wp/v2/grant_lga` | Local Government Areas | 540 |

Each returns `[{ id, name, slug, count, description, taxonomy }]`. Same WP REST params as `/wp/v2/grant` (`search`, `per_page`, `slug`, etc.).

```bash
curl 'https://grantd.com.au/wp-json/wp/v2/grant_industry?per_page=100&_fields=id,slug,name'
```

### `GET /pmpro/v1/membership_levels`

Pricing. Returns all membership levels including ones with `allow_signups: "0"`.

```bash
curl 'https://grantd.com.au/wp-json/pmpro/v1/membership_levels'
```

**Current pricing (AUD, verified 2026-04-23):**

| ID | Name | Initial | Recurring | Cycle | Signup? |
|---|---|---:|---:|---|:---:|
| 5 | Starter | $0 | — | — | — |
| 3 | Seeker (quarterly) | $53.64 | $53.64 | 3 months | ✅ |
| 4 | Seeker (annual) | $201.82 | $201.82 | 1 year | ✅ |
| 2 | Enterprise | $499 | $499 | 1 year | ❌ (sales-led) |

### Sitemaps (XML)

Yoast sitemaps are indexable without auth. Faster than paginating `/wp/v2/grant` if you only need URLs + lastmod.

```
https://grantd.com.au/sitemap_index.xml
  ├─ page-sitemap.xml
  ├─ grant-sitemap.xml, grant-sitemap2…8.xml   all 7,105 grants, sharded
  └─ article-sitemap.xml                        blog / resources
```

---

## Authenticated endpoints

All endpoints below return `401 rest_forbidden` without auth. Use an Application Password (recommended) or cookie + `X-WP-Nonce`. See [AUTH.md](AUTH.md).

### `grantd/v1` namespace

#### Grants (rich)

##### `GET /grantd/v1/grants`

The rich grant list — returns populated `content`, ACF fields, taxonomy assignments. This is what the Grant'd dashboard UI consumes.

Accepts the same query params as `/wp/v2/grant` plus site-specific filters (exact shape needs a live auth'd capture to document fully — contributions welcome).

#### Per-user grant state (`/grantd/v1/grant-user-data/<id>/…`)

All verbs are `PUT`. Confirmed against the theme's own JS (`project-dropdown.js`, `user-grant-status.js`).

| Route | Body | Purpose |
|---|---|---|
| `PUT …/add-to-favourites` | — | Bookmark grant |
| `PUT …/remove-from-favourites` | — | Un-bookmark |
| `PUT …/add-to-calendar` | — | Add deadline to personal ICS feed |
| `PUT …/remove-from-calendar` | — | Remove from ICS |
| `PUT …/add-to-project` | `{ project_id }` | Assign to user's project |
| `PUT …/remove-from-project` | `{ project_id }` | Unassign |
| `PUT …/hide` | — | Hide from default views |
| `PUT …/notes` | `{ notes }` | Attach free-text note |
| `PUT …/status` | `{ status }` | Mark status (e.g. applying, submitted) |

#### Dashboard / derived views

| Route | Returns |
|---|---|
| `GET /grantd/v1/dashboard/grant-counts` | Counts by category for the logged-in user |
| `GET /grantd/v1/dashboard/top-grants` | Ranked matches based on user's match criteria |
| `GET /grantd/v1/dashboard/saved-grants` | Favourites list |
| `GET /grantd/v1/dashboard/saved-searches` | Saved search definitions |
| `GET /grantd/v1/dashboard/saved-searches-skeleton` | Skeleton for UI rendering |
| `GET /grantd/v1/dashboard/projects` | User's projects |

#### Saved searches

| Route | Body | Purpose |
|---|---|---|
| `POST /grantd/v1/saved-search` | filter criteria | Create |
| `PUT /grantd/v1/saved-search/<id>` | filter criteria | Update |
| `DELETE /grantd/v1/saved-search/<id>` | — | Delete |

#### Projects

| Route | Body | Purpose |
|---|---|---|
| `POST /grantd/v1/projects` | `{ name, … }` | Create |
| `PATCH /grantd/v1/projects/<id>` | partial | Update |
| `DELETE /grantd/v1/projects/<id>` | — | Delete |

#### User profile (`/grantd/v1/users/me`)

| Route | Purpose |
|---|---|
| `PUT /grantd/v1/users/me` | Generic profile edit |
| `PUT /grantd/v1/users/me/onboarding` | Onboarding answers |
| `PUT /grantd/v1/users/me/match-criteria` | Industries, locations, funding need, org type, SDG, sector, LGA, amount — the matching inputs |
| `PUT /grantd/v1/users/me/funding-goal` | Target funding amount |
| `GET,PUT /grantd/v1/users/me/grants-per-page` | UI preference |
| `PUT /grantd/v1/users/me/password` | Change password |
| `POST,DELETE /grantd/v1/users/me/avatar` | Avatar upload / removal |

#### Calendar feeds (ICS)

| Route | Returns |
|---|---|
| `GET /grantd/v1/calendar/feed.ics` | `text/calendar` — all grant deadlines on user's radar |
| `GET /grantd/v1/calendar/favourites.ics` | `text/calendar` — favourited grants only |

Plug these into Google Calendar, Apple Calendar, or any tool that reasons over time windows.

#### Exports

| Route | Returns |
|---|---|
| `GET /grantd/v1/export/favourite-grants` | Bulk dump of favourites (format TBD) |

#### Mail (likely internal)

| Route | Notes |
|---|---|
| `GET /grantd/v1/mail/send-weekly-digest` | Triggers digest email. Authz likely cron-only; use at your own risk. |

### `pmpro/v1` namespace

| Route | Purpose |
|---|---|
| `GET /pmpro/v1/me` | Current user's membership (sanity-check auth) |
| `GET /pmpro/v1/get_membership_level_for_user` | Primary level |
| `GET /pmpro/v1/get_membership_levels_for_user` | All levels |
| `GET /pmpro/v1/has_membership_access` | Boolean access check |
| `GET /pmpro/v1/checkout_level` | Checkout state |
| `GET /pmpro/v1/checkout_levels` | Signup-visible tiers (returns "No levels found." unauth) |
| `POST /pmpro/v1/change_membership_level` | Upgrade / downgrade |
| `POST /pmpro/v1/cancel_membership_level` | Cancel |
| `GET /pmpro/v1/recent_orders` | Order history |
| `GET /pmpro/v1/recent_memberships` | Membership history |
| `GET /pmpro/v1/order` | Single order |

### `yoast/v1` namespace

| Route | Purpose |
|---|---|
| `GET /yoast/v1/meta/search?s=<q>` | Full-text search with excerpts across grants + articles |

Returns 401 without auth. Auth'd gives semantic-ish search — a meaningful upgrade over WP's `/wp/v2/search` (title-only).

---

## Pagination pattern

Every list endpoint follows the WordPress convention:

```
GET /wp/v2/grant?per_page=100&page=1

HTTP/1.1 200 OK
X-WP-Total: 7105
X-WP-TotalPages: 72
Link: <…?page=2>; rel="next"
```

Walk until `page > X-WP-TotalPages` or the `Link: rel="next"` header disappears. Max `per_page` is **100**.

For incremental sync, use `modified_after` with the ISO timestamp of your last run. `X-WP-Total` then gives you the diff count.

## Error model

Standard WordPress REST error JSON:

```json
{
  "code": "rest_forbidden",
  "message": "Sorry, you are not allowed to do that.",
  "data": { "status": 401 }
}
```

Common codes you'll see:

| Code | Status | Meaning |
|---|---|---|
| `rest_forbidden` | 401 / 403 | Endpoint requires auth, or auth lacks capability |
| `rest_post_invalid_id` | 404 | Unknown grant ID |
| `rest_invalid_param` | 400 | Bad query param (check `_fields`, `orderby`, etc.) |
| `rest_cookie_invalid_nonce` | 403 | Cookie-auth nonce expired or missing `X-WP-Nonce` header |

## What's NOT documented here

The following are referenced but not probed in depth. PRs welcome.

- Exact JSON shape of an authenticated `/grantd/v1/grants` response (body + ACF fields list) — needs a live paid login.
- `GET /grantd/v1/calendar/feed.ics` content structure — ICS is standard, but the set of included fields depends on the account's match criteria.
- `/wp-admin/admin-ajax.php` surface — not tested.
- `/oembed/1.0/*`, EDD (`edd`, `edd/v3`, `edd/webhooks/v1`) — outside the integrator scope.
