Official Ruby SDK for the Wefunder API.
- A
Wefunder::Clienton Faraday with a generated, typed layer for every public operation - OAuth 2.0:
client_credentialsfor server-to-server,authorization_code+ PKCE for user consent, refresh-token rotation handled for you - Automatic retries (rate limits, transient errors) and one-shot token recovery on 401
- Lazy auto-pagination over opaque cursors
- Typed errors with the
request_idsupport asks for - Webhook signature verification, envelope parsing, and dispatch
Requires Ruby 3.2+.
gem install wefunder
# or in a Gemfile:
gem "wefunder", "~> 1.0"The SDK follows semantic versioning from 1.0.0: breaking changes only in a new major version, announced in the changelog.
require "wefunder"
wf = Wefunder::Client.from_client_credentials(
client_id: ENV.fetch("WEFUNDER_CLIENT_ID"),
client_secret: ENV.fetch("WEFUNDER_CLIENT_SECRET"),
scopes: ["read:public"]
)
page = wf.offerings.listClient-credentials tokens represent the application, not a user. They cannot call user-scoped
endpoints such as wf.users.me or wf.portfolio.get. The SDK mints a new token automatically when a
client-credentials token expires.
Generate the authorization URL on your server; store state and the PKCE verifier in the user's
session before redirecting:
pkce = Wefunder.generate_pkce
state = SecureRandom.urlsafe_base64(32)
save_oauth_attempt(state: state, code_verifier: pkce.code_verifier)
url = Wefunder.create_authorization_url(
client_id: client_id, redirect_uri: redirect_uri, scopes: ["read:investments"], state: state, pkce: pkce
)The consent host is chosen from the client_id: pk_test_ ids go to the sandbox, everything else to
wefunder.com. On the callback, validate state and exchange the code:
attempt = consume_oauth_attempt(state)
tokens = Wefunder.exchange_code(
client_id: client_id, client_secret: client_secret, # omit for public clients
code: params[:code], redirect_uri: redirect_uri, code_verifier: attempt.code_verifier
)
wf = Wefunder::Client.new(tokens: tokens, client_id: client_id, client_secret: client_secret, store: TokenStore.new)Refresh tokens rotate. When the SDK refreshes an access token it calls store.save(tokens) with the
new set before retrying the request. Persist the entire token set each time:
class TokenStore
def save(tokens)
DB.save_tokens(tokens.access_token, tokens.refresh_token, tokens.expires_at)
end
endThe rotated set is saved before any thread can use it. If store.save or on_token_refresh raises, the SDK raises
Wefunder::TokenPersistenceError and keeps the rotated set pending: no request is made with it, the
consumed refresh token is never reused, and the next call retries the save (or persist
error.tokens yourself and call wf.token_manager.mark_persisted!(error.tokens); a stale acknowledgment after another rotation is a no-op). Alert on this error; a process
that keeps failing to save cannot reconnect after a restart.
Concurrent requests that hit a 401 at the same time share one refresh (the client is thread-safe). If several application instances can use the same OAuth connection, serialize refreshes for that connection yourself.
The SDK sends Wefunder-Version: 2026-10-15 on every request (DEFAULT_API_VERSION); pass
api_version: to pin an older one. 1.1.0 moved the default from the pre-launch 2025-01-15 alias:
GET /activity and the syndicate deal close / finalize operations (and the
syndicates.close_deal / finalize_deal intent actions) are not part of 2026-10-15, so their
generated methods are gone; the legacy Attribution and attribution-webhook operations, removed
from the API on 2026-10-14, left with them (wf.attribution is gone; use wf.referral_codes).
offerings = wf.offerings.list(sort: "newest")
investments = wf.investments.list(company_id: "co_example")
portfolio = wf.portfolio.getNamespaces: users, offerings, investments, portfolio, campaigns, syndicates, intents,
installations, member_links, referral_codes, join_sessions, investment_sessions, and
webhook_endpoints. Query parameters are keyword arguments;
enum-typed ones take plain strings, and Time / DateTime / Date values are sent as ISO 8601.
wf.installations lets your app act as a company or syndicate: eligible_targets, create,
mint_token, list, get, revoke, and install_or_mint_token, which handles the API's 409
already_installed answer by minting a token for the existing install with the same scopes.
wf.member_links mints your app's per-person links to one deal page and reports what each member did
(list, all, create, get, cancel; needs a company- or syndicate-owned token). wf.referral_codes
(beta) creates codes with your server key and reads per-company stats (list, all, get, create,
update, stats). wf.join_sessions (beta) mints hosted syndicate join sessions (list, all, get,
create; the API publishes no schema for these yet, so they come back as string-keyed Hashes), and
wf.investment_sessions (beta) puts a known investor straight into checkout for one offering (list,
all, list_for_offering, all_for_offering, get, create, cancel).
wf.investments is the Investment Delta API. list without a cursor bootstraps; pass updated_since:
or the meta.next_cursor you saved from your last page to receive only records that changed since then.
next_cursor is always present, even on the final page, so persist it after every sync.
The API base URL is https://api.wefunder.com. Paths are version-free; the SDK sends the API version in
the Wefunder-Version request header.
page = wf.offerings.list(sort: "newest") # one page, and its cursor
puts page.meta.next_cursor
wf.offerings.all(sort: "most_raised").each do |offering| # every item, lazily (an Enumerator)
puts offering.id
end
investments = wf.investments.collect # everything in one ArrayCursors are opaque. Pass the value returned by the API without modifying it.
API failures raise Wefunder::Error:
begin
wf.syndicates.get("syn_aB3xQ9k2vF8mNp1zT5wY7Qc4")
rescue Wefunder::Error => e
puts e.status, e.type, e.message, e.request_id, e.remediation
endRetries are bounded (retry_options: max_retries, default 2) and differ by failure: a 429 is
retried for any method after waiting out X-RateLimit-Reset; transient network errors and 5xx
responses are retried only for idempotent methods (GET, HEAD, OPTIONS); a 401 is recovered
once, for any method, by refreshing or re-minting the token and resending. Size request timeouts with
the rate-limit wait in mind, since rate limiting alone can attempt a write up to three times, and the
one-time 401 recovery can add one more. Exhausted
network failures raise Wefunder::Error with status 0 and type "network_error", from namespaces
and request alike.
timeout: (seconds, default 30) applies to every request the client makes: API calls, request, and
the OAuth token round-trips for refresh and re-mint.
Endpoints belong to your application and are managed through the live API (scope write:webhooks).
The signing secret is returned only on create and rotate, so store it immediately.
endpoint = wf.webhook_endpoints.create(
url: "https://yourapp.com/webhooks/wefunder", # public HTTPS; localhost and private IPs are rejected
events: ["offering.opened", "investment.executed"],
mode: "live" # "test" endpoints receive sandbox events
)
save_secret(endpoint.attributes.secret)wf.webhook_endpoints also provides list, get, update, remove, rotate_secret, reenable,
and test.
Pass the raw request body, the headers (a Rack env works), and your secret to
Wefunder.construct_event. It raises Wefunder::WebhookSignatureError (with a reason) when a
delivery is not authentic.
post "/webhooks/wefunder" do
begin
event = Wefunder.construct_event(request.body.read, request.env, WEBHOOK_SECRET)
rescue Wefunder::WebhookSignatureError => e
halt 400, e.reason
end
Wefunder.dispatch_webhook(event, {
"investment.executed" => ->(e) { record_funding(e.data["id"], e.data.dig("amounts", "committed")) },
"offering.opened" => ->(e) { announce(e.data.dig("company", "name")) },
default: ->(e) { logger.info("unhandled #{e.event}") }
})
200
endDeliveries are at-least-once and unordered. Deduplicate on event.id, and where a payload carries
occurred_at, keep the state from the latest one you have seen.
wf.webhook_endpoints.test(endpoint.id) sends a real, signed example event and reports the outcome.
To unit-test your handler without the API, sign a fixture yourself:
header = Wefunder.sign_webhook(body, secret)
# POST `body` to your handler with `Wefunder-Signature: <header>`Each delivery carries Wefunder-Signature: t=<unix seconds>,v1=<hex> where v1 is
HMAC-SHA256(secret, "<t>.<raw body>"). Requests whose t is more than five minutes from now are
rejected (tolerance_seconds: adjusts this). During a secret rotation the header carries one v1 per
active secret and construct_event accepts either. Wefunder.verify_webhook and
Wefunder.check_webhook_signature expose the check without parsing, and construct_event still accepts
the retired attribution headers (X-Wefunder-Signature / X-Wefunder-Timestamp).
Typed namespaces cover the common resources. Every operation in the public OpenAPI specification is
generated under WefunderGenerated::<Tag>Api and reachable through wf.raw.<tag>; wrap the call to get
the same auth, retries, and typed errors:
members = wf.wrap { wf.raw.syndicate_members.list_syndicate_members("syn_aB3xQ9k2vF8mNp1zT5wY7Qc4") }For a path the generated layer does not know yet, wf.request(:get, path, query:, body:, headers:)
sends a fully-wrapped request and returns the decoded JSON.
bundle install
bundle exec rubocop
bundle exec rspec # hermetic — live examples are tagged :e2e and filtered out
WEFUNDER_E2E=1 bundle exec rspec spec/e2e # live sandbox; also needs WEFUNDER_CLIENT_ID / WEFUNDER_CLIENT_SECRETThe live group is opt-in by tag, not by path: without WEFUNDER_E2E=1 it never runs, even when sandbox
credentials happen to be exported in your shell.
Generated files in lib/wefunder_generated/ come from openapi/openapi.yaml via script/generate
(openapi-generator in Docker; no Java needed) and are never edited by hand.
conformance/*.json is the cross-language behavioural contract shared with wefunder-node and
wefunder-python: signatures, token rotation, pagination, retries, errors. spec/conformance_spec.rb
runs every case. The files are vendored from Wefunder/wefunder-node at the ref in conformance/PIN;
refresh with ruby script/sync_conformance.rb [ref]. Never edit a vector to make the shell pass.
examples/ is the source of truth for the Ruby snippets on docs.wefunder.com; see
examples/README.md. After adding one, run ruby script/build_examples_manifest.rb and remove its
operationId from examples/coverage-allowlist.json.
script/sync_spec /path/to/wefunder # public-tier spec → openapi/openapi.yaml
script/generate # → lib/wefunder_generated
bundle exec rspecCommit the specification and generated client together.
Set the version in lib/wefunder/version.rb (e.g. 1.1.0; a prerelease is 1.2.0.beta1), run
bundle install so Gemfile.lock follows, rebuild examples_manifest.json, commit, then:
git push origin main
git tag v1.1.0
git push origin v1.1.0 # push the tag explicitly: `--follow-tags` only pushes annotated tagsThe release workflow verifies the tag, runs the checks, and publishes to RubyGems via trusted publishing.