Documentation Index: Fetch llms.txt first to discover every published page. This page is also available as Markdown at /node-sdk-errors.md.
Verified · 8/11/2026

Node.js SDK Errors

Look up what triggers each Node.js SDK error class, PrimitiveApiError, WebhookVerificationError, WebhookValidationError, WebhookPayloadError, and X402Error, and how to fix it.

All @primitivedotdev/sdk errors resolve to one of five classes: PrimitiveApiError (generated API + high-level client.send/reply/forward/memories/agent calls), WebhookVerificationError and WebhookPayloadError and WebhookValidationError (webhook parsing, thrown by handleWebhook/handleWebhookEvent/receive), and X402Error (the x402 client). Each carries a stable machine-readable code you can branch on instead of matching message text.

PrimitiveApiError: inbound_not_repliable (HTTP 422)#

client.reply(email, ...) throws when the inbound row isn't in a state Primitive can reply to: the email was rejected at ingestion, its content was discarded, or it has no recipient recorded.

A missing Message-Id header does not trigger this; it only omits the threading headers on the reply.

import { PrimitiveApiError } from "@primitivedotdev/sdk/api";

try {
  await client.reply(email, "Thanks for your email.");
} catch (err) {
  if (err instanceof PrimitiveApiError && err.code === "inbound_not_repliable") {
    // Fall back to client.send(...) with a fresh subject/thread instead.
  }
}

PrimitiveApiError: validation errors on send/forward#

Before any network call, client.send and client.forward validate their input locally and throw a TypeError (not PrimitiveApiError) for malformed fields:

  • from must be at least 3 characters / from must be at most 998 characters
  • to must be at least 3 characters / to must be at most 320 characters
  • to must be a valid email address
  • subject must be a non-empty string
  • one of bodyText or bodyHtml is required
  • thread.references must contain at most 100 values
  • thread.references header must be at most 8192 characters
  • waitTimeoutMs must be an integer / waitTimeoutMs must be between 1000 and 30000

Fix the input shape; these never reach the server, so retrying without a change repeats the same throw.

PrimitiveApiError from the generated API and client.memories#

Every non-2xx response from the generated API client, including client.memories.set/get/search/delete and client.agent.createAccount/claimStart/claimVerify, is unwrapped into a PrimitiveApiError with:

  • message, human-readable description
  • code, stable error code from the API's error.code field
  • status, the HTTP status
  • gates, gate-denial details, when the server attached them
  • requestId, for support correlation
  • retryAfter, parsed from the Retry-After header, when present
  • details, additional structured context
import { PrimitiveApiError } from "@primitivedotdev/sdk/api";

try {
  await client.memories.get("thread:latest");
} catch (err) {
  if (err instanceof PrimitiveApiError) {
    console.error(err.code, err.status, err.requestId);
  }
}

A response with no data field (an empty-body success) throws Primitive API returned no <label>. That means the server returned 200 with a shape the SDK didn't expect, so check you're calling the right operation.

client.memories.set rejects non-JSON values#

Calling client.memories.set({ key, value }) with a value that isn't string | number | boolean | null | array | plain object throws a TypeError:

client.memories.set value must be a JSON value: string, finite number, boolean, null,
array, or plain object. Undefined, bigint, symbol, function, NaN, Infinity, sparse
arrays, class instances, and cyclic values are not valid memory values.

Strip or convert the offending field before calling set. This check runs client-side via isMemoryJsonValue (see Memory Value Validation Helper), so it fails before any network round trip.

client.memories.* rejects the generated-operation shape#

Passing { client, body } or { client, query } (the shape the raw generated setMemory/getMemory/etc. operations expect) into the high-level client.memories.set/get/delete/search throws a TypeError naming the correct call:

client.memories.set takes the memory fields directly; use client.memories.set({ key, value }),
not the generated operation options shape.

Use client.memories.search({ prefix }); it's key-prefix search, not free-text. For free-text mail search, use client.semanticSearch(...) instead.

