---
title: "Python SDK Error Reference"
canonical: "https://test.abhinandan.one/python-errors-reference"
markdown_url: "https://test.abhinandan.one/python-errors-reference.md"
publisher: "Primitive SDKs"
kind: "reference"
content_type: "reference"
category: "Python SDK"
description: "Lists every error code PrimitiveAPIError, WebhookVerificationError, WebhookPayloadError, and X402Error can raise in the Python SDK, with the fix for each."
keywords: ["PrimitiveAPIError", "WebhookVerificationError", "WebhookPayloadError", "WebhookValidationError", "X402Error", "RawEmailDecodeError"]
last_modified: "2026-08-21T18:22:43.359885+00:00"
published_at: "2026-08-11T18:55:07.905324+00:00"
source_files:
  - "sdk-python/src/primitive/client.py"
  - "sdk-python/src/primitive/x402/client.py"
sections:
  - {anchor: "primitiveapierror", title: "PrimitiveAPIError"}
  - {anchor: "common-code-values-and-fixes", title: "Common `code` values and fixes"}
  - {anchor: "webhook-errors", title: "Webhook errors"}
  - {anchor: "webhookverificationerror", title: "`WebhookVerificationError`"}
  - {anchor: "webhookpayloaderror", title: "`WebhookPayloadError`"}
  - {anchor: "webhookvalidationerror", title: "`WebhookValidationError`"}
  - {anchor: "rawemaildecodeerror", title: "`RawEmailDecodeError`"}
  - {anchor: "x402error", title: "X402Error"}
  - {anchor: "status-0-client-side-conditions", title: "Status-0 (client-side) conditions"}
  - {anchor: "server-side-non-zero-status-conditions", title: "Server-side (non-zero status) conditions"}
  - {anchor: "next-steps", title: "Next steps"}
---

> Documentation index: https://test.abhinandan.one/llms.txt

# Python SDK Error Reference

Look up every error the Python SDK raises, PrimitiveAPIError fields, webhook verification/payload/validation codes, and X402Error status conditions, with the fix for each.

The Python SDK raises three error families: `PrimitiveAPIError` from the email client, the webhook error classes (`WebhookVerificationError`, `WebhookPayloadError`, `WebhookValidationError`, `RawEmailDecodeError`), and `X402Error` from every x402 method. All are plain Python exceptions you catch with `try`/`except`.

## PrimitiveAPIError

Raised by `client.send`, `client.reply`, `client.forward`, and `client.semantic_search` (and their `a*` async variants) whenever the API responds with a non-2xx status, or the response envelope is missing its `data` field.

```python
from primitive.client import PrimitiveAPIError, PrimitiveClient

client = PrimitiveClient(api_key="prim_test")

try:
    client.send(
        from_email="support@example.com",
        to="alice@example.com",
        subject="Hello",
        body_text="Hi there",
    )
except PrimitiveAPIError as err:
    print(err.status_code, err.code, str(err))
```

| Field | Type | Description |
|---|---|---|
| `status_code` | `int \| None` | HTTP status code. `None` when no response was ever parsed. |
| `code` | `str \| None` | Machine-readable error code from the API's `error.code` field (e.g. `validation_error`, `recipient_not_allowed`, `rate_limit_exceeded`). |
| `gates` | `list[dict] \| None` | Gate-denial details when a send is blocked by an authorization gate. Each entry carries `name`, `reason`, `subject`, `message`, and an optional `fix`. |
| `request_id` | `str \| None` | The API's request id, useful when filing a support ticket. |
| `retry_after` | `int \| None` | Seconds to wait before retrying, parsed from the `Retry-After` header. Present on `429` responses. |
| `details` | `dict \| None` | Additional structured context (e.g. `sent_email_id`, `required_entitlements`). |
| `payload` | `Any` | The raw parsed error response, or the fallback payload when parsing failed. |

`str(err)` returns the human-readable `message`.

### Common `code` values and fixes

