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.
Encrypted bodies
When ZeroClick encrypts a request body to your public key, decrypt it before doing anything else — the signature covers the ciphertext, so verify first, decrypt second.
require "zeroclick/sellers/encryption"
envelope = ZeroClick::Sellers.decrypt_request(
request.body,
resolve_private_key: ->(kid) { MY_JWKS[kid] }
)
envelope.plaintext # the decrypted body, UTF-8
envelope.cty # the plaintext's media type, when declared
envelope.reply_jwk # present when the buyer wants an encrypted reply
# Returns the response unchanged when no reply key was offered.
ZeroClick::Sellers.encrypt_response(response, envelope)
The suite is pinned to ECDH-ES+A256KW / A256GCM, and anything else is
refused rather than negotiated — accepting another algorithm is how an attacker
downgrades to one they can break. A reply JWK carrying private material
(d, p, q, …) is rejected outright with its own error code.
Still no runtime dependencies. This is built on OpenSSL from the standard
library; the one primitive it lacks, Concat KDF, is a short SHA-256 loop. Even
base64 is done with core pack/unpack1 rather than the base64 stdlib,
which stops being a default gem in Ruby 3.4.
Stateful sellers
If purchases provision a standing account — credits, a subscription, API keys —
rather than settling one call, require "zeroclick/sellers/stateful".
response = ZeroClick::Sellers::Stateful.handle_access_request(
request,
base_path: "/zeroclick/access",
remint_policy: "additive", # or "rotating" — one live key at a time
signing_secrets: ZeroClick::Sellers.secrets_from_env,
on_write: ->(input) { ... ZeroClick::Sellers::Stateful::WriteResult.new(lifecycle: "active") },
on_mint: ->(input) { ZeroClick::Sellers::Stateful::MintKey.new(api_key: "sk_...") }
)
# nil means the route is not ours — serve it normally.
return serve_normally if response.nil?
The credit arithmetic is cumulative, not incremental: both grants and reversals are monotonic totals, so a delivery that arrives twice, out of order, or after a gap converges on the same purse.
delta = ZeroClick::Sellers::Stateful::Entitlement.derive_credit_delta(stored, entitlement)
delta.outcome # "credit", "debit", "replay", or "no_credit_dimension"
delta.delta_usd # what to move
delta.next # persist ATOMICALLY with the purse, or a retry double-applies
Three properties worth internalising before you wire it up:
- The signature is a different one. Stateful requests sign a seven-field canonical string whose trailing purpose segment comes from the matched route. An ordinary proxy signature is invalid here, and vice versa — deliberately.
- A matched route always answers. A handler that raises becomes the
503ZeroClick retries, never an exception escaping into your request path. - Money is a six-decimal string, capped at 2^53−1. Ruby's Integer is arbitrary precision, so that ceiling is a contract rule here rather than a language limit — without it Ruby would accept amounts the TypeScript SDK cannot represent.
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.