WebhookVerificationError: MISSING_SECRET#

Thrown when the secret argument to verifyWebhookSignature, handleWebhook, handleWebhookEvent, or receive is empty, null, or omitted. Set PRIMITIVE_WEBHOOK_SECRET from your dashboard and pass it explicitly; there is no environment-variable fallback baked into the SDK.

WebhookVerificationError: INVALID_SIGNATURE_HEADER#

The Primitive-Signature header (or the Standard Webhooks webhook-signature header) is missing, empty, or doesn't match the expected format. For the default scheme the expected format is:

Primitive-Signature: t=<unix-seconds>,v1=<hex>

Check you're forwarding the raw header value verbatim, not stripping the t=/v1= parameters.

WebhookVerificationError: TIMESTAMP_OUT_OF_RANGE#

The delivery's timestamp is either more than toleranceSeconds (default 300s / 5 minutes) old, or more than 60 seconds in the future relative to your server clock. This is the replay-protection window described in webhook signature verification. If your server clock drifts, sync it with NTP; if you need a wider window for legitimate redelivery delays, pass a larger toleranceSeconds.

WebhookVerificationError: SIGNATURE_MISMATCH#

The computed HMAC-SHA256 doesn't match any signature in the header. The most common causes, in order of likelihood:

  • Re-serialized body. You must verify against the exact raw request bytes, before any JSON.parse/JSON.stringify round trip. If the SDK detects a pretty-printed body shape, the error message includes a specific hint: Request body appears re-serialized (pretty-printed). Use the raw request body before any json.loads() or json.dumps() calls.
  • Wrong secret. Fetch the current value from GET /account/webhook-secret and use it as a raw UTF-8 string. Do not base64-decode it, even though it looks base64-shaped.
  • Framework middleware already consumed the body as JSON before your handler saw the raw bytes. Configure your framework to expose the raw body (e.g. express.raw()), or use primitive.receive(request, { secret }) with a standard Request object, which reads the raw bytes for you.

WebhookPayloadError: PAYLOAD_EMPTY_BODY / PAYLOAD_NULL / PAYLOAD_UNDEFINED / PAYLOAD_IS_ARRAY / PAYLOAD_WRONG_TYPE#

Thrown while parsing the JSON body, before signature classification. Each name states the exact problem: an empty string body, a null/undefined body, an array instead of an object, or some other non-object type. Verify your framework is passing the actual request body through to handleWebhook/handleWebhookEvent, not an already-transformed value.

WebhookPayloadError: JSON_PARSE_FAILED#

The body isn't valid JSON. When the parser can locate the failure position, the message includes it (Invalid JSON at position 42...) with a hint that your framework may be truncating the body; otherwise it repeats the underlying JSON parser's message.

WebhookPayloadError: PAYLOAD_MISSING_EVENT#

Neither the X-Webhook-Event header nor a top-level event field in the body could classify the payload. A real Primitive delivery always sends the header, so seeing this means something upstream (a proxy, a test harness) stripped it. Pass the header through, or call handleWebhookEvent/receive, which reads it for you automatically.

WebhookValidationError: schema validation failed#

Thrown when a payload classified as email.received fails the canonical JSON Schema validation, for example a missing required field or a wrong type on a known property. Compare the payload against json-schema/email-received-event.schema.json (or run it through safeValidateEmailReceivedEvent, which returns a result object instead of throwing) to find the exact mismatched field.

handleWebhook throws on non-email.received events#

handleWebhook is hard-typed to email.received for backward compatibility. Any other event type reaching it (a payment.* or interaction.* delivery) surfaces as a payload error naming the unsupported event. Switch to handleWebhookEvent, which returns a typed union covering every event family plus UnknownEvent for forward compatibility. See handling payment and interaction webhook events.

X402Error: status: 0 (request never reached the server)#

