---
title: "Node.js SDK Agent Guide"
canonical: "https://test.abhinandan.one/node-sdk-agent-guide"
markdown_url: "https://test.abhinandan.one/node-sdk-agent-guide.md"
publisher: "Primitive SDKs"
kind: "guide"
content_type: "reference"
category: "Node.js SDK"
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."
keywords: ["@primitivedotdev/sdk agent guide", "primitive.receive", "createX402Client", "handleWebhookEvent", "isTrustedSender", "client.memories"]
last_modified: "2026-08-11T18:54:57.654078+00:00"
published_at: "2026-08-11T18:54:57.472559+00:00"
source_files:
  - "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"
sections:
  - {anchor: "install-and-authenticate", title: "Install and authenticate"}
  - {anchor: "import-path-map", title: "Import path map"}
  - {anchor: "core-email-flow", title: "Core email flow"}
  - {anchor: "step-construct-the-client", title: "Construct the client"}
  - {anchor: "step-normalize-the-inbound-webhook", title: "Normalize the inbound webhook"}
  - {anchor: "step-act-on-it-reply-forward-or-send", title: "Act on it: reply, forward, or send"}
  - {anchor: "signatures-youll-actually-call", title: "Signatures you'll actually call"}
  - {anchor: "request-options-every-client-method", title: "Request options (every client method)"}
  - {anchor: "sender-trust-two-different-questions", title: "Sender trust: two different questions"}
  - {anchor: "webhook-signature-verification-manual-path", title: "Webhook signature verification (manual path)"}
  - {anchor: "handling-every-webhook-event-family-not-just-email", title: "Handling every webhook event family (not just email)"}
  - {anchor: "x402-payments", title: "x402 payments"}
  - {anchor: "register-payout-address-payee-once", title: "Register payout address (payee, once)"}
  - {anchor: "charge-payee--pay-payer", title: "Charge (payee) / Pay (payer)"}
  - {anchor: "email-native-payments", title: "Email-native payments"}
  - {anchor: "low-level-signing-primitives", title: "Low-level signing primitives"}
  - {anchor: "spend-policy", title: "Spend policy"}
  - {anchor: "generated-api-client-and-primitive-memories", title: "Generated API client and Primitive Memories"}
  - {anchor: "gotchas-an-agent-will-otherwise-hit", title: "Gotchas an agent will otherwise hit"}
  - {anchor: "next-steps", title: "Next steps"}
---

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

