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.
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:
- Link back and attribute. Every offering you show links to its
attributes.urlon 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. - 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.
- Say "reserve" when it is a reservation. An offering with
attributes.testing_the_waters: truecollects 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:
- Authenticates as your application with a server-side key (no user login).
- Lists open offerings, one per company.
- Pages through the full catalog.
- Looks up individual deals by their stable id.
- Renders a company's page and its Overview, Details, Posts, and Ask tabs.
- 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:publicscope.
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_(orat_test_for sandbox apps). scope=read:publicis required — it's the only scope a server-side key may hold.expires_inis 7200 (2 hours) and there is no refresh token for this grant. When the token nears expiry — or when a request returns401— simply call this endpoint again.
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.
testing_the_waters on every cardA 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.
| Param | Values |
|---|---|
exemption | reg_cf or reg_d |
security | The instrument, e.g. safe, convertible_note, equity |
testing_the_waters | true or false |
max_min_investment | USD decimal; offerings whose minimum investment is at most this |
min_amount_raised | USD decimal; offerings that have raised at least this |
closing_before | ISO 8601 date |
industry | One or more of the site's industry tags |
business_model | One or more of the site's business-model tags |
followed | true 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=:
sort | Order |
|---|---|
most_raised (default) | Highest amount raised first |
closing_soon | Soonest funding close first |
newest | Most recently launched first |
most_investors | Highest investor count first |
curl "https://api.wefunder.com/explore?sort=closing_soon" \
-H "Authorization: Bearer $WF_ACCESS_TOKEN"
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"
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 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 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 atper_page=100. - 60 requests per minute per IP on
/explore,/offerings/{id},/offerings/{id}/stats, and/companies/searchcombined. 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_sincefilter on/explore, and no HTTPETag/If-None-Match→304. Theetagfield 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_raisedsort 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 byidacross 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
sortand 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 crossedmin_amount_raised, its close date moved pastclosing_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}returns200for 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 a200does not mean "still in the feed", and a404does not mean "closed"; it means "no longer public". Readattributes.statusfrom 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:
- Fetch
GET /offerings/{id}, through the same request pacer as the feed pages (next section). 404: the offering is no longer public. Mark the cardunavailableand stop showing it (you cannot say why).200withattributes.statusofclosedorcanceled: the round ended. Mark the card with that status and stop showing it as live.200withattributes.statusofopen(orupcoming): 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. AfterABSENCE_LIMITconsecutive complete polls without it, mark itdelisted.
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 returnshas_more: trueand a cursor. The client's second request carriesexemption,sort, andcursor; a client that sendscursoralone fails. A page whosemeta.sortis 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
200whose status isopen, counts one miss, and keeps the card asnot_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 goesnot_in_feed,not_in_feed,delisted; the record is never deleted and keeps the last detail payload andfetched_at. On the fourth poll it is on page 1: the state islistedagain with misses at 0, exactly one entry inchanged, 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 returns200withattributes.status: "closed". The loop marks the cardclosedon 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 = 40the 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 fake429carryingperiod: 60,retry_after: 60, andX-RateLimit-ResetofT + 60makes the client sleep until the next 60-second boundary afterT(at most 61 seconds) and retry; a429on 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
| Field | Notes |
|---|---|
id | The offering's stable ofr_ external id. |
type | Always "offering". |
attributes.company_name | Company name. |
attributes.tagline | Short pitch; may be null. |
attributes.url | Absolute Wefunder profile URL (environment-correct). Link every card here. |
attributes.card_image_url | Absolute explore card image URL, or null. |
attributes.logo_url | Absolute company logo URL, or null. |
attributes.exemption.family | reg_cf, reg_d, reg_a, reg_s, or ecsp. |
attributes.exemption.subtype | 506b / 506c for the reg_d family, otherwise null. |
attributes.exemption.label | Display string, e.g. Reg D 506(c). |
attributes.security_type | The instrument — safe, convertible_note, equity, priced_round, fund, … |
attributes.status | Lifecycle status: upcoming, open, closed, or canceled. Only open offerings appear in the feed; the detail endpoint returns the others too. |
attributes.testing_the_waters | true ⇒ a reservation round (no money changes hands). Use "reserve" copy, not "invest". |
attributes.funding_target / min_investment / amount_raised | USD as clean decimal strings (e.g. "50000"). |
attributes.investor_count | Distinct investor count (hellban-filtered); may be null. |
attributes.started_at / closed_at | ISO 8601 timestamps; closed_at may be null. |
company | The parent company's co_ external id (a bare string, not an object). Resolve it with GET /companies/{id}. |
etag | Digest 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-Afterheader. ReadX-RateLimit-Limit,X-RateLimit-Remaining(always0on a 429), andX-RateLimit-Reset. X-RateLimit-Resetanderror.details.retry_afterare 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 mostretry_afterand usually much less. Sleeping the fullretry_afterworks but over-sleeps.
Errors
| Status | Meaning |
|---|---|
401 | Missing, invalid, or expired token. Mint a new one. |
404 | Unknown 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. |
429 | Rate 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(falsewhen an anonymous visitor could not see it) andinvested(the user has an active investment in it); meta.personalized: trueso 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_sincechange-polling — today you re-page the list and dedupe withetag.- HTTP
ETag/If-None-Match→304—etagis provided as a field for client-side dedup, but conditional requests aren't supported. per_pagecontrol: the page size is fixed at 25.
Next steps
- Browse the full schema in the API Reference under Explore.
- Learn how access tokens and scopes work in Authentication and Scopes.