Track A · Module 1 of 7

The data model: grants, contracts, funders and one response envelope

Learn the three OpenGrants objects, the results/pagination/meta envelope, auth and error codes, and wrap them in a typed client.

11 min read · 4 min video

Video length 3:58Captions available
Transcript

The OpenGrants data model

Every screen in a funding portal is built from three object types and one response envelope. Learn those four shapes and the rest of this course is plumbing. In the next few minutes we will cover what the OpenGrants API returns, how it authenticates, how it fails, and why you should wrap all of it in one small typed client.

Seven endpoints, one base URL

The API lives under ops dot opengrants dot io, slash functions, slash v one. There are three list endpoints: grants, contracts and funders. Each has a lookup by ID. The seventh is the only POST, the matching endpoint. Search, filters, sorting and paging are all query parameters on the list endpoints. That is the whole surface.

The key never reaches a browser

Authentication is a bearer token in the Authorization header. You create keys in the Developer Dashboard and see each one exactly once. The API allows cross-origin calls, which is exactly why you must not make them from a browser. Anyone who opens developer tools would get your unlimited-request key. Every call goes through your server.

results, pagination, meta

All three list endpoints return the same shape. Results is the page. Pagination gives you the total for the whole filter set, so you can show a count without a second call. Returned is separate from limit, so you know when you are on the last page. Meta echoes the sort the server actually applied, which catches query bugs early.

Two responses break the pattern

Two responses do not use that envelope. Ask for count only, and you get just a total, which is ideal for dashboard badges. The matching endpoint returns count and matches. Code that assumes every response has a results array will break on matching, so type the shapes separately from day one.

Grant, contract, funder

A grant has a title, description, amount range, deadline, geography, funder and a listing URL. A contract has all of that plus a six digit NAICS code, a set-aside code, a contact email and a document link. A funder record is lighter: a name, a summary and a website. Grants and contracts point back to their funder by ID.

A null is information, not zero

The most common bug in funding portals is treating null as zero or as soon. A missing deadline usually means rolling or unpublished, so never sort it to the top of a closing soon list. Missing amounts mean the source did not say, so never print zero dollars. Decide what each null means on screen before you build the component.

Read the body, not just the status

Errors come back as JSON with a stable code and a request ID. A four hundred means your query builder let something bad through. A four oh one with invalid API key means the key itself is the problem. A four oh three means the plan lacks API access. Only retry four twenty nine and server errors, and always log the request ID.

Wrap it all in a typed client

Put every one of these rules into a single file: the base URL, the types, parameter serialization, retries and one error class. Every later lesson imports it. When a field name changes, you fix one file instead of hunting through route handlers. The complete listing is in the starter template for this course.

Generate your API key

Now open the Developer Dashboard at ops dot opengrants dot io, slash app, slash developer. Create a key for local development and store it somewhere safe. Make one call for a single result, then run the count for your state with and without national programs. The gap between those two numbers is what a state-only filter hides.

Every screen in a funding portal is built from three object types and one response envelope. Learn those four shapes well and the rest of this course is plumbing.

This lesson covers what the OpenGrants API returns, how it authenticates, how it fails, and how to wrap all of it in a small typed client that every later lesson imports. The client matters more than it looks: when a field name or parameter changes, you fix one file instead of hunting through route handlers.

The API at a glance

The OpenGrants API is a REST API over the same index the OpenGrants app searches: 139,000+ indexed funding opportunities, 43,000+ of them open, drawn from Grants.gov, SAM.gov, state and municipal portals and IRS 990 filings, refreshed daily. It is published with an OpenAPI 3.0.3 spec.

ItemValue
Base URLhttps://ops.opengrants.io/functions/v1
Spechttps://ops.opengrants.io/openapi.json
Docs and playgroundhttps://ops.opengrants.io/api-docs
AuthAuthorization: Bearer <api key>
KeysCreated in the Developer Dashboard at ops.opengrants.io/app/developer
Price$299/month, or $239/month billed annually, unlimited requests