# Node.js SDK Agent Guide

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.

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?](https://test.abhinandan.one/node-sdk-overview.md) instead.

> **Tip:** If 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.

## Install and authenticate

Install `@primitivedotdev/sdk` (the Node.js SDK package) with npm, then export your API key; the SDK requires Node.js 22 or newer.

```bash
npm install @primitivedotdev/sdk
```

Set the API key from the dashboard:

```bash
export PRIMITIVE_API_KEY=prim_test
```

Every client constructor takes `apiKey` explicitly; nothing reads `process.env` for you except the `x402` client's `PRIMITIVE_API_KEY` fallback.

## Import path map

`@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.

| Import path | Use for | Runtime |
|---|---|---|
| `@primitivedotdev/sdk` (root) | `primitive.receive`, `primitive.client`, `primitive.x402` | Node only (uses `node:crypto`) |
| `@primitivedotdev/sdk/webhook` | Low-level webhook verify/parse (`verifyWebhookSignature`, `handleWebhookEvent`, `handleWebhook`) | Node only |
| `@primitivedotdev/sdk/api` | `PrimitiveApiClient`, `createPrimitiveClient`, `isTrustedSender`, `validateEmailAuth`, `normalizeReceivedEmail`, `EmailReceivedEvent` type | **Workers-safe** |
| `@primitivedotdev/sdk/x402` | `createX402Client`, signing primitives, `parseEmailChallengeFromPart` | Node only |
| `@primitivedotdev/sdk/contract` | `buildEmailReceivedEvent`, `buildEventFromParsedData` (fixture/producer tooling) | Node only |
| `@primitivedotdev/sdk/parser` | Raw MIME parsing, address parsing | Node only |

> **Warning:** Inside 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.

## Core email flow

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

```typescript
// app/api/inbound/route.ts
import primitive from "@primitivedotdev/sdk";

export const runtime = "nodejs";
export const maxDuration = 300;

const client = primitive.client({
  apiKey: process.env.PRIMITIVE_API_KEY!,
});

export async function POST(req: Request) {
  const email = await primitive.receive(req, {
    secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
  });

  await client.reply(email, "Thank you for your email.");

  return Response.json({ ok: true });
}
```

`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](https://test.abhinandan.one/email-model.md). Receiving mechanics specific to this SDK: [Receiving Inbound Email](https://test.abhinandan.one/node-sdk-receiving-email.md). Sending/reply/forward mechanics: [Sending, Replying, and Forwarding Email](https://test.abhinandan.one/node-sdk-sending-email.md).

### 1. Construct the client

```typescript
const client = primitive.client({ apiKey: process.env.PRIMITIVE_API_KEY! });
```

### 2. Normalize the inbound webhook

```typescript
const email = await primitive.receive(req, {
  secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
});
```

If your framework doesn't hand you a standard `Request`, use the explicit form instead:

```typescript
const email = primitive.receive({
  body: req.body,
  headers: req.headers,
  secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
});
```

### 3. Act on it: reply, forward, or send

```typescript
await client.reply(email, "Thank you for your email.");
await client.forward(email, { to: "ops@example.com", bodyText: "Can you take this one?" });
await client.send({
  from: "Support <support@example.com>",
  to: "alice@example.com",
  subject: "Hello",
  bodyText: "Hi there",
});
```

### Signatures you'll actually call

```typescript
client.send(input: SendInput, options?: RequestOptions): Promise<SendResult>
client.reply(email: ReceivedEmail, input: ReplyInput, options?: RequestOptions): Promise<SendResult>
client.forward(email: ReceivedEmail, input: ForwardInput, options?: RequestOptions): Promise<SendResult>
```

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

> **Tip:** By 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](https://test.abhinandan.one/email-model.md)), 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`.

## Request options (every client method)

Every client method takes an optional second argument, `RequestOptions`, carrying an abort signal, a per-call timeout in milliseconds, extra headers, and an idempotency key.

```typescript
// Type shape exported from @primitivedotdev/sdk
interface RequestOptions {
  signal?: AbortSignal;          // cancels; surfaces as AbortError
  timeout?: number;               // ms; composed with signal via AbortSignal.any
  headers?: Record<string, string>; // merged on top of client headers, per-call wins
  idempotencyKey?: string;        // sent as Idempotency-Key header
}
```

```typescript
await client.send(
  { from, to, subject, bodyText },
  { signal: AbortSignal.timeout(15000), idempotencyKey: "customer-key-abc123" },
);
```

Full reference: [Request Options and Idempotency](https://test.abhinandan.one/node-sdk-request-options.md).

## Sender trust: two different questions

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

```typescript
import { isTrustedSender } from "@primitivedotdev/sdk/api";

const trust = isTrustedSender(email.raw, { domain: "example.com" });

if (trust.trusted) {
  // authenticated mail whose From address is @example.com
} else if (trust.retryable) {
  // transient DNS failure during DMARC evaluation: respond 5xx so
  // webhook redelivery retries this email later
} else {
  console.warn("untrusted:", trust.reason, trust.auth.reasons);
}
```

`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](https://test.abhinandan.one/node-sdk-email-authenticity.md).

> **Warning:** Do 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`.

## Webhook signature verification (manual path)

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

```typescript
import { verifyWebhookSignature } from "@primitivedotdev/sdk/webhook";

verifyWebhookSignature({
  rawBody: rawBodyString,       // exact bytes before JSON.parse
  signatureHeader: req.headers["primitive-signature"] as string,
  secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
});
```

Wire 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](https://test.abhinandan.one/webhook-events.md). Manual verification deep dive: [Webhook Signature Verification](https://test.abhinandan.one/node-sdk-webhook-signing.md).

> **Tip:** Integrating with tooling that already expects `webhook-id`/`webhook-timestamp`/`webhook-signature`? Use [Standard Webhooks Signature Support](https://test.abhinandan.one/node-sdk-webhook-signing/node-sdk-standard-webhooks.md) instead. It is the alternative scheme, not the default.

## Handling every webhook event family (not just email)

Call `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.

```typescript
import {
  handleWebhookEvent,
  isPaymentSettledEvent,
  isInteractionX402Event,
} from "@primitivedotdev/sdk/webhook";

const event = handleWebhookEvent({
  body: rawBodyString,
  headers: req.headers,
  secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
});

if (isPaymentSettledEvent(event)) {
  console.log("settled", event.challenge_id, event.amount, event.settle_tx);
} else if (isInteractionX402Event(event)) {
  console.log(event.event, event.interaction);
}
```

`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](https://test.abhinandan.one/node-sdk-webhook-events.md).

## x402 payments

Construct 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](https://test.abhinandan.one/x402-payments-overview.md); this section is signature lookup only.

```typescript
import { createX402Client } from "@primitivedotdev/sdk/x402";

const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! });
```

### Register payout address (payee, once)

```typescript
import { createX402Client } from "@primitivedotdev/sdk/x402";
import { privateKeyToAccount } from "viem/accounts";

const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! });
const payee = privateKeyToAccount(process.env.PAYEE_KEY as `0x${string}`);
await x402.registerPayoutAddress({ network: "base-sepolia", label: "treasury" }, { signer: payee });
```

Org id is auto-resolved from the API key. Pass `org` only to override.

### Charge (payee) / Pay (payer)

```typescript
const challenge = await x402.charge({
  amountUsdc: "0.01",      // human USDC, the documented easy path
  network: "base-sepolia", // testnet default; "base" is mainnet
  payerOrg: process.env.PAYER_ORG_ID,
  description: "API call",
});

const payer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`);
const receipt = await x402.pay(challenge, { signer: payer });
console.log(receipt.status, receipt.settle_tx);
```

Pass exactly one of `amountUsdc` or `amount` (base units, e.g. `"10000"`); passing both throws. `pay()` accepts any viem `LocalAccount`; it uses `signTypedData` internally.

> **Warning:** Every 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.

### Email-native payments

Use when the payment must ride a real email thread instead of an out-of-band challenge id.

```typescript
const issued = await x402.createEmailChallenge({
  from: "payee@your-domain.example",
  to: "payer@their-domain.example",
  amountUsdc: "0.01",
  network: "base-sepolia",
});
```

```typescript
import { parseEmailChallengeFromPart } from "@primitivedotdev/sdk/x402";

// interactionPart is the body of the inbound `interaction.json` MIME attachment
const parsedChallenge = parseEmailChallengeFromPart(interactionPart);
```

```typescript
import { Buffer } from "node:buffer";
import primitive from "@primitivedotdev/sdk";

const mail = primitive.client({ apiKey: process.env.PRIMITIVE_API_KEY! });
const built = await x402.payEmailChallenge(parsedChallenge, { signer: payer });
// built.json is the interaction.json bytes; attach it to a reply on the challenge email
await mail.reply(challengeEmail, {
  text: "Payment attached.",
  attachments: [{
    filename: "interaction.json",
    content_type: "application/json",
    content_base64: Buffer.from(built.json, "utf8").toString("base64"),
  }],
});
```

`payEmailChallenge` never sends anything itself; it only signs and returns the envelope plus its canonical JSON bytes. Full walkthrough: [Email-Native x402 Payments](https://test.abhinandan.one/x402-payments-overview-04a296ff/node-sdk-x402-email.md).

### Low-level signing primitives

Reach for these only when `pay()` doesn't fit your signing flow (hardware wallet, custom submission path):

```typescript
import {
  deriveEip3009Nonce,
  computePaymentValidityWindow,
  signInteractionPayment,
  buildExactEvmPaymentPayload,
} from "@primitivedotdev/sdk/x402";
```

`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](https://test.abhinandan.one/x402-payments-overview-04a296ff/node-sdk-x402-signing-primitives.md).

### Spend policy

```typescript
await x402.setSpendPolicy({ paused: false, max_per_payment: "5000000" });
const policy = await x402.getSpendPolicy();
await x402.listPayoutAddresses();
```

The spend policy is the org-level guardrail on outbound x402 payments, explained in [x402 Payments Overview](https://test.abhinandan.one/x402-payments-overview.md); updates merge, so only the fields you pass change. Full detail: [Spend Policy and Payout Address Management](https://test.abhinandan.one/x402-payments-overview-04a296ff/node-sdk-x402-spend-policy.md).

## Generated API client and Primitive Memories

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

```typescript
import { createPrimitiveClient } from "@primitivedotdev/sdk/api";

const client = createPrimitiveClient({ apiKey: process.env.PRIMITIVE_API_KEY! });

await client.memories.set({ key: "thread:latest", value: { email_id: "em_123" } });
const memory = await client.memories.get("thread:latest");
const page = await client.memories.search({ prefix: "thread:", includeValue: false });
await client.memories.delete("thread:latest");
```

`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](https://test.abhinandan.one/node-sdk-api-client.md).

## Gotchas an agent will otherwise hit

These are the mistakes that compile cleanly and fail at runtime or on the wire.

- **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.
- **`reply()` with a `subject` field**: not a supported input; the type doesn't even include it. Use `send()`.
- **Passing both `amount` and `amountUsdc`** to `charge()` or `createEmailChallenge()`: throws immediately, by design. Pick one.
- **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.
- **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.
- **Assuming `client.memories.search` does text search**: it is prefix-only key search. Use `client.semanticSearch(...)` for content search.
- **Forgetting `waitTimeoutMs` bounds**: the valid range is 1000 to 30000 milliseconds, and anything outside it throws a `TypeError` before the request is sent.
- **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.
