Skip to main content

Build a Deal Aggregator with the Explore API

This guide walks through a complete use case: a public discovery app (a deal feed, an alerting dashboard, an analysis tool) that lists the active deals on Wefunder, shows each company's page, and points its users toward them. By the end you will have a server-side key, minted an access token, paged through every public offering, rendered a company's detail tabs, and set up a polling loop that stays under the rate limits, all without any per-user OAuth flow.

The Explore endpoints are public: they return exactly what an anonymous visitor sees on wefunder.com. Private rounds (e.g. Reg D 506(b)) are never included.

Three rules before anything you build goes live

Public offering data is for display with a link back: show what the API reports for an offering, as reported, and add nothing to it. Until the full acceptable-use page is published, these are the rules every discovery app must follow to stay listed:

  1. Link back and attribute. Every offering you show links to its attributes.url on wefunder.com and is marked as offered on Wefunder. Do not redistribute or resell the catalog, and do not imply Wefunder endorses your app or the deal.
  2. Show the facts, not a pitch. Repeat what the offering states (company, instrument, exemption, the security's terms, target, amount raised, close date). No promotional claims, no advice, no ranking by "quality". Raised and target figures must carry the time you fetched them; a stale number shown as current is a violation.
  3. Say "reserve" when it is a reservation. An offering with attributes.testing_the_waters: true collects non-binding reservations, not investments. Your copy must say "reserve" or "reservation" for those, never "invest".

A shipped app that breaks these can have its key disabled.

Before anything you build goes live, read the acceptable-use and attribution rules. They are a condition of your API access: link back and attribute every offering, show the reported facts and terms with a fetch time rather than a pitch, and say "reserve" for testing-the-waters offerings.

What you'll build​

A backend service that:

  1. Authenticates as your application with a server-side key (no user login).
  2. Lists open offerings, one per company.
  3. Pages through the full catalog.
  4. Looks up individual deals by their stable id.
  5. Renders a company's page and its Overview, Details, Posts, and Ask tabs.
  6. Polls for changes on a schedule that stays under the limits.

Prerequisites​

  • A registered Wefunder application with a Client ID and Client Secret from the developer portal. Portal access is approved off the API waitlist unless you already run a syndicate or company on Wefunder.
  • The application is allowed the read:public scope.
Two credentials, two actors

Your Client ID + Client Secret are the durable "API key" — they don't expire; store the secret securely and never ship it to a browser or mobile client. The access token you mint from them is short-lived (see below). If you later let users connect their Wefunder account, that produces a separate per-user token via the Authorization Code flow — a different credential representing the user, not your app.

Step 1: Mint a server-side access token​

Server-side keys use the OAuth 2.0 Client Credentials grant. There is no redirect and no user — your app exchanges its credentials for a token directly:

curl -X POST https://api.wefunder.com/oauth/token \
-d grant_type=client_credentials \
-d client_id=$WF_CLIENT_ID \
-d client_secret=$WF_CLIENT_SECRET \
-d scope=read:public

Response:

{
"access_token": "at_live_xxxxxxxxxxxxxxxxxxxxxxxx",
"token_type": "Bearer",
"expires_in": 7200,
"scope": "read:public",
"created_at": 1781736932
}
  • The token is prefixed at_live_ (or at_test_ for sandbox apps).
  • scope=read:public is required — it's the only scope a server-side key may hold.
  • expires_in is 7200 (2 hours) and there is no refresh token for this grant. When the token nears expiry — or when a request returns 401 — simply call this endpoint again.
Cache the token

Don't mint a new token per request. Cache it for ~2 hours and re-mint on expiry or on the first 401. A constant poller mints roughly a dozen tokens per day.

Step 2: List open offerings​

Send the token as a Bearer credential:

curl https://api.wefunder.com/explore \
-H "Authorization: Bearer $WF_ACCESS_TOKEN"
{
"data": [
{
"id": "ofr_yw3OhusvpfJP4Pk3wRYV1F2R",
"type": "offering",
"attributes": {
"company_name": "ZeroBorder Self-Banking Centers",
"tagline": "Banking infrastructure for the unbanked",
"url": "https://wefunder.com/zeroborder",
"card_image_url": "https://uploads.wefunder.com/uploads/company/custom_card_photo/189517/large_card.jpg",
"logo_url": "https://uploads.wefunder.com/uploads/company/logo/189517/large_logo.png",
"exemption": { "family": "reg_cf", "subtype": null, "label": "Reg CF" },
"security_type": "safe",
"status": "open",
"testing_the_waters": false,
"funding_target": "1000000",
"min_investment": "100",
"amount_raised": "642300",
"investor_count": 1203,
"started_at": "2026-05-01T00:00:00Z",
"closed_at": null
},
"company": "co_8Kd0aB3xQ9k2vF8mNp1zT5wY",
"etag": "a1b2c3d4e5f6"
}
],
"meta": { "total_count": 119, "page_count": 25, "has_more": true, "sort": "most_raised", "next_cursor": 25 }
}

The feed returns one offering per company — the curated set shown on wefunder.com/explore (companies that clear Wefunder's soft-confirmed traction threshold + approval bar), not every open round. meta.total_count is the total across all pages.

"Invest" or "reserve"? Check testing_the_waters on every card

A feed is exactly where this goes wrong, because one template renders every offering. When attributes.testing_the_waters is true the round is collecting reservations: no money changes hands and nothing is purchased. Your button, badge, and summary copy must say "reserve", never "invest", for those cards. Branch on the flag in the template, not in a one-off.

Filtering​

Narrow the list with objective, per-offering filters. Each takes one value; industry and business_model also accept a comma-separated list (any match). An unknown value returns 400 with details.parameter naming the filter.

ParamValues
exemptionreg_cf or reg_d
securityThe instrument, e.g. safe, convertible_note, equity
testing_the_waterstrue or false
max_min_investmentUSD decimal; offerings whose minimum investment is at most this
min_amount_raisedUSD decimal; offerings that have raised at least this
closing_beforeISO 8601 date
industryOne or more of the site's industry tags
business_modelOne or more of the site's business-model tags
followedtrue or false; only with a logged-in read:explore connection
curl "https://api.wefunder.com/explore?exemption=reg_cf&security=safe&max_min_investment=250" \
-H "Authorization: Bearer $WF_ACCESS_TOKEN"

The filters are deliberately objective (exemption, instrument, minimum, dates, tags). There is no relevance, quality, or ranking filter: a funding portal may help you search on objective terms, never on the advisability of an investment.

Sorting​

Order results with ?sort=:

sortOrder
most_raised (default)Highest amount raised first
closing_soonSoonest funding close first
newestMost recently launched first
most_investorsHighest investor count first
curl "https://api.wefunder.com/explore?sort=closing_soon" \
-H "Authorization: Bearer $WF_ACCESS_TOKEN"
Monetary values

funding_target, min_investment, and amount_raised are decimal strings in USD — e.g. "100" means $100. Parse them as decimals (never floats) to avoid rounding errors.

Step 3: Page through the whole catalog​

Pagination is cursor-based, 25 offerings per page by default, up to 100 with per_page (meta.total_count is the total). When meta.has_more is true, pass meta.next_cursor (an opaque token) as the cursor query param to get the next page:

curl "https://api.wefunder.com/explore?cursor=25" \
-H "Authorization: Bearer $WF_ACCESS_TOKEN"

The cursor is a position in the list your query defines: the server applies your filters and sort first, then slices. So every page request must carry the same filter and sort parameters as the first one. A cursor sent with a different query, or with none, lands at the same position in a different list, and you get cards you have already seen or skip cards you have not. Each page echoes what the server applied: meta.sort, and meta.filters with the normalized value of every filter it used (max_min_investment=500 comes back as "500.0", a list filter as an array). Check the echo against your query. If you change filters or sort mid-walk, or the echo does not match what you sent, throw the cursor away and start again from the first page.

A simple loop (pseudocode):

query = {"sort": "closing_soon", "exemption": "reg_cf"}   # the same on every page
cursor = None
while True:
params = {**query, "cursor": cursor} if cursor else query
body = get("/explore", params=params, token=token).json()
meta = body["meta"]
if meta["sort"] != query["sort"] or set(meta["filters"]) != {"exemption"}:
raise RestartWalk # the server did not apply our query; positions are meaningless
for offering in body["data"]:
index(offering) # store/update in your catalog
if not meta["has_more"]:
break
cursor = meta["next_cursor"]

Closed rounds​

The feed lists live rounds by default. Pass status=closed to page through rounds that have finished raising, newest closed_at first, or status=all for the live list followed by the closed list. meta.status echoes the value. Closed rounds cover every finished round the public view of GET /offerings/{id} serves, for companies in Wefunder's public directory, so one company can appear more than once. With status=closed or status=all, exemption is the only filter accepted, and sort is rejected with status=closed. For a full backfill, use per_page=100:

curl "https://api.wefunder.com/explore?status=closed&per_page=100" \
-H "Authorization: Bearer $WF_ACCESS_TOKEN"
Detecting changes

Each offering includes an etag — a digest of its public payload. Store it alongside the offering and skip re-processing when it's unchanged between polls. See Polling on a schedule for the loop that uses it.

Step 4: Fetch a single offering​

Look up any deal by its ofr_ id — useful for refreshing one listing or resolving a link:

curl https://api.wefunder.com/offerings/ofr_yw3OhusvpfJP4Pk3wRYV1F2R \
-H "Authorization: Bearer $WF_ACCESS_TOKEN"

Returns { "data": { ...same offering shape... } }.

A valid id is not always resolvable

A private round (e.g. Reg D 506(b)) has an ofr_ id too, but it is not publicly listed and returns 404 here. Having an id does not grant access — visibility is enforced server-side, exactly as on the website.

Step 5: Beyond the card: the company page and its tabs​

A card is enough for a feed. A detail screen wants the company page behind it. Every offering carries its company's co_ id in the top-level company field (a bare string); five endpoints under /companies/{id} return the page and its tabs as structured data, all on read:public. Each honors the same "who has a page" rule as the site: a valid id for a company the viewer may not see is a 404, never a partial payload.

The page: GET /companies/{id}​

curl https://api.wefunder.com/companies/co_8Kd0aB3xQ9k2vF8mNp1zT5wY \
-H "Authorization: Bearer $WF_ACCESS_TOKEN"

What to render: name, tagline, logo_url, card_image_url, location, and url for the header; raising plus current_raise (the live round, with its linked legs combined into one amount_raised, funding_target, investor_count, closes_at, and its own testing_the_waters) for the progress block; past_rounds[] and wefunder_rounds[] for a history section. The three "raised" numbers the page shows are kept apart on purpose: current_raise.amount_raised is this campaign, past_rounds[] are prior rounds the page's ticker folds in (each tagged source: wefunder or reported), and totals.profile_total_raised is the ticker itself. A funded company renders with raising: false and current_raise: null.

Overview tab: GET /companies/{id}/pitch​

curl "https://api.wefunder.com/companies/co_8Kd0aB3xQ9k2vF8mNp1zT5wY/pitch" \
-H "Authorization: Bearer $WF_ACCESS_TOKEN"

The founder's story as ordered story.blocks (heading, paragraph, list, image, video, footnote) with absolute image and video URLs, plus perks (the perk tiers of the round the page shows). Render the blocks in order; founders publish charts and perk tables as images, so a text-only rendering drops real content. Check authored_by: company means the issuer's own words; wefunder means a Wefunder-prepared deal memo, and disclaimer carries the notice the page shows with it. A pitch can run to ~70 KB; pass sections=story or sections=perks to fetch one part.

Details tab: GET /companies/{id}/disclosures​

curl "https://api.wefunder.com/companies/co_8Kd0aB3xQ9k2vF8mNp1zT5wY/disclosures?sections=financial_statements,risks,use_of_funds" \
-H "Authorization: Bearer $WF_ACCESS_TOKEN"

The company's own Form C disclosures, section for section: business, financial_statements, ratios, current_position, financial_condition, financial_statement_documents, investment_documents, risks, use_of_funds, directors, officers, voting_power, capital_structure, prior_offerings, outstanding_notes, outstanding_debts, related_parties. A section the company hides on the site is null. Money is decimal strings in the currency the payload names (currency for the round's figures, financials_currency for the statements), not USD by assumption. A company with no Reg CF round that has a filing answers 404 with error.type: no_disclosures, which is different from an unknown company; show "no details filed", not "not found".

Posts tab: GET /companies/{id}/updates​

curl "https://api.wefunder.com/companies/co_8Kd0aB3xQ9k2vF8mNp1zT5wY/updates" \
-H "Authorization: Bearer $WF_ACCESS_TOKEN"

The published updates the anonymous viewer may see, pinned first then newest, each with kind, title, a plain-text excerpt, published_at, author.name, comments_count, likes_count, and url. Paged with cursor and per_page. The list carries excerpts only; fetch one post's full plain text with GET /companies/{id}/updates/{update_id}. Render a "latest update" line on the card and the list on the detail screen; link each post to its url.

Ask tab: GET /companies/{id}/questions​

curl "https://api.wefunder.com/companies/co_8Kd0aB3xQ9k2vF8mNp1zT5wY/questions?sort=recent" \
-H "Authorization: Bearer $WF_ACCESS_TOKEN"

Investor questions with their answers, as the tab lists them: question, asked_at, asked_by, likes_count, highlighted, answered_by_team, and answers[] (each with answer, answered_at, answered_by). Questions default to the current raise; past_raises=true includes every raise. sort is relevance (default), recent, upvoted, or unanswered; q searches question and answer text.

Render every answer as a quote attributed to the person in its own answered_by, never as a fact your app asserts, and never as "the company said" by default. The list includes every visible reply, including ones from other investors. answered_by.team is true only when that reply came from the company's team, and answered_by.role is the label the site shows next to the name: founder or employee for the team, wefunder team for Wefunder staff, otherwise the commenter's own disclosure (for example investor) or null. The question-level answered_by_team means at least one reply is from the team; it says nothing about any individual reply.

The company tabs are not under the explore cap

The 60 requests/minute per IP cap below covers /explore, /offerings/*, and /companies/search. The five /companies/{id} endpoints are not in that group; they count only toward the general limits (1,000 per hour per token, 5,000 per hour per IP). Fetch tabs on demand when a user opens a detail screen, not for every card on every poll.

Step 6: Poll on a schedule that stays under the limits​

There is no server-side change feed for the public catalog, so the pattern is: re-page the first N pages every M minutes, diff on etag, and fetch a single offering or company page only when something changed or a user asks. The numbers that bound the schedule:

  • Page size is 25 by default, at most 100 with per_page; the cursor is opaque. A catalog of 119 offerings is 5 pages at the default, 2 at per_page=100.
  • 60 requests per minute per IP on /explore, /offerings/{id}, /offerings/{id}/stats, and /companies/search combined. The throttle matches on the path prefix, so every /offerings/... call counts.
  • 1,000 requests per hour per token and 5,000 per hour per IP across the whole API, including the company tabs.
  • No updated_since filter on /explore, and no HTTP ETag / If-None-Match → 304. The etag field is for client-side dedup only; every poll pays for the full page.
  • Access tokens live 2 hours. Re-mint on expiry or the first 401, not per request.

A schedule that fits comfortably: poll all pages of the feed every 10 minutes (5 pages × 6 polls = 30 explore requests per hour), re-fetch only the offerings whose etag changed, and load company tabs when a user opens a detail screen. Even a 500-offering catalog (20 pages) polled every 5 minutes is 240 explore requests per hour, well inside both the per-minute and per-hour caps, as long as the 20 page requests are spread across the minute rather than fired at once.

An offering that closes or stops being listed does not get a final etag update; it simply stops appearing in the feed. So a poll has to do two things: upsert what it sees, and after a complete traversal, work out what it did not see. Four cautions before you change a card's state:

  • Never prune on a partial poll. A page failed, or you only fetched the first N pages: a card you did not look at is not a card that disappeared.
  • A complete traversal is not a snapshot. The feed is re-sorted on every request and sliced by position, so the list can shift between your page 1 and page 2. Under the default most_raised sort an offering that takes a large investment between two of your requests moves up the list: the card that moved can appear on two consecutive pages (a duplicate) and the card that slid into its old slot can fall between pages (an omission). Dedupe by id across pages, and treat an id missing from one traversal as a candidate, not a removal.
  • Reconcile against one fixed query. The traversal you infer removals from must send the same sort and filters every time (Step 3), and the safest query is no filters at all: an id missing from a filtered traversal may simply have stopped matching (its raise crossed min_amount_raised, its close date moved past closing_before). Filtered calls are for display. If you must reconcile a filtered view, keep its records apart from the unfiltered feed's, because absence means something different in each.
  • The detail endpoint answers a different question than the feed. GET /offerings/{id} returns 200 for any offering the viewer may see, including a successfully closed public round, while the feed lists only the curated companies' currently open, investable rounds, one per company. So a 200 does not mean "still in the feed", and a 404 does not mean "closed"; it means "no longer public". Read attributes.status from the detail response to learn the lifecycle state, and decide feed membership from persistence of absence, not from one poll.

Putting those together, the rule for an id that a complete traversal did not return:

  1. Fetch GET /offerings/{id}, through the same request pacer as the feed pages (next section).
  2. 404: the offering is no longer public. Mark the card unavailable and stop showing it (you cannot say why).
  3. 200 with attributes.status of closed or canceled: the round ended. Mark the card with that status and stop showing it as live.
  4. 200 with attributes.status of open (or upcoming): the offering is public but absent from the feed. Either it fell between pages, or the company left the curated set, or the feed now shows a different round of the same company. Keep the card with the fresh detail payload, flag it "not in the feed", and count the miss. After ABSENCE_LIMIT consecutive complete polls without it, mark it delisted.

The absence limit is a heuristic, not a guarantee. The feed cannot tell you why a public, open offering is absent, and nothing stops a card that sits near a page boundary from being skipped on several traversals in a row, so delisted is an inference from repeated absence and it can be wrong in both directions. Three misses is a reasonable default at one traversal every 10 minutes (a wrong inference lasts about half an hour); raise it if you poll more often. Because it is an inference, keep the record: mark the card delisted rather than deleting it, keep its last payload and fetched_at, and when a later traversal returns the id, flip it back to listed and reset the misses. It is the same card, not a new one. A card that reaches closed, canceled, or unavailable also stays in your store with that state and the time you observed it. A feed that forgets a round ever existed cannot show its history, and cannot tell a returning user what happened to a deal they were watching.

One pacer for pages and confirmations​

Feed pages and confirmation calls draw on the same 60-per-minute per-IP bucket (/explore, /offerings/{id}, /offerings/{id}/stats, and /companies/search share it), so pacing the page loop while firing confirmations in a burst still trips the cap: 20 paced pages plus 45 unpaced confirmations in the same minute is a 429. Route every call to those paths through one limiter, with a budget below the cap (40 per rolling minute leaves room for the detail lookups your app makes when a user opens a card, which go through the same limiter).

When a 429 does arrive, the response gives you the window, not the moment it resets: X-RateLimit-Reset is the time of the rejected request plus the full error.details.period (60 seconds here), and error.details.retry_after is the period itself. The windows are fixed clock buckets, so the real reset is the next boundary. Compute the wait from the response instead of sleeping retry_after blindly, bound it (retry_after is the ceiling), retry a small fixed number of times, and when the retries run out treat the poll as incomplete: keep your records as they are and change no states. A 429 must never count as a miss.

ABSENCE_LIMIT = 3                 # consecutive complete polls before inferring "delisted"; a heuristic
QUERY = {"sort": "most_raised"} # one fixed query for reconciliation, sent on every page
BUDGET_PER_MINUTE = 40 # below the 60/min per-IP cap; shared by pages, confirmations, and user-opened cards
MAX_429_RETRIES = 2

limiter = RollingWindowLimiter(BUDGET_PER_MINUTE, seconds=60) # one instance for the whole process

def paced_get(path, token, params=None):
for _ in range(MAX_429_RETRIES + 1):
limiter.acquire() # blocks until the rolling minute has a slot
resp = get(path, params=params, token=token)
if resp.status_code != 429:
return resp
details = resp.json()["error"]["details"]
period, retry_after = details["period"], details["retry_after"]
rejected_at = int(resp.headers["X-RateLimit-Reset"]) - period # Reset is "rejected at" + period
wait = min(period - (rejected_at % period) + 1, retry_after + 1) # next bucket boundary, bounded
sleep(wait)
return resp # still 429: the caller treats it as incomplete

# records: {id: {"etag", "state", "misses", "payload", "fetched_at"}}
# state is one of listed / not_in_feed / delisted / closed / canceled / unavailable
def poll(token, records):
cursor, changed, seen = None, [], set()
while True:
params = {**QUERY, "cursor": cursor} if cursor else QUERY
resp = paced_get("/explore", token, params)
if resp.status_code != 200:
return changed, [] # incomplete (429s exhausted, 5xx, ...): change no states
body = resp.json()
if body["meta"]["sort"] != QUERY["sort"] or body["meta"]["filters"]:
return changed, [] # the server did not apply our query; positions are meaningless
for o in body["data"]:
if o["id"] in seen:
continue # the list shifted under us; same card twice
seen.add(o["id"])
rec = records.setdefault(o["id"], {"etag": None, "state": None, "misses": 0})
rec["misses"] = 0 # in the feed: whatever we inferred, it is listed
if rec["etag"] != o["etag"] or rec["state"] != "listed":
rec.update(etag=o["etag"], state="listed", payload=o, fetched_at=now())
changed.append(o) # re-render; a returning "delisted" card is the same card
if not body["meta"]["has_more"]:
break
cursor = body["meta"]["next_cursor"]
transitions = []
for offering_id in set(records) - seen: # candidates only; confirm each one, paced
rec = records[offering_id]
if rec["state"] in ("closed", "canceled", "unavailable", "delisted"):
continue # resolved or already inferred; keep the record, no call
check = paced_get(f"/offerings/{offering_id}", token)
if check.status_code == 404:
rec.update(state="unavailable", fetched_at=now()) # no longer public; the record stays
transitions.append((offering_id, "unavailable"))
elif check.status_code == 200:
o = check.json()["data"]
status = o["attributes"]["status"]
rec.update(etag=o["etag"], payload=o, fetched_at=now())
if status in ("closed", "canceled"):
rec["state"] = status # the round ended; stop showing it as live
transitions.append((offering_id, status))
else: # public, open, not in the feed
rec["misses"] += 1
if rec["misses"] >= ABSENCE_LIMIT:
rec["state"] = "delisted" # inferred from absence, not observed
transitions.append((offering_id, "delisted"))
else:
rec["state"] = "not_in_feed"
changed.append(o) # refresh the card and show its state
# 429 after the retries, or any other status: leave it for the next poll; no miss counted
return changed, transitions

A delisted card is not confirmed again on later polls (it would cost one request per card per poll forever); the feed itself revives it if it comes back. If you show delisted cards, re-check them on a slower schedule, daily say, so one that closes while delisted eventually picks up its closed status.

Cases to keep in your tests, each against a fake server you control:

  • Filters survive the walk. Start a walk with ?exemption=reg_cf&sort=closing_soon; the fake returns has_more: true and a cursor. The client's second request carries exemption, sort, and cursor; a client that sends cursor alone fails. A page whose meta.sort is not the one requested ends the walk with no state changes, and changing the filter between runs starts from no cursor.
  • Reordered, still listed. 26 offerings; page 1 returns ids 1 to 25, then offering 26 takes a large investment and moves to first place before you request page 2. Page 2 (offset 25) returns offering 25 again and never shows 26. The loop skips the duplicate, confirms 26 with a 200 whose status is open, counts one miss, and keeps the card as not_in_feed. It reappears on the next poll and the misses reset.
  • Reordered again and again. The same card falls between pages on three consecutive complete polls, each confirmed 200 / open. Misses go 1, 2, 3; the state goes not_in_feed, not_in_feed, delisted; the record is never deleted and keeps the last detail payload and fetched_at. On the fourth poll it is on page 1: the state is listed again with misses at 0, exactly one entry in changed, and no second card. A fifth poll in which it is absent again starts the count from 1, not 3.
  • Closed, still readable. A Reg CF round reaches its close and succeeds. It leaves the feed, but GET /offerings/{id} still returns 200 with attributes.status: "closed". The loop marks the card closed on the first poll after the close, never waits out the absence limit, never re-upserts it as live, keeps the record, and does not confirm it again.
  • Pages and confirmations share one budget. A 20-page traversal followed by 30 confirmations is 50 calls in one poll. With BUDGET_PER_MINUTE = 40 the limiter holds the last ten until the rolling minute allows them; an implementation that paces pages and not confirmations, or uses two limiters, sends them at once and fails the test. A fake 429 carrying period: 60, retry_after: 60, and X-RateLimit-Reset of T + 60 makes the client sleep until the next 60-second boundary after T (at most 61 seconds) and retry; a 429 on every retry ends the poll with no transitions and no miss counted.

Store the fetch time with every raised and target figure you show; rule 2 above requires you to display it.

Field reference​

FieldNotes
idThe offering's stable ofr_ external id.
typeAlways "offering".
attributes.company_nameCompany name.
attributes.taglineShort pitch; may be null.
attributes.urlAbsolute Wefunder profile URL (environment-correct). Link every card here.
attributes.card_image_urlAbsolute explore card image URL, or null.
attributes.logo_urlAbsolute company logo URL, or null.
attributes.exemption.familyreg_cf, reg_d, reg_a, reg_s, or ecsp.
attributes.exemption.subtype506b / 506c for the reg_d family, otherwise null.
attributes.exemption.labelDisplay string, e.g. Reg D 506(c).
attributes.security_typeThe instrument — safe, convertible_note, equity, priced_round, fund, …
attributes.statusLifecycle status: upcoming, open, closed, or canceled. Only open offerings appear in the feed; the detail endpoint returns the others too.
attributes.testing_the_waterstrue ⇒ a reservation round (no money changes hands). Use "reserve" copy, not "invest".
attributes.funding_target / min_investment / amount_raisedUSD as clean decimal strings (e.g. "50000").
attributes.investor_countDistinct investor count (hellban-filtered); may be null.
attributes.started_at / closed_atISO 8601 timestamps; closed_at may be null.
companyThe parent company's co_ external id (a bare string, not an object). Resolve it with GET /companies/{id}.
etagDigest of the offering's public payload; changes when the offering changes.

Rate limits & token lifetime​

  • Explore/offerings/search: up to 60 requests/minute per IP, within the overall API limits of 1,000 req/hour per token and 5,000 req/hour per IP. See the checklist in Step 6.
  • Access token: 2 hours; re-mint via Step 1 as needed.

When you are throttled, the 429 tells you less than it looks like:

  • There is no Retry-After header. Read X-RateLimit-Limit, X-RateLimit-Remaining (always 0 on a 429), and X-RateLimit-Reset.
  • X-RateLimit-Reset and error.details.retry_after are both the full window length added to the time of the rejected request (60 seconds on the explore cap, 3,600 on the hourly ones), not the moment the window actually resets. The windows are fixed clock buckets, so the real reset is the next boundary: period - (now mod period) seconds, which is at most retry_after and usually much less. Sleeping the full retry_after works but over-sleeps.

Errors​

StatusMeaning
401Missing, invalid, or expired token. Mint a new one.
404Unknown offering or company id, or one not publicly visible. On /disclosures, error.type: no_disclosures means the company exists but has no public Form C.
429Rate limited; see above for what the response does and does not tell you.

All errors use the standard envelope: { "error": { "type": "...", "message": "...", "request_id": "..." } }.

Logged-in view: read:explore​

Everything above is the public view. If your app lets a user connect their Wefunder account (Authorization Code + PKCE), request the read:explore scope and call the same endpoints with that user's access token. You get what that user sees on wefunder.com/explore:

  • every public offering, unchanged, plus offerings only they qualify for — accredited-only / Vault deals, private rounds they were invited to. A round limited to qualified purchasers (a 3(c)(7) fund) appears only to a viewer whose accreditation is qualified purchaser; an invite link does not bypass that;
  • two extra attributes on each offering: publicly_visible (false when an anonymous visitor could not see it) and invested (the user has an active investment in it);
  • meta.personalized: true so you can tell which view you got;
  • on the company tabs, the posts and questions the site shows that viewer (a follower or investor sees community- and investors-only posts).

The public response is a strict subset — same ids, same field meanings — so one client can handle both. read:explore is user-scoped: a server-side key cannot hold it, and a token that carries it without a resolvable user falls back to the public view. This is the view Wefunder's own MCP server exposes to AI agents (ChatGPT, Claude, etc.) through its explore_offerings tool.

Not yet available​

These are on the roadmap but not implemented yet — don't build against them:

  • updated_since change-polling — today you re-page the list and dedupe with etag.
  • HTTP ETag / If-None-Match → 304 — etag is provided as a field for client-side dedup, but conditional requests aren't supported.
  • per_page control: the page size is fixed at 25.

Next steps​