> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zeroclick.ai/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.
</Warning>

## 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`](/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.
</Note>

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](/sdks/ruby/middleware).

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