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 charactersto must be at least 3 characters/to must be at most 320 charactersto must be a valid email addresssubject must be a non-empty stringone of bodyText or bodyHtml is requiredthread.references must contain at most 100 valuesthread.references header must be at most 8192 characterswaitTimeoutMs 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 descriptioncode, stable error code from the API'serror.codefieldstatus, the HTTP statusgates, gate-denial details, when the server attached themrequestId, for support correlationretryAfter, parsed from theRetry-Afterheader, when presentdetails, 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.stringifyround 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-secretand 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 useprimitive.receive(request, { secret })with a standardRequestobject, 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
amountandamountUsdcset: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;amountUsdcvalues 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 likepayer_orginstead ofpayerOrgimmediately 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 ofid,network,expires_at,nonce_binding, or apayment_requirementsfield (maxAmountRequired,payTo,asset,extra.name/extra.version) is absent or the wrong shape.email challenge is missing or malformed: <field>: the same check for theX402EmailChallengeshape returned bycreateEmailChallenge/parseEmailChallengeFromPart, plus a consistency check that the envelope'sinteraction_idmatcheschallenge.nonce_binding.interaction_id.interaction.json part is not a valid x402 challenge: <field>:parseEmailChallengeFromPartrejects a part that isn't thex402.paymentprotocol'schallengestep, has the wrongprotocol_version, or has a malformedchallenge_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 prefixcomputePaymentValidityWindow: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 pinvalidBeforeSec/validAfterSecexplicitly withclamp: 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#
Use handleWebhookEvent and typed guards to branch on email, payment, and interaction deliveries from one endpoint.
Webhook Signature VerificationVerify the Primitive-Signature HMAC header manually when your framework doesn't hand you a standard Request.
Paying a ChallengeSign and submit payment for an x402 challenge with pay() and read the settlement receipt.
Generated API Client and Primitive MemoriesCall any Primitive HTTP endpoint directly and store, search, and delete durable JSON records.
Was this page helpful?