| `code` | What triggered it | Fix |
|---|---|---|
| `validation_error` | Request body failed server-side validation (e.g. sending to an address that hasn't sent authenticated mail yet on the `agent` plan). | Check the error message for the specific field; adjust the request. |
| `recipient_not_allowed` | A gate denied the send (see `gates` for detail). | Inspect `err.gates[0]["reason"]` and `err.gates[0]["fix"]` for the exact remediation, e.g. wait for an inbound email from that address first. |
| `rate_limit_exceeded` | Exceeded the sliding-window rate limit (120 requests per 60 seconds per organization). | Back off for `err.retry_after` seconds before retrying. |
| `inbound_not_repliable` (HTTP 422) | `client.reply(...)` targeted an inbound row that cannot be replied to: it was rejected at ingestion, its content was discarded, or it has no recipient recorded. A missing `Message-Id` does not trigger this error; it only omits the threading headers. | Don't retry the reply; the message is not repliable. |

## Webhook errors

Raised by `primitive.receive`, `primitive.handle_webhook`, and `primitive.handle_webhook_event`. Full webhook verification mechanics are documented on [Verifying Webhook Signatures](https://test.abhinandan.one/python-webhook-verification.md) and [Handling Webhook Events](https://test.abhinandan.one/python-webhook-events.md); this section is the error lookup.

### `WebhookVerificationError`

Raised when the HMAC (or Standard Webhooks) signature check fails.

| `code` | Cause | Fix |
|---|---|---|
| `MISSING_SECRET` | `secret` was `None`, empty string, or empty bytes; or (Standard Webhooks) the secret is not valid base64 (with or without the `whsec_` prefix), or decodes to zero bytes. | Pass the real webhook secret from `GET /account/webhook-secret`, as a UTF-8 string, unmodified. |
| `INVALID_SIGNATURE_HEADER` | The `Primitive-Signature` header is missing the `t=`/`v1=` format, or (Standard Webhooks) `webhook-signature` is present but empty, or `webhook-id`/`webhook-timestamp` is missing, or `webhook-timestamp` is not a unix-seconds integer. | Confirm the header is forwarded to your handler unmodified. |
| `TIMESTAMP_OUT_OF_RANGE` | The signature timestamp is more than `tolerance_seconds` (default 300s) old, or more than 60 seconds in the future. | Check server clock sync; pass a larger `tolerance_seconds` only if you understand the replay-window tradeoff. |
| `SIGNATURE_MISMATCH` | The computed HMAC does not match any signature in the header. | Verify the secret matches your account, and that you're signing the raw request body, not a re-serialized `json.dumps()` of it. |

### `WebhookPayloadError`

Raised when the request body isn't parseable as the expected shape.

| `code` | Cause | Fix |
|---|---|---|
| `PAYLOAD_EMPTY_BODY` | The request body is empty. | Check your web framework passes the raw body through. |
| `JSON_PARSE_FAILED` | The body isn't valid JSON. | Check for truncation or double-encoding before it reaches the SDK. |
| `INVALID_ENCODING` | The body contains invalid UTF-8 bytes. | If the data is binary, base64-encode it first. |
| `PAYLOAD_UNDEFINED` | Nothing was passed to `parse_webhook_event(...)`. | Pass the parsed body explicitly. |
| `PAYLOAD_NULL` | The payload is `None`. | Check that your request-body variable is defined before parsing. |
| `PAYLOAD_IS_ARRAY` | The payload is a JSON array, not an object. | Webhook payloads are always objects; check upstream framing. |
| `PAYLOAD_WRONG_TYPE` | The payload (or a required nested field, e.g. `email.content.download.expires_at`) is missing or the wrong type. | Confirm the path named in the error message is present in the raw payload. |
| `PAYLOAD_MISSING_EVENT` | Neither the `X-Webhook-Event` header nor a top-level `event` field in the body identifies the event type. | Pass the `X-Webhook-Event` header through, or call `handle_webhook_event`, which reads it for you. |

### `WebhookValidationError`

Raised when a known event type (most commonly `email.received`) fails JSON Schema validation.

The error code is `SCHEMA_VALIDATION_FAILED`, and the message describes the schema violation. Fix: compare the payload against `json-schema/email-received-event.schema.json`, the canonical schema source referenced on [Webhook Events Overview](https://test.abhinandan.one/webhook-events.md).

### `RawEmailDecodeError`

Raised by `decode_raw_email` and `verify_raw_email_download`.

| `code` | Cause | Fix |
|---|---|---|
| `NOT_INCLUDED` | Raw content wasn't included inline; the error message includes the download URL. | Fetch `email.content.download.url` instead of decoding inline. |
| `INVALID_BASE64` | `email.content.raw.data` isn't valid base64. | Treat as a corrupted payload; do not retry decoding the same bytes. |
| `HASH_MISMATCH` | The decoded (or downloaded) bytes' SHA-256 doesn't match `email.content.raw.sha256`. | The content may be corrupted in transit; re-fetch or re-request delivery. |

## X402Error

Raised by every method on `primitive.x402.X402Client` (`charge`, `create_email_challenge`, `pay`, `pay_email_challenge`, `register_payout_address`, `get_challenge`, `get_spend_policy`, `set_spend_policy`, `list_payout_addresses`, `list_declined_payments`) on a client-side, transport, or non-2xx server error.

```python
from primitive import X402Error

try:
    x402.pay(challenge, signer=payer)
except X402Error as err:
    print(err.status, err.retry_after, err.body)
```

| Field | Type | Description |
|---|---|---|
| `status` | `int` | HTTP status, or `0` for a client-side/transport error that never reached the server. |
| `body` | `Any` | The parsed error envelope when present, or a truncated raw response body. |
| `retry_after` | `str \| None` | The `Retry-After` response header, when the server sent one. |

> **Warning:** On `pay()`, a `status == 0` error means the request may never have reached the server, the payment outcome is **indeterminate**. Do not blindly retry; check `get_challenge(id)` or the settlement webhook before resubmitting, since resubmitting a payment that actually landed risks a duplicate authorization attempt.

### Status-0 (client-side) conditions

These never reach the server. All are raised before any HTTP request is made.

| Message contains | Cause | Fix |
|---|---|---|
| `no API key configured` | Neither `api_key` nor `PRIMITIVE_API_KEY` was set. | Pass `api_key=` explicitly or export `PRIMITIVE_API_KEY`. |
| `unknown charge() option "..."` | A typo'd keyword argument to `charge()`. | Check the argument name against the documented `charge()` signature on [Creating and Paying Challenges](https://test.abhinandan.one/x402-payments-overview-04a296ff/python-x402-charge-and-pay.md). |
| `exactly one of \`amount\` ... or \`amount_usdc\`` | Both `amount` and `amount_usdc` were passed. | Pass exactly one. |
| `requires \`amount\` as a positive integer string ... or \`amount_usdc\`` | Neither `amount` nor `amount_usdc` was passed, or the value given failed validation. | Pass `amount_usdc="0.01"` (human USDC) or `amount="10000"` (base units). |
| `at most 6 decimals` | `amount_usdc` had more than 6 decimal places, was non-positive, or was malformed. | USDC has 6 decimals; use a value like `"0.01"`. |
| `requires a signer` | `pay()` or `pay_email_challenge()` was called with `signer=None` or a signer missing the required methods. | Pass a `PrivateKeySigner` or an object implementing `sign_typed_data`/`sign_message`. |
| `challenge is missing or malformed: <field>` | The challenge object passed to `pay()` is missing a required field (`id`, `network`, `expires_at`, `nonce_binding`, or a `payment_requirements` field). | Re-fetch the challenge with `get_challenge(id)` rather than hand-constructing one. |
| `email challenge is missing or malformed: <field>` | The challenge passed to `pay_email_challenge()` is missing a required field, or its `interaction_id` disagrees with `challenge.nonce_binding.interaction_id`. | Use `extract_email_challenge(...)` to build the challenge from the raw `interaction.json` part instead of constructing it manually. |
| `interaction.json part is not a valid x402 challenge: <field>` | `extract_email_challenge(...)` received a malformed or non-challenge envelope. | Confirm the attachment is the unmodified `interaction.json` part from the challenge email. |
| `already expired` | The challenge's `expires_at` (plus settlement margin) is in the past. | Request a fresh challenge; an expired challenge cannot be signed into a valid authorization. |
| `invalid expires_at` | The challenge's `expires_at` isn't a parseable timestamp. | Re-fetch the challenge rather than editing the field by hand. |
| `network mismatch` | The challenge's top-level `network` disagrees with `payment_requirements.network`. | Treat as a malformed challenge; re-fetch it. |
| `could not resolve your organization id` | `register_payout_address()` was called without `org=` and the account lookup returned no id. | Pass `org=` explicitly, or confirm your API key resolves to a valid organization. |
| `requires \`from_\`` / `requires \`to\`` | `create_email_challenge()` was called without one of the required email addresses. | Pass both `from_` and `to`. |
| request timed out / request failed | A DNS, connection, or TLS-level failure, or the request exceeded the client's timeout. | Retry with backoff; check network connectivity to `api.primitive.dev`. |
| non-JSON response / missing success/data envelope | The server returned something other than the expected `{"success": ..., "data": ...}` envelope. | Usually transient; retry. Persisting failures indicate an API-side issue. |

### Server-side (non-zero status) conditions

| `status` | Typical cause |
|---|---|
| `422` | Payment declined at settlement (e.g. `payment_declined` in the error message). |
| `429` | Rate limited; `retry_after` names the backoff window. |
| Other 4xx/5xx | See `err.body` for the server's `error.message`. |
