Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions examples/install_target.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Guide: act for a company or syndicate. `wf` holds a MANAGER's user token with
`read:installations write:installations` (listing needs the read scope; write does not imply
it). Install, then mint the installation's own token — it has no expiry and no refresh;
revoking the install revokes it."""

from __future__ import annotations

from typing import Any

from wefunder import Wefunder, WefunderError
from wefunder._generated.api.installations import (
create_installation,
create_installation_token,
list_eligible_install_targets,
)
from wefunder._generated.api.syndicate_deals import list_syndicate_deals
from wefunder._generated.models.create_installation_body import CreateInstallationBody


def example(wf: Wefunder, syndicate_id: str = "syn_abc123Example") -> Any:
# region guides/install-target
# 1. Which companies / syndicates may this user install on? (Only those — an investor's
# empty list is not a failure.)
targets = wf.call(list_eligible_install_targets, target_type="syndicate")
for target in targets.data:
print(target.id, target.name, "(already installed)" if target.installed else "")

# 2. Install. The response carries the install (`data`) AND its token. If the app is
# already installed here the API answers 409 `already_installed` — its
# `details["installation"]` is the existing install's id, so mint a fresh token for that
# instead. Any other error (revoked install, missing scope) still raises.
body = CreateInstallationBody.from_dict(
{"target_type": "syndicate", "target_id": syndicate_id, "scopes": ["read:syndicates"]}
)
try:
installed = wf.call(create_installation, body=body)
installation_token = installed.token.access_token
except WefunderError as err:
if err.type != "already_installed" or not isinstance(err.details, dict):
raise
existing_id = err.details.get("installation")
if not existing_id:
raise
minted = wf.call(create_installation_token, external_id=existing_id)
installation_token = minted.token.access_token # shown once — store it

# 3. First request AS the installation.
as_syndicate = Wefunder(access_token=installation_token)
deals = as_syndicate.call(list_syndicate_deals, syndicate_id=syndicate_id)
print(len(deals.data), "deals")
# endregion
return deals
23 changes: 23 additions & 0 deletions examples/list_company_investment_changes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""operationId: listCompanyInvestmentChanges — the company-scoped Investment Delta feed
(read:investments or read:companies; the user must be able to edit the company)."""

from __future__ import annotations

from wefunder import Wefunder
from wefunder._generated.api.investment_delta import list_company_investment_changes


