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 503 ZeroClick 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-signature before anything else. The signature is an HMAC-SHA256 over the raw request bytes.
  • Decisions, not exceptions. A guard returns Allow or Deny; Deny#response is ready to return. ZeroClick::Sellers::Error is 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 decoded PATH_INFO; behind a server that decodes, a route containing %2F cannot 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.