{"schema_version":"1.0","publisher":"Primitive SDKs","canonical_url":"https://test.abhinandan.one/node-sdk-agent-guide","markdown_url":"https://test.abhinandan.one/node-sdk-agent-guide.md","article":{"id":"f8aec5b7-a5ab-4e0f-a335-c6cde778b89a","article_slug":"node-sdk-agent-guide","parent_article_slug":null,"parent_article_title":null,"kind":"guide","published_at":"2026-08-11T18:54:57.472559+00:00","keywords":["@primitivedotdev/sdk agent guide","primitive.receive","createX402Client","handleWebhookEvent","isTrustedSender","client.memories"],"meta_description":"Lists every @primitivedotdev/sdk import path, method signature, and integration gotcha an AI coding agent needs to wire up email and x402 payments correctly.","og_image_url":null,"source_file_paths":["sdk-node/README.md","sdk-node/src/api/index.ts","sdk-node/src/webhook/index.ts","sdk-node/src/x402/client.ts","sdk-node/src/x402/sign.ts"],"recording_id":null,"replayable":false,"task_name":"Node.js SDK Agent Guide","category":"Node.js SDK","summary":null,"description":"A dense reference for AI coding agents wiring up @primitivedotdev/sdk: exact import paths, method signatures, and gotchas for email and x402 payments, so the first integration attempt compiles and works.","content_kind":"repo_page","content_markdown":"Use this page as a lookup table when generating code against `@primitivedotdev/sdk`. It assumes Node.js 22+ and TypeScript, and it favors exact signatures over prose. If you are a human skimming for concepts instead of wiring code, start at [What is the Primitive Node.js SDK?](node-sdk-overview) instead.\n\n<Tip>\n\nIf you're implementing the CLI or an operator script instead of application code, install `primitive` (`npm install -g primitive`), not this package. `@primitivedotdev/sdk` no longer ships a `primitive` bin.\n\n</Tip>\n\n## Install and authenticate\n\nInstall `@primitivedotdev/sdk` (the Node.js SDK package) with npm, then export your API key; the SDK requires Node.js 22 or newer.\n\n```bash\nnpm install @primitivedotdev/sdk\n```\n\nSet the API key from the dashboard:\n\n```bash\nexport PRIMITIVE_API_KEY=prim_test\n```\n\nEvery client constructor takes `apiKey` explicitly; nothing reads `process.env` for you except the `x402` client's `PRIMITIVE_API_KEY` fallback.\n\n## Import path map\n\n`@primitivedotdev/sdk` exposes six subpath entry points, and you should pick the narrowest one that covers what you're writing. Functions handlers in particular must avoid the root and `/webhook` entries, both of which pull `node:crypto` and break Workers-style bundles.\n\n| Import path | Use for | Runtime |\n|---|---|---|\n| `@primitivedotdev/sdk` (root) | `primitive.receive`, `primitive.client`, `primitive.x402` | Node only (uses `node:crypto`) |\n| `@primitivedotdev/sdk/webhook` | Low-level webhook verify/parse (`verifyWebhookSignature`, `handleWebhookEvent`, `handleWebhook`) | Node only |\n| `@primitivedotdev/sdk/api` | `PrimitiveApiClient`, `createPrimitiveClient`, `isTrustedSender`, `validateEmailAuth`, `normalizeReceivedEmail`, `EmailReceivedEvent` type | **Workers-safe** |\n| `@primitivedotdev/sdk/x402` | `createX402Client`, signing primitives, `parseEmailChallengeFromPart` | Node only |\n| `@primitivedotdev/sdk/contract` | `buildEmailReceivedEvent`, `buildEventFromParsedData` (fixture/producer tooling) | Node only |\n| `@primitivedotdev/sdk/parser` | Raw MIME parsing, address parsing | Node only |\n\n<Warning>\n\nInside a Primitive Function handler, import auth/normalization helpers from `@primitivedotdev/sdk/api`, never from the root or `/webhook`. The root and `/webhook` entries import Node's `crypto` module for signing helpers, which is unavailable in the Workers-style runtime Functions execute in.\n\n</Warning>\n\n## Core email flow\n\nNormalize an inbound delivery with `primitive.receive`, then act on it with `client.reply`, `client.forward`, or `client.send`. This is the whole default surface for app code.\n\n```typescript\n// app/api/inbound/route.ts\nimport primitive from \"@primitivedotdev/sdk\";\n\nexport const runtime = \"nodejs\";\nexport const maxDuration = 300;\n\nconst client = primitive.client({\n  apiKey: process.env.PRIMITIVE_API_KEY!,\n});\n\nexport async function POST(req: Request) {\n  const email = await primitive.receive(req, {\n    secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,\n  });\n\n  await client.reply(email, \"Thank you for your email.\");\n\n  return Response.json({ ok: true });\n}\n```\n\n`primitive.receive(...)` reads the body, verifies the `Primitive-Signature` HMAC header, and returns a normalized `ReceivedEmail`. Full field list and semantics: [Inbound and Outbound Email Model](email-model). Receiving mechanics specific to this SDK: [Receiving Inbound Email](node-sdk-receiving-email). Sending/reply/forward mechanics: [Sending, Replying, and Forwarding Email](node-sdk-sending-email).\n\n<Steps>\n\n<Step title=\"Construct the client\">\n\n```typescript\nconst client = primitive.client({ apiKey: process.env.PRIMITIVE_API_KEY! });\n```\n\n</Step>\n\n<Step title=\"Normalize the inbound webhook\">\n\n```typescript\nconst email = await primitive.receive(req, {\n  secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,\n});\n```\n\nIf your framework doesn't hand you a standard `Request`, use the explicit form instead:\n\n```typescript\nconst email = primitive.receive({\n  body: req.body,\n  headers: req.headers,\n  secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,\n});\n```\n\n</Step>\n\n<Step title=\"Act on it: reply, forward, or send\">\n\n```typescript\nawait client.reply(email, \"Thank you for your email.\");\nawait client.forward(email, { to: \"ops@example.com\", bodyText: \"Can you take this one?\" });\nawait client.send({\n  from: \"Support <support@example.com>\",\n  to: \"alice@example.com\",\n  subject: \"Hello\",\n  bodyText: \"Hi there\",\n});\n```\n\n</Step>\n\n</Steps>\n\n### Signatures you'll actually call\n\n```typescript\nclient.send(input: SendInput, options?: RequestOptions): Promise<SendResult>\nclient.reply(email: ReceivedEmail, input: ReplyInput, options?: RequestOptions): Promise<SendResult>\nclient.forward(email: ReceivedEmail, input: ForwardInput, options?: RequestOptions): Promise<SendResult>\n```\n\n`ReplyInput` is either a bare string (treated as `text`) or `{ text?, html?, from?, attachments?, wait? }`. **`subject` is not accepted on reply.** Gmail's Conversation View needs both a References match and a normalized-subject match to thread, so a custom subject silently breaks the thread for half the recipient population. Use `client.send(...)` when you need subject control.\n\n<Tip>\n\nBy default `send`, `reply`, and `forward` return as soon as Primitive accepts the message. Pass `wait: true` only when the caller needs the first downstream SMTP outcome before responding (see [wait mode](email-model)), and configure a request timeout long enough for SMTP delivery, typically 30 to 60 seconds. `waitTimeoutMs` defaults to 30000 and must be an integer between 1000 and 30000. Terminal `deliveryStatus` values are `delivered`, `bounced`, `deferred`, and `wait_timeout`.\n\n</Tip>\n\n## Request options (every client method)\n\nEvery client method takes an optional second argument, `RequestOptions`, carrying an abort signal, a per-call timeout in milliseconds, extra headers, and an idempotency key.\n\n```typescript\n// Type shape exported from @primitivedotdev/sdk\ninterface RequestOptions {\n  signal?: AbortSignal;          // cancels; surfaces as AbortError\n  timeout?: number;               // ms; composed with signal via AbortSignal.any\n  headers?: Record<string, string>; // merged on top of client headers, per-call wins\n  idempotencyKey?: string;        // sent as Idempotency-Key header\n}\n```\n\n```typescript\nawait client.send(\n  { from, to, subject, bodyText },\n  { signal: AbortSignal.timeout(15000), idempotencyKey: \"customer-key-abc123\" },\n);\n```\n\nFull reference: [Request Options and Idempotency](node-sdk-request-options).\n\n## Sender trust: two different questions\n\n`validateEmailAuth` answers \"was this email authenticated at all?\" and returns `legit` even for a domain an attacker registered. `isTrustedSender` answers \"did this come from the domain I expect?\", so anchor authorization decisions to it, not to the bare verdict.\n\n```typescript\nimport { isTrustedSender } from \"@primitivedotdev/sdk/api\";\n\nconst trust = isTrustedSender(email.raw, { domain: \"example.com\" });\n\nif (trust.trusted) {\n  // authenticated mail whose From address is @example.com\n} else if (trust.retryable) {\n  // transient DNS failure during DMARC evaluation: respond 5xx so\n  // webhook redelivery retries this email later\n} else {\n  console.warn(\"untrusted:\", trust.reason, trust.auth.reasons);\n}\n```\n\n`trusted` is true only when the verdict is `legit`, DMARC's evaluated domain equals `domain`, and the From header strict-parses to a single valid address in `domain`. Never authorize on `email.replyTarget` or `email.smtp.mail_from`, both are sender-controlled. Full detail: [Verifying Inbound Email Authenticity](node-sdk-email-authenticity).\n\n<Warning>\n\nDo not regex the raw `From` header yourself. `From: \"trusted@example.com\" <x@evil.com>` puts an allowlisted string in the display name while DMARC evaluates (and can pass for) `evil.com`. Use `isTrustedSender`.\n\n</Warning>\n\n## Webhook signature verification (manual path)\n\n`primitive.receive(...)` and `handleWebhook(...)` verify the `Primitive-Signature` HMAC header automatically, so manual verification is the exception. Reach for `verifyWebhookSignature` only when your framework doesn't give you a standard `Request` and you've already extracted the raw body and header yourself.\n\n```typescript\nimport { verifyWebhookSignature } from \"@primitivedotdev/sdk/webhook\";\n\nverifyWebhookSignature({\n  rawBody: rawBodyString,       // exact bytes before JSON.parse\n  signatureHeader: req.headers[\"primitive-signature\"] as string,\n  secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,\n});\n```\n\nWire format: `Primitive-Signature: t=<unix-seconds>,v1=<hex>`. Throws `WebhookVerificationError` on mismatch, expired timestamp, or malformed input; default replay tolerance is 300 seconds (override with `toleranceSeconds`). Full contract: [Webhook Events Overview](webhook-events). Manual verification deep dive: [Webhook Signature Verification](node-sdk-webhook-signing).\n\n<Tip>\n\nIntegrating with tooling that already expects `webhook-id`/`webhook-timestamp`/`webhook-signature`? Use [Standard Webhooks Signature Support](node-sdk-standard-webhooks) instead. It is the alternative scheme, not the default.\n\n</Tip>\n\n## Handling every webhook event family (not just email)\n\nCall `handleWebhookEvent`, which verifies the signature and returns the full typed event union for `email.*`, `payment.*`, and `interaction.x402.*` deliveries on one endpoint. The event name always lives in the `X-Webhook-Event` header, never reliably in the body (payment bodies carry it in `type`; interaction bodies carry no event field at all). Default to `handleWebhookEvent`, not the legacy `handleWebhook`, unless you are hard-typed to `email.received` only.\n\n```typescript\nimport {\n  handleWebhookEvent,\n  isPaymentSettledEvent,\n  isInteractionX402Event,\n} from \"@primitivedotdev/sdk/webhook\";\n\nconst event = handleWebhookEvent({\n  body: rawBodyString,\n  headers: req.headers,\n  secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,\n});\n\nif (isPaymentSettledEvent(event)) {\n  console.log(\"settled\", event.challenge_id, event.amount, event.settle_tx);\n} else if (isInteractionX402Event(event)) {\n  console.log(event.event, event.interaction);\n}\n```\n\n`handleWebhookEvent` returns an `UnknownEvent` (it does not throw) for event types it doesn't recognize; that is the forward-compatibility contract. Full event catalog and type guards: [Handling Payment and Interaction Webhook Events](node-sdk-webhook-events).\n\n## x402 payments\n\nConstruct the x402 client from the `x402` subpath, then call `registerPayoutAddress`, `charge`, and `pay`. The conceptual model (registration, charge, pay, settle) is owned by [x402 Payments Overview](x402-payments-overview); this section is signature lookup only.\n\n```typescript\nimport { createX402Client } from \"@primitivedotdev/sdk/x402\";\n\nconst x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! });\n```\n\n### Register payout address (payee, once)\n\n```typescript\nimport { createX402Client } from \"@primitivedotdev/sdk/x402\";\nimport { privateKeyToAccount } from \"viem/accounts\";\n\nconst x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! });\nconst payee = privateKeyToAccount(process.env.PAYEE_KEY as `0x${string}`);\nawait x402.registerPayoutAddress({ network: \"base-sepolia\", label: \"treasury\" }, { signer: payee });\n```\n\nOrg id is auto-resolved from the API key. Pass `org` only to override.\n\n### Charge (payee) / Pay (payer)\n\n```typescript\nconst challenge = await x402.charge({\n  amountUsdc: \"0.01\",      // human USDC, the documented easy path\n  network: \"base-sepolia\", // testnet default; \"base\" is mainnet\n  payerOrg: process.env.PAYER_ORG_ID,\n  description: \"API call\",\n});\n\nconst payer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`);\nconst receipt = await x402.pay(challenge, { signer: payer });\nconsole.log(receipt.status, receipt.settle_tx);\n```\n\nPass exactly one of `amountUsdc` or `amount` (base units, e.g. `\"10000\"`); passing both throws. `pay()` accepts any viem `LocalAccount`; it uses `signTypedData` internally.\n\n<Warning>\n\nEvery x402 method throws `X402Error`, never a bare fetch error. On `pay()`, `status === 0` means the request may never have reached the server, so the payment outcome is **indeterminate**, not failed. Don't retry blindly; check `getChallenge(id)` or the settlement webhook first.\n\n</Warning>\n\n### Email-native payments\n\nUse when the payment must ride a real email thread instead of an out-of-band challenge id.\n\n```typescript\nconst issued = await x402.createEmailChallenge({\n  from: \"payee@your-domain.example\",\n  to: \"payer@their-domain.example\",\n  amountUsdc: \"0.01\",\n  network: \"base-sepolia\",\n});\n```\n\n```typescript\nimport { parseEmailChallengeFromPart } from \"@primitivedotdev/sdk/x402\";\n\n// interactionPart is the body of the inbound `interaction.json` MIME attachment\nconst parsedChallenge = parseEmailChallengeFromPart(interactionPart);\n```\n\n```typescript\nimport { Buffer } from \"node:buffer\";\nimport primitive from \"@primitivedotdev/sdk\";\n\nconst mail = primitive.client({ apiKey: process.env.PRIMITIVE_API_KEY! });\nconst built = await x402.payEmailChallenge(parsedChallenge, { signer: payer });\n// built.json is the interaction.json bytes; attach it to a reply on the challenge email\nawait mail.reply(challengeEmail, {\n  text: \"Payment attached.\",\n  attachments: [{\n    filename: \"interaction.json\",\n    content_type: \"application/json\",\n    content_base64: Buffer.from(built.json, \"utf8\").toString(\"base64\"),\n  }],\n});\n```\n\n`payEmailChallenge` never sends anything itself; it only signs and returns the envelope plus its canonical JSON bytes. Full walkthrough: [Email-Native x402 Payments](node-sdk-x402-email).\n\n### Low-level signing primitives\n\nReach for these only when `pay()` doesn't fit your signing flow (hardware wallet, custom submission path):\n\n```typescript\nimport {\n  deriveEip3009Nonce,\n  computePaymentValidityWindow,\n  signInteractionPayment,\n  buildExactEvmPaymentPayload,\n} from \"@primitivedotdev/sdk/x402\";\n```\n\n`deriveEip3009Nonce` hashes `keccak256(lower(interactionId) || 0x00 || lower(challengeStepId) || 0x00 || rawNonceBytes)`; this byte layout is locked to a normative test vector the platform recomputes, so do not \"simplify\" it. `computePaymentValidityWindow` keeps at least 60 seconds of settlement headroom and clamps the total window to the 24-hour cap by default; pass `clamp: false` if you want an out-of-band pinned `validBeforeSec`/`validAfterSec` to throw instead of being clamped. Full reference: [Low-Level x402 Signing Primitives](node-sdk-x402-signing-primitives).\n\n### Spend policy\n\n```typescript\nawait x402.setSpendPolicy({ paused: false, max_per_payment: \"5000000\" });\nconst policy = await x402.getSpendPolicy();\nawait x402.listPayoutAddresses();\n```\n\nThe spend policy is the org-level guardrail on outbound x402 payments, explained in [x402 Payments Overview](x402-payments-overview); updates merge, so only the fields you pass change. Full detail: [Spend Policy and Payout Address Management](node-sdk-x402-spend-policy).\n\n## Generated API client and Primitive Memories\n\nReach for the generated API client (`PrimitiveApiClient`) only for operations the high-level `send`/`reply`/`forward`/`receive` surface doesn't cover: Primitive Memories, semantic search, and account or domain management.\n\n```typescript\nimport { createPrimitiveClient } from \"@primitivedotdev/sdk/api\";\n\nconst client = createPrimitiveClient({ apiKey: process.env.PRIMITIVE_API_KEY! });\n\nawait client.memories.set({ key: \"thread:latest\", value: { email_id: \"em_123\" } });\nconst memory = await client.memories.get(\"thread:latest\");\nconst page = await client.memories.search({ prefix: \"thread:\", includeValue: false });\nawait client.memories.delete(\"thread:latest\");\n```\n\n`client.memories.search` lists memories by key prefix; it is not free-text or semantic search, so use `client.semanticSearch(...)` for mail search. Memories default to org scope; explicit function scope needs the function id **UUID**, never the function name. Full reference: [Generated API Client and Primitive Memories](node-sdk-api-client).\n\n## Gotchas an agent will otherwise hit\n\nThese are the mistakes that compile cleanly and fail at runtime or on the wire.\n\n- **Wrong import for Functions handlers**: importing `isTrustedSender` or `validateEmailAuth` from the root `@primitivedotdev/sdk` (instead of `/api`) pulls `node:crypto` and breaks the Workers-style Function runtime. Always import auth/normalization helpers from `@primitivedotdev/sdk/api` in handler code.\n- **`reply()` with a `subject` field**: not a supported input; the type doesn't even include it. Use `send()`.\n- **Passing both `amount` and `amountUsdc`** to `charge()` or `createEmailChallenge()`: throws immediately, by design. Pick one.\n- **Treating `X402Error` with `status === 0` as a failed payment**: it means the request state is unknown, not that the payment definitely failed. Don't auto-retry `pay()` without checking.\n- **Building `interaction.json` by hand**: use `parseEmailChallengeFromPart` to read one and `payEmailChallenge` or `buildExactEvmPaymentPayload` to build one. The wire format has strict snake_case fields the platform re-validates.\n- **Assuming `client.memories.search` does text search**: it is prefix-only key search. Use `client.semanticSearch(...)` for content search.\n- **Forgetting `waitTimeoutMs` bounds**: the valid range is 1000 to 30000 milliseconds, and anything outside it throws a `TypeError` before the request is sent.\n- **CLI vs SDK package confusion**: `npm install @primitivedotdev/sdk` gets you the library; `npm install -g primitive` gets you the CLI. They are separate packages now.\n\n## Next steps\n\n<CardGroup cols={2}>\n\n<Card title=\"Sending, Replying, and Forwarding Email\" href=\"node-sdk-sending-email\">\n\nFull send/reply/forward parameter reference with attachments and threading control.\n\n</Card>\n\n<Card title=\"Handling Payment and Interaction Webhook Events\" href=\"node-sdk-webhook-events\">\n\nEvery typed event guard and the full WEBHOOK_EVENT_TYPES catalog.\n\n</Card>\n\n<Card title=\"Charging and Registering Payout Addresses\" href=\"node-sdk-x402-charging\">\n\nPayee-side x402 setup: register a payout address and create challenges.\n\n</Card>\n\n<Card title=\"Node.js SDK Errors\" href=\"node-sdk-errors\">\n\nEvery error class and code the SDK throws, with what triggers each.\n\n</Card>\n\n</CardGroup>","canonical_base_url":"https://test.abhinandan.one","seo_indexing_enabled":true,"last_modified":"2026-08-21T18:22:43.359885+00:00","video_url":null,"voiceover_url":null,"tools_used":[],"demonstrated_by":[],"steps":[],"related_links":[],"intro":null,"prerequisites":[],"verification":[],"troubleshooting":[],"suggest_edit_url":"https://github.com/abhi-browzer/primitive-sdks/edit/main/sdk-node/README.md","raise_issue_url":"https://github.com/abhi-browzer/primitive-sdks/issues/new?title=Docs+feedback%3A+Node.js+SDK+Agent+Guide&body=Page%3A+https%3A%2F%2Ftest.abhinandan.one%2Fnode-sdk-agent-guide","page_feedback_enabled":true,"verified_ref":null,"verified_at":"2026-08-11T18:38:45.205849+00:00"}}