def example(wf: Wefunder, company_id: str = "co_abc123Example") -> str | None:
# region listCompanyInvestmentChanges
# Walk every change for one company. Persist meta.next_cursor after each page — it is
# always present, even on the last page — and pass it back next sync for only what changed.
cursor: str | None = None
while True:
page = wf.call(list_company_investment_changes, company_id=company_id, cursor=cursor, per_page=50)
for record in page.data:
print(record.id, record.visible)
cursor = page.meta.next_cursor
if not page.meta.has_more:
break
# endregion
return cursor
55 changes: 55 additions & 0 deletions examples/user_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Guide: act on behalf of a user (authorization_code + PKCE). The consent screen IS the
installation on the user — there is nothing to call on /installations for this case.
Split across your redirect handler (step 1) and your callback handler (steps 2–3)."""

from __future__ import annotations

import os
import secrets
from collections.abc import Callable
from typing import Any

from wefunder import TokenSet, Wefunder, create_authorization_url, exchange_code, generate_pkce


def begin_authorization(save_attempt: Callable[[str, str], None]) -> Any:
# region guides/user-token
# 1. Send the investor to consent. Keep state + the PKCE verifier in their session.
pkce = generate_pkce()
state = secrets.token_urlsafe(32)
save_attempt(state, pkce.code_verifier)
authorization_url = create_authorization_url(
client_id=os.environ["WEFUNDER_CLIENT_ID"],
redirect_uri=os.environ["WEFUNDER_REDIRECT_URI"],
scopes=["read:investments"],
state=state,
pkce=pkce,
)
# redirect(authorization_url)

# 2. On the callback, exchange the code (after checking `state` matches the session).
tokens = exchange_code(
client_id=os.environ["WEFUNDER_CLIENT_ID"],
client_secret=os.environ.get("WEFUNDER_CLIENT_SECRET"), # omit for public clients
code="AUTHORIZATION_CODE",
redirect_uri=os.environ["WEFUNDER_REDIRECT_URI"],
code_verifier=pkce.code_verifier,
)

# 3. Read their holdings. The access token lasts two hours; the SDK rotates the refresh
# token for you and hands every new set to `store.save` — persist the whole thing.
wf = Wefunder(
tokens=tokens,
client_id=os.environ["WEFUNDER_CLIENT_ID"],
client_secret=os.environ.get("WEFUNDER_CLIENT_SECRET"),
store=TokenStore(),
)
portfolio = wf.portfolio.get()
print(portfolio.attributes.total_current_value_cents)
# endregion
return authorization_url


class TokenStore: # harness stand-in for your DB
def save(self, tokens: TokenSet) -> None:
del tokens
3 changes: 3 additions & 0 deletions examples_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
"getCurrentUser": "me = wf.users.me()\nprint(me.id, me.attributes.name)",
"getOffering": "try:\n offering = wf.offerings.get(offering_id)\nexcept WefunderError as err:\n # 404 → err.type == \"not_found\"; err.request_id is what support asks for.\n print(err.status, err.type, err.request_id)\n raise\nprint(offering.attributes.company_name, offering.attributes.status)",
"guides/client-credentials": "wf = Wefunder.from_client_credentials(\n client_id=os.environ[\"WEFUNDER_CLIENT_ID\"],\n client_secret=os.environ[\"WEFUNDER_CLIENT_SECRET\"],\n scopes=[\"read:public\"],\n)\n\nprint(\"mode:\", wf.mode) # \"live\" or \"test\", from the token prefix\n\n# client_credentials holds only read:public — browse public offerings.\n# (wf.users.me() would raise 403 insufficient_scope: it needs read:profile.)\nfor offering in wf.offerings.all():\n print(offering.id)",
"guides/install-target": "# 1. Which companies / syndicates may this user install on? (Only those — an investor's\n# empty list is not a failure.)\ntargets = wf.call(list_eligible_install_targets, target_type=\"syndicate\")\nfor target in targets.data:\n print(target.id, target.name, \"(already installed)\" if target.installed else \"\")\n\n# 2. Install. The response carries the install (`data`) AND its token. If the app is\n# already installed here the API answers 409 `already_installed` — its\n# `details[\"installation\"]` is the existing install's id, so mint a fresh token for that\n# instead. Any other error (revoked install, missing scope) still raises.\nbody = CreateInstallationBody.from_dict(\n {\"target_type\": \"syndicate\", \"target_id\": syndicate_id, \"scopes\": [\"read:syndicates\"]}\n)\ntry:\n installed = wf.call(create_installation, body=body)\n installation_token = installed.token.access_token\nexcept WefunderError as err:\n if err.type != \"already_installed\" or not isinstance(err.details, dict):\n raise\n existing_id = err.details.get(\"installation\")\n if not existing_id:\n raise\n minted = wf.call(create_installation_token, external_id=existing_id)\n installation_token = minted.token.access_token # shown once — store it\n\n# 3. First request AS the installation.\nas_syndicate = Wefunder(access_token=installation_token)\ndeals = as_syndicate.call(list_syndicate_deals, syndicate_id=syndicate_id)\nprint(len(deals.data), \"deals\")",
"guides/user-token": "# 1. Send the investor to consent. Keep state + the PKCE verifier in their session.\npkce = generate_pkce()\nstate = secrets.token_urlsafe(32)\nsave_attempt(state, pkce.code_verifier)\nauthorization_url = create_authorization_url(\n client_id=os.environ[\"WEFUNDER_CLIENT_ID\"],\n redirect_uri=os.environ[\"WEFUNDER_REDIRECT_URI\"],\n scopes=[\"read:investments\"],\n state=state,\n pkce=pkce,\n)\n# redirect(authorization_url)\n\n# 2. On the callback, exchange the code (after checking `state` matches the session).\ntokens = exchange_code(\n client_id=os.environ[\"WEFUNDER_CLIENT_ID\"],\n client_secret=os.environ.get(\"WEFUNDER_CLIENT_SECRET\"), # omit for public clients\n code=\"AUTHORIZATION_CODE\",\n redirect_uri=os.environ[\"WEFUNDER_REDIRECT_URI\"],\n code_verifier=pkce.code_verifier,\n)\n\n# 3. Read their holdings. The access token lasts two hours; the SDK rotates the refresh\n# token for you and hands every new set to `store.save` — persist the whole thing.\nwf = Wefunder(\n tokens=tokens,\n client_id=os.environ[\"WEFUNDER_CLIENT_ID\"],\n client_secret=os.environ.get(\"WEFUNDER_CLIENT_SECRET\"),\n store=TokenStore(),\n)\nportfolio = wf.portfolio.get()\nprint(portfolio.attributes.total_current_value_cents)",
"guides/webhook-receiver": "try:\n event = construct_event(raw_body, headers, secret)\nexcept WebhookSignatureError as err:\n return 400, err.reason # not authentic — don't process\n\n# Acknowledge first, then do the work. Deliveries are at-least-once and unordered:\n# deduplicate on event.id and, where a payload has occurred_at, keep the latest state.\ndef record_funding(e: WebhookEvent) -> None:\n print(\"funded\", e.data[\"id\"], e.data[\"amounts\"][\"committed\"])\n\ndispatch_webhook(\n event,\n {\n \"investment.executed\": record_funding,\n \"offering.opened\": lambda e: print(\"opened\", e.data[\"company\"][\"name\"]),\n \"default\": lambda e: print(\"unhandled\", e.event),\n },\n)",
"listCompanyInvestmentChanges": "# Walk every change for one company. Persist meta.next_cursor after each page — it is\n# always present, even on the last page — and pass it back next sync for only what changed.\ncursor: str | None = None\nwhile True:\n page = wf.call(list_company_investment_changes, company_id=company_id, cursor=cursor, per_page=50)\n for record in page.data:\n print(record.id, record.visible)\n cursor = page.meta.next_cursor\n if not page.meta.has_more:\n break",
"listInvestments": "# First sync: no cursor. Persist meta.next_cursor after EVERY page — it is always\n# present, even on the last one — and pass it back next time for only what changed.\npage = wf.investments.list(company_id=\"co_abc123Example\")\nfor record in page.data:\n print(record.id, record.visible)\ncheckpoint = page.meta.next_cursor\n\n# Or let the SDK walk every page (stops on has_more=false):\nchanged = wf.investments.collect(company_id=\"co_abc123Example\", updated_since=\"2026-09-01T00:00:00Z\")",
"listOfferings": "# Browse live offerings, sorted. The cursor is opaque and handled for you.\npage = wf.offerings.list(sort=\"most_raised\")\nfor offering in page.data:\n print(offering.id, offering.attributes.company_name)\n\n# Or stream every offering lazily, one page fetched at a time:\nfor offering in wf.offerings.all(sort=\"newest\"):\n print(offering.id)"
}
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ ignore = ["E731"]
pythonVersion = "3.11"
include = ["src/wefunder", "tests", "examples", "scripts"]
exclude = ["src/wefunder/_generated"]
extraPaths = ["scripts"]
extraPaths = ["scripts", "examples"]
typeCheckingMode = "standard"
reportMissingModuleSource = false

Expand Down
Loading
Loading