Every x402 client method (charge, pay, createEmailChallenge, payEmailChallenge, registerPayoutAddress, setSpendPolicy,...) throws X402Error with status: 0 for anything that failed before getting an HTTP response: a rejected fetch (DNS, connection refused, TLS), a client-side timeout, or an SDK-side validation failure caught before the request was built.

On pay() specifically, a status: 0 error means the payment outcome is indeterminate: the request may or may not have reached the server. Do not blindly retry. Check getChallenge(id) or the settlement webhook before resubmitting, to avoid a duplicate authorization attempt.

import { X402Error } from "@primitivedotdev/sdk/x402";

try {
  await x402.pay(challenge, { signer: payer });
} catch (err) {
  if (err instanceof X402Error && err.status === 0) {
    // Outcome unknown. Check getChallenge(challenge.id) before retrying.
  }
}

X402Error: "no API key configured"#

Thrown by any X402Client method when neither apiKey was passed to createX402Client(...) nor PRIMITIVE_API_KEY is set in the environment. Set one of the two before calling charge, pay, or any other method.

X402Error: charge()/createEmailChallenge() amount errors#

  • Both amount and amountUsdc set: charge() takes exactly one of amount (base units) or amountUsdc (human USDC), not both. Pass exactly one.
  • Neither set, or malformed: charge() requires amount as a positive integer string in token base units (e.g. "10000"), or amountUsdc as a positive USDC amount with at most 6 decimals (e.g. "0.01"). USDC has 6 decimals; amountUsdc values with more than 6 decimal places, non-positive values, or non-numeric strings are all rejected before any network call.
  • Unknown option key: unknown charge() option "<key>"; expected one of: ... catches typos like payer_org instead of payerOrg immediately rather than silently dropping the field.

X402Error: challenge/email-challenge validation failures#

pay(), payEmailChallenge(), and parseEmailChallengeFromPart() validate the challenge shape before signing, so a missing field fails with a named error instead of an opaque signing exception:

  • challenge is missing or malformed: <field>: one of id, network, expires_at, nonce_binding, or a payment_requirements field (maxAmountRequired, payTo, asset, extra.name/extra.version) is absent or the wrong shape.
  • email challenge is missing or malformed: <field>: the same check for the X402EmailChallenge shape returned by createEmailChallenge/parseEmailChallengeFromPart, plus a consistency check that the envelope's interaction_id matches challenge.nonce_binding.interaction_id.
  • interaction.json part is not a valid x402 challenge: <field>: parseEmailChallengeFromPart rejects a part that isn't the x402.payment protocol's challenge step, has the wrong protocol_version, or has a malformed challenge_nonce/step_id.

Inspect the challenge object you're passing in; these are almost always the result of hand-constructing a challenge instead of using the object returned by charge()/createEmailChallenge()/parseEmailChallengeFromPart() unmodified.

X402Error: server rejects the payment (non-2xx with status set)#

A non-zero status on X402Error means the request reached the server and it rejected the payment; message and body carry the server's explanation (e.g. payment_declined, spend-policy caps exceeded, expired challenge). Check err.retryAfter for a Retry-After value if the server attached one, and see spend policy and payout address management if the rejection relates to caps or the allowlist.

X402Error: signing primitive errors#

Calling the low-level primitives directly (see low-level x402 signing primitives) throws plain Error, not X402Error, for malformed inputs:

  • deriveEip3009Nonce: challengeNonce must be exactly 64 lowercase hex chars (32 bytes), no 0x prefix
  • computePaymentValidityWindow: invalid validity window: validBefore (...) is below the minimum settlement headroom... (too close to expiry) or ...exceeds the ... window cap... the authorization window is too wide (too far in the future), both only thrown when you pin validBeforeSec/validAfterSec explicitly with clamp: false; the default behavior clamps into the accepted band instead of throwing.
  • buildExactEvmPaymentPayload: unsupported network <network>, or a malformed nonce/signature hex string.

These are programming errors in a custom signing flow, not something a caller retries. Fix the input and re-derive.

Next steps#

Was this page helpful?

© Primitive SDKs

Powered by Browzer