> ## Documentation Index
> Fetch the complete documentation index at: /docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Ruby SDK errors

> What raises, what returns a decision, and how zeroclick-sellers classifies allowance-API failures.

The Ruby SDK draws one line: **an expected event is a value, a mistake is an exception.**

A request with a bad signature, or a buyer who cannot pay, is an expected event — the SDK returns a decision carrying the exact response to send. `ZeroClick::Sellers::Error` is reserved for what you got wrong, or what the environment did.

## Decisions, not exceptions

```ruby theme={null}
decision = SELLER.guard(request, service_slug: "extractor", usage: usage)

unless decision.allow?
  decision.reason   # why
  decision.response # what to return
end
```

| Reason | Status |
| - | - |
| `missing_signature`, `malformed_signature`, `stale_timestamp`, `missing_request_id`, `unknown_kid`, `invalid_signature` | `401` |
| `identity_required` | `402`, empty usage |
| A usage denial reason (below) | `402` with the payment challenge |
| `allowance_unavailable` | `503`, only under the `"deny"` policy |

### Usage denial reasons

`service_not_found`, `access_not_found`, `access_inactive`, `plan_expired`, `meter_not_found`, `meter_not_priced`, `usage_exhausted`.

Any other reason on the wire is treated as a malformed response rather than passed through — a seller matching on an unknown reason would silently take the wrong branch.

## `ZeroClick::Sellers::Error`

```ruby theme={null}
begin
  SELLER.report_usage(...)
rescue ZeroClick::Sellers::Error => e
  e.code      # a stable machine code — match on this, never the message
  e.operation # "report_usage"
  e.context   # { operation: …, status: 402, reason: "usage_exhausted" }
end
```

`ZeroClick::Sellers.error?(e, "api_status_error")` is the same check as a predicate.

| Code | Meaning |
| - | - |
| `malformed_input` | You passed something invalid. A bug in your integration. |
| `signing_secret_resolution_failed` | Your `resolve_signing_secret` raised, or returned an empty secret. |
| `api_transport_error` | The ZeroClick API could not be reached. |
| `api_status_error` | The API answered with an unsuccessful status. |
| `api_response_invalid` | The API answered with something unparseable or off-contract. |

### Encryption error codes

`require "zeroclick/sellers/encryption"` adds its own codes on the same `ZeroClick::Sellers::Error`, with `operation` set to `decrypt_request` or `encrypt_response`:

| Code | Meaning |
| - | - |
| `invalid_compact_jwe` | The body is not a well-formed five-segment Compact JWE: wrong segment count, an undecodable protected header, or a parse failure. |
| `unsupported_jwe_suite` | The header names an algorithm outside the pinned suite (`ECDH-ES+A256KW` key management, `A256GCM` content encryption). |
| `jwe_kid_required` | The protected header carries no `kid`, so no private key can be selected. |
| `private_key_not_found` | Your `resolve_private_key` returned `nil` for the header's kid. |
| `private_key_resolution_failed` | Your `resolve_private_key` raised. |
| `invalid_reply_jwk` | The buyer's reply key is not a usable public P-256 JWK. |
| `private_reply_jwk` | The buyer's reply key carried private parameters, which a reply key must never do. |
| `decryption_failed` | The suite and key were right, but decryption failed. |
| `encryption_failed` | Encrypting the reply to the buyer's key failed. |

These are raised, not returned: an undecryptable body is not a routine refusal the way an unsigned request is, so it surfaces as an exception for you to map to a response. Decide deliberately whether that response is encrypted — if the failure was in reading the header, there is no reply key to encrypt to.

### Stateful refusal codes

The stateful rail answers `401` with one of exactly four codes, which are response bodies rather than exceptions:

| Code | Meaning |
| - | - |
| `missing_or_malformed_signature` | No `zc-signature`, or one that does not parse. |
| `stale_timestamp` | Outside the skew window (default 300s). |
| `unknown_kid` | The kid names no secret you hold. |
| `invalid_signature` | The HMAC did not match. |

Deliberately fewer codes than the proxy rail: a caller learning *why* its signature failed learns something about the secret.

> [!WARNING]
>
> Match on `code`, never on the message. Messages are written for humans reading logs and will change.

## Which failures the outage policy covers

Only these mean *"the allowance API did not give us an answer"*:

`api_transport_error`, `api_status_error`, `api_response_invalid`.

Only they are subject to [`allowance_unavailable_policy`](/docs/sdks/ruby/configuration#the-allowance-unavailable-policy). Anything else — a `malformed_input` from your own call — propagates under every policy, because a bug in your integration must not be laundered into a fail-open allow.

> [!NOTE]
>
> This is where the SDKs legitimately differ. The Go SDK treats any 4xx from the allowance API (a revoked key, an unknown service) as a hard error, never an outage. The Ruby, TypeScript and Python SDKs route API errors through the policy. A revoked key under a `"allow"` policy therefore serves unbilled work in Ruby, and refuses in Go — set the policy to `"deny"` or `"throw"` if that matters to you.

The policy is applied **only after a signature verifies**, so a fail-open allowance policy never becomes a fail-open signature policy.

## Errors raised at construction

Invalid configuration raises immediately rather than at the first request:

* no credential (neither `api_key` nor the usage keys)
* both or neither of `signing_secrets` and `resolve_signing_secret`
* an empty `signing_secrets`
* an `allowance_unavailable_policy` that is not `"allow"`, `"deny"` or `"throw"`

`Middleware::Meter` likewise raises at wire-up when given a `max_quantity` item, because it would otherwise bill zero on a delivered `200`. See [middleware](/docs/sdks/ruby/middleware.md).

## Errors that are not raised

The SDK never retries and never invents an idempotency key. A failed `report_usage` is yours to retry, with the same key — which is exactly why the key must be derived from `zc_request_id` rather than generated.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.