Project

wefunder

0.0
The project is in a healthy, maintained state
Client for the Wefunder API: OAuth (PKCE + client credentials) with refresh rotation, retries, auto-pagination, typed errors, and webhook verification.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 1.0.1, < 3.0
~> 1.0
 Project Readme

wefunder

Official Ruby SDK for the Wefunder API.

  • A Wefunder::Client on Faraday with a generated, typed layer for every public operation
  • OAuth 2.0: client_credentials for 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_id support asks for
  • Webhook signature verification, envelope parsing, and dispatch

Requires Ruby 3.2+.

Install

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.

Authentication

Server-to-server

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.list

Client-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.

User authorization with PKCE

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

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
end

The 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.

API version

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).

Calling the API

offerings = wf.offerings.list(sort: "newest")
investments = wf.investments.list(company_id: "co_example")
portfolio = wf.portfolio.get

Namespaces: 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.

Pagination

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 Array

Cursors are opaque. Pass the value returned by the API without modifying it.

Errors and retries

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
end

Retries 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.

Webhooks

1. Register an endpoint

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.

2. Verify and handle deliveries

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
end

Deliveries 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.

3. Test your handler

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>`

Signature scheme

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).

Generated operations

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.

Development

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_SECRET

The 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 vectors

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

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.

Updating the API specification

script/sync_spec /path/to/wefunder   # public-tier spec → openapi/openapi.yaml
script/generate                      # → lib/wefunder_generated
bundle exec rspec

Commit the specification and generated client together.

Releasing

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 tags

The release workflow verifies the tag, runs the checks, and publishes to RubyGems via trusted publishing.