zeroclick-sellers
The ZeroClick billing guard for Ruby backends. It verifies that a request really came from ZeroClick, checks that the buyer can pay before you do the work, builds the refusal responses ZeroClick expects, and reports what was used.
Ships Rack middleware, so it works in Rails, Sinatra, Hanami and Roda alike — Rails is a Rack app.
# config.ru — or config/application.rb under Rails
require "zeroclick/sellers"
SELLER = ZeroClick::Sellers.create(
api_key: ENV.fetch("ZEROCLICK_API_KEY"),
signing_secrets: ZeroClick::Sellers.secrets_from_env
)
# The service slug is per-route, not per-client: one process can serve several.
use ZeroClick::Sellers::Middleware::Meter,
seller: SELLER,
service_slug: "extractor",
usage: [ZeroClick::Sellers::UsageItem.new(meter_slug: "requests", quantity: 1)]
run ->(env) { [200, { "content-type" => "application/json" }, ['{"ok":true}']] }
That is the whole integration for a fixed-price endpoint. The middleware
verifies the signature, refuses unpayable requests with a 402, and — only if
your app answers 2xx — reports the usage on the way out.
Install
gem "zeroclick-sellers"
Requires Ruby 3.1+. No runtime dependencies: the core is stdlib-only
(openssl, json, net/http), and the Rack middleware is duck-typed, so
adding this gem cannot change how anything else in your bundle resolves.
Configuration
SELLER = ZeroClick::Sellers.create(
api_key: ENV.fetch("ZEROCLICK_API_KEY"),
signing_secrets: ZeroClick::Sellers.secrets_from_env
)
ZEROCLICK_SIGNING_SECRETS is formatted kid1:secret1,kid2:secret2. It is a
map rather than a single secret because during a rotation both the old and the
new kid must verify, or every in-flight request fails.
| Option | Default | Why you would change it |
|---|---|---|
api_key |
— | A both-scopes credential. |
usage_read_key / usage_write_key |
falls back to api_key |
Split by direction when a separate worker reports usage. |
signing_secrets |
— | A kid => secret map. Exactly one of this or resolve_signing_secret. |
resolve_signing_secret |
— | A callable, when secrets live in a vault rather than the environment. |
allowance_unavailable_policy |
"allow" |
"deny" or "throw" if serving unbilled work during an outage is worse than refusing. |
check_timeout_seconds |
1.5 |
The allowance check sits in your request path. |
on_allowance_unavailable |
— | A callable, to alert on fail-open events. |
Fail open, but only after verification
If the allowance API cannot give an answer, allowance_unavailable_policy
decides: serve it ("allow", the default), refuse with 503 ("deny"), or
raise ("throw").
The policy is applied only after a signature verifies. A fail-open allowance policy never becomes a fail-open signature policy — an unsigned request is refused regardless.
An Allow reached this way reports allowance == "unavailable" rather than
"allowed", so you can log or meter it.
Rails
The Railtie is opt-in, so the gem stays usable without Rails on the load path.
# config/application.rb
require "zeroclick/sellers/railtie"
config.zeroclick.api_key = ENV.fetch("ZEROCLICK_API_KEY")
config.zeroclick.signing_secrets = ZeroClick::Sellers.secrets_from_env
# No seller: here. config.middleware.use evaluates its arguments in the
# Application class body, which runs BEFORE the initializer that reads
# config.zeroclick — so no client exists yet. Omit it and the middleware
# resolves ZeroClick::Sellers.seller on the first request instead.
config.middleware.use ZeroClick::Sellers::Middleware::Meter,
service_slug: "extractor",
usage: [ZeroClick::Sellers::UsageItem.new(meter_slug: "requests", quantity: 1)]
Verified against Rails 8.1: this boots, and an unsigned request is refused with
401 by the guard.
In development only, ActionDispatch::HostAuthorization sits at the top of
the stack and answers 403 before the guard is ever reached, so a local tunnel
or any non-localhost Host needs allowing:
config.hosts << "my-tunnel.ngrok.app" # development only
It is absent from the production stack, so a deployed app needs nothing here.
In a controller, the verified caller is on the request env:
zc = request.env["zeroclick.context"]
zc.zc_agent_id # the buyer
zc.zc_request_id # correlates with ZeroClick's logs — use it for idempotency
Guarding without the middleware
When the charge depends on the request, guard inside the action instead:
request = ZeroClick::Sellers::Middleware.request_from_env(env).first
decision = SELLER.guard(
request,
service_slug: "extractor",
usage: [ZeroClick::Sellers::UsageItem.new(meter_slug: "requests", quantity: pages)]
)
unless decision.allow?
return [decision.response.status, decision.response.headers, [decision.response.body]]
end
guard_identity is the same for a free endpoint that still needs to know which
buyer is calling. It makes no network call.
Reporting variable usage
Meter settles usage from what it declared, so it requires an explicit
quantity on every item and refuses the rest at wire-up. A max_quantity
ceiling — or an item with neither quantity nor ceiling — has no settled amount,
and inventing one would silently mis-bill a delivered 200. Both forms are
still fine for guard, which only asks whether the buyer could pay.
For work whose cost is unknown until it is done, declare the fixed part in the middleware and report the rest afterwards:
SELLER.report_usage(
zc_agent_id: zc.zc_agent_id,
idempotency_key: "extract:#{zc.zc_request_id}", # seller-owned, derived
service_slug: "extractor",
meter_slug: "tokens",
quantity: tokens_used
)
The idempotency key is yours to derive and must be stable for the request. The SDK never invents one and never retries on your behalf — a retry that changed the key would double-bill.
Behaviour worth knowing
- Verify first, always. Every entry point verifies
zc-signaturebefore anything else. The signature is an HMAC-SHA256 over the raw request bytes. - Decisions, not exceptions. A guard returns
AlloworDeny;Deny#responseis ready to return.ZeroClick::Sellers::Erroris reserved for what you got wrong or what the environment did. - Only delivered responses are billed. A non-2xx answer gets no
zc-usage. - The body still reads. Verification must consume
rack.input; the middleware replaces it with a fresh stream over the same bytes. It does not rely on#rewind, which Rack 3 no longer guarantees. - Bodies are bounded. 10 MiB by default (
max_body_bytes:). Verification covers the whole body, so it must be buffered — and an attacker needs no valid signature to make you buffer. - Raw targets matter. The signature covers the percent-encoded target. The
middleware prefers
REQUEST_URI(Puma sets it) over the decodedPATH_INFO; behind a server that decodes, a route containing%2Fcannot verify.
Development
bundle install
bundle exec rake test
The suite executes the shared wire vectors in test/vectors/, generated
independently of any SDK, so passing means this implementation matches the spec
rather than matching itself.
License
Apache-2.0.