Seven endpoints accept an API key:

MethodPathReturns
GET/grants-apiPaginated list of grants
GET/grants-api/{id}One grant
GET/contracts-apiPaginated list of government contracts
GET/contracts-api/{id}One contract
GET/funders-apiPaginated list of funding organizations
GET/funders-api/{id}One funder
POST/match-grants-apiGrants ranked against an organization profile

That is the whole surface. Search, filtering, sorting and paging are query parameters on the three list endpoints. Matching is the one POST. Webhooks (Lesson 4) push to you rather than being called.

Authentication

Keys are opaque strings, not JWTs. OpenGrants’ own agent documentation describes them as 40 characters. You create them in the Developer Dashboard, name them (“Production portal”, “Local dev”), and see each key exactly once, at creation. The dashboard afterward shows only a prefix, a created date and a last-used date.

curl -sS "https://ops.opengrants.io/functions/v1/grants-api?limit=1" \
  -H "Authorization: Bearer $OPENGRANTS_API_KEY"

Three rules follow from “you see it once”:

  1. Create one key per environment. Local development, staging and production each get their own. When one leaks, you revoke one.
  2. Store it in a secret manager the moment you copy it. On Cloudflare Workers that is npx wrangler secret put OPENGRANTS_API_KEY. Lesson 7 covers the full setup.
  3. The key never reaches a browser. Not in a bundle, not in a NEXT_PUBLIC_ variable, not in a query string. The API sends Access-Control-Allow-Origin: *, so a browser call would technically work, and that is exactly why you must not do it: anyone who opens dev tools gets your unlimited-request key. Every call in this course goes through your server.

The response envelope

All three list endpoints return the same shape:

interface ListResponse<T> {
  results: T[];
  pagination: { total: number; limit: number; offset: number; returned: number };
  meta: { response_time_ms: number; sort_by: string; order: "asc" | "desc" };
}

Each field has a job in your UI:

  • pagination.total is the count for the whole filter set. Render “Showing 20 of 1,284” without a second call.
  • pagination.returned is separate from limit. When offset + returned >= total, you are on the last page. Do not infer the end from returned < limit; use the arithmetic.
  • meta.sort_by and meta.order echo what the server actually applied. If you asked for sort_by=deadline_date and meta.sort_by says created_at, your query builder has a bug, and the response told you before a user did.
  • meta.response_time_ms is server time. Log it next to your own round-trip time; the difference is network and your code.

Live responses also carry meta.search_mode, which is not in the documented interface. Treat any undocumented field as optional in your types.

Two responses do not use this envelope:

  • count_only=true on a list endpoint returns only { total }. Use it for badge counts and dashboards.
  • POST /match-grants-api returns { count, matches }. Lesson 3 covers it. Code that assumes every response has results will break on matching, which is a good reason to type the two shapes separately.

Paging limits

limit runs from 1 to 100 (default 20). offset is capped at 10,000. So the deepest you can page into any single filter set is about 10,100 records. That is not a problem for a portal, because no person pages that deep. It is a problem for anyone trying to mirror the index with a paging loop. If you think you need to walk everything, you almost certainly need a narrower filter, count_only, or webhooks instead.

The three objects

Grant

interface Grant {
  id: string;                 // UUID
  title: string;
  description: string;
  amount_min: number | null;
  amount_max: number | null;
  deadline_date: string | null; // ISO date
  status: string;
  geography: string | null;
  funder_id: string;
  funder_name: string;
  listing_url: string | null;
  created_at: string;         // ISO datetime
  categories: string[];
}

Contract

A contract carries everything a grant does, plus four fields that matter for government contracting:

FieldMeaning
naicsThe six-digit NAICS industry code on the notice
set_aside_codeThe socioeconomic set-aside, if any. Often null.
primary_contact_emailContracting contact, when published
document_urlSolicitation documents, when published

For federal notices, listing_url points at the SAM.gov notice. funder_name carries the agency path, for example DEPT OF DEFENSE > DEPT OF THE NAVY.

Funder

Funder records are lighter: id, name, summary, website, created_at. Live responses also include a slug. Grants and contracts point back to their funder with funder_id and funder_name.

Fields you will see that the docs do not list

Querying live data shows a few extra fields on grants and contracts: opportunity_type (for example "open_opportunity"), project_end_date, and, on semantic or hybrid searches, relevance, a normalized 0 to 1 similarity score described in the API changelog. Model them as optional so your code survives either way.

Nulls are information

The most common data-model bug in funding portals is treating null as zero or as “soon.” Decide what each null means on screen before you write a component.

Field is nullWhat it usually meansRender
deadline_dateRolling, continuous, or not published in the source“No published deadline. Check the listing.” Never sort it to the top of a “closing soon” list.
amount_min and amount_maxThe source did not state an award size“Award amount not listed.” Never “$0.”
Only amount_minA floor was stated“From $50,000”
Only amount_maxA ceiling was stated“Up to $250,000”
listing_urlNo source linkLink to your own detail page, and say the source link is unavailable
set_aside_codeThe notice is not restricted to a set-aside category, or none was recorded“No set-aside listed”

The amount filters interact with these nulls in a way that surprises people. From the docs: min_amount filters on amount_max, and max_amount filters on amount_min. So min_amount=250000 means “the award can reach at least $250,000.” That is the right semantics for “show me programs big enough for this project,” and it also means a record with no stated amounts will not match an amount filter. If your users care about completeness, offer the amount filter as an option, not a default.

Open, awarded, and the default you should keep

Since the API changelog entry of 2026-06-11, list endpoints return open opportunities only unless you ask otherwise. The opportunity_type parameter takes:

  • open (default): applyable opportunities.
  • awarded: previously funded projects, for research. These have no deadline; they carry project_end_date instead.
  • all: both.

Keep the default for anything a member will act on. Use awarded for a separate “who has been funded for this kind of work” research view, and label it loudly. Mixing awarded records into a list people apply from is a support ticket waiting to happen.

Errors, and what each one is telling you

Errors come back as JSON with a stable machine code and a request ID:

{ "error": "Missing or invalid Authorization header", "code": "INVALID_API_KEY", "request_id": "9b891cf7-..." }
StatusTypical codeCauseYour response
400INVALID_PARAMBad parameter or wrong method; detail often says whichFix the query builder. Do not retry.
401INVALID_API_KEYKey missing, malformed, inactive, or subscription lapsedAlert yourself. Do not retry.
401UNAUTHORIZED_INVALID_JWT_FORMATThe gateway rejected a path that does not accept API keysYou called the wrong endpoint. The key is fine.
403NO_API_ACCESSKey valid, plan does not include API accessCheck the subscription on the account.
404Single-record lookup, ID not foundShow “no longer listed”
429Request quota exceededBack off, then retry
500Server errorRetry with backoff, then surface a friendly message

The two different 401s are worth memorizing. OpenGrants documents the distinction explicitly: a body with INVALID_API_KEY came from OpenGrants’ key validator; a body with UNAUTHORIZED_INVALID_JWT_FORMAT came from the gateway in front of it, which means you called a path that expects a session token. Read the body, not just the status.

Log request_id on every failure. It is the one value support can use to find your request.

Rate limits

The Developer tier is unlimited, and responses still carry X-RateLimit-Limit (-1 means unlimited) and X-RateLimit-Remaining. Write your client as if the API were metered anyway: retry only on 429 and 5xx, with exponential backoff, and cap the retries. That habit protects you from your own bugs (an infinite render loop firing requests) as much as from the API.

The typed client

Everything above goes into one file, src/lib/opengrants.ts. The full listing is in the starter template; the core looks like this:

export const OPENGRANTS_BASE_URL = "https://ops.opengrants.io/functions/v1";

export class OpenGrantsClient {
  constructor(private readonly apiKey: string, options: ClientOptions = {}) { /* ... */ }

  searchGrants(query: OpportunityQuery = {}): Promise<ListResponse<Grant>> {
    return this.request("/grants-api", { query: { ...query } });
  }
  countGrants(query: OpportunityQuery = {}): Promise<CountResponse> {
    return this.request("/grants-api", { query: { ...query, count_only: true } });
  }
  searchContracts(query: ContractQuery = {}): Promise<ListResponse<Contract>> {
    return this.request("/contracts-api", { query: { ...query } });
  }
  searchFunders(query: FunderQuery = {}): Promise<ListResponse<Funder>> {
    return this.request("/funders-api", { query: { ...query } });
  }
  matchGrants(req: MatchRequest): Promise<MatchResponse> {
    return this.request("/match-grants-api", { method: "POST", body: req });
  }
  // getGrant, getContract, getFunder, countContracts ...
}

The private request method does four things: serializes arrays like states: ["OR", "WA"] into states=OR,WA, drops undefined parameters, retries 429 and 5xx twice with backoff, and throws an OpenGrantsError carrying status, code, requestId and detail. Route handlers catch that one error type and never parse raw responses themselves.

One deliberate hedge: the docs describe the single-record endpoints as returning “one grant” but do not show whether the object comes bare or wrapped. The client’s unwrapOne accepts a bare object or a { data } or { result } wrapper. Once you have seen a real response, delete the branch you do not need.

Worked example: Riverbend Regional Development Partnership, a fictional economic development organization in Oregon, wants a number for its board: how many open grants its members could look at today. One call, no paging:

const og = new OpenGrantsClient(env.OPENGRANTS_API_KEY);
const { total } = await og.countGrants({ states: ["OR"], include_national: true });

Without include_national: true, the states filter returns state-specific opportunities only, and the count leaves out every national program an Oregon organization could also apply to. OpenGrants’ own documentation calls forgetting that flag the most common reason a state query looks empty.

Common mistakes

Common mistake: Calling the API from the browser because the CORS headers allow it. The key is then public. Route every call through your server.

Common mistake: Assuming every response has results. Matching returns { count, matches }; count_only returns { total }. Type them separately.

Common mistake: Rendering null amounts as $0 or sorting null deadlines first in a “closing soon” view. Decide the meaning of every null before you build the component.

Common mistake: Retrying 400 and 401. They will fail identically every time. Retry 429 and 5xx only.

Common mistake: Filtering by states without include_national. Your Oregon member sees Oregon programs and none of the federal ones.

Do this now in OpenGrants

  1. If you do not have an API subscription, start at ops.opengrants.io/signup. The API is $299/month, or $239/month billed annually.
  2. Open the Developer Dashboard at ops.opengrants.io/app/developer and create a key named “local-dev.” Copy it straight into a password manager or .dev.vars file that is in .gitignore.
  3. Run the curl call above with limit=1. Confirm you get results, pagination and meta.
  4. Run the same call with count_only=true&states=YOUR_STATE&include_national=true, then again without include_national. Write both numbers down. The gap is the national programs a state-only filter hides.
  5. Copy src/lib/opengrants.ts from the starter template into your project. You will import it in every lesson that follows.

Generate your API key (opens in a new tab)

Check yourself

  1. A response has pagination.total = 212, offset = 200, returned = 12. Is there another page? How do you know?
  2. What is the difference between a 401 with INVALID_API_KEY and a 401 with UNAUTHORIZED_INVALID_JWT_FORMAT, and what do you do about each?
  3. A grant has amount_min: null, amount_max: 150000. What does your card say, and will it match min_amount=100000?
  4. Why should a member-facing list keep the default opportunity_type=open?
  5. Name two responses that do not use the results/pagination/meta envelope.

Sources