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

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

npm install @primitivedotdev/sdk

Set the API key from the dashboard:

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 pathUse forRuntime
@primitivedotdev/sdk (root)primitive.receive, primitive.client, primitive.x402Node only (uses node:crypto)
@primitivedotdev/sdk/webhookLow-level webhook verify/parse (verifyWebhookSignature, handleWebhookEvent, handleWebhook)Node only
@primitivedotdev/sdk/apiPrimitiveApiClient, createPrimitiveClient, isTrustedSender, validateEmailAuth, normalizeReceivedEmail, EmailReceivedEvent typeWorkers-safe
@primitivedotdev/sdk/x402createX402Client, signing primitives, parseEmailChallengeFromPartNode only
@primitivedotdev/sdk/contractbuildEmailReceivedEvent, buildEventFromParsedData (fixture/producer tooling)Node only
@primitivedotdev/sdk/parserRaw MIME parsing, address parsingNode 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.

// 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. Receiving mechanics specific to this SDK: Receiving Inbound Email. Sending/reply/forward mechanics: Sending, Replying, and Forwarding Email.

  1. 1

    Construct the client#

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

    Normalize the inbound webhook#

    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:

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

    Act on it: reply, forward, or send#

    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#

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), 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.

// 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
}
await client.send(
  { from, to, subject, bodyText },
  { signal: AbortSignal.timeout(15000), idempotencyKey: "customer-key-abc123" },
);

Full reference: Request Options and Idempotency.

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.

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.

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.

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. Manual verification deep dive: Webhook Signature Verification.

Tip

Integrating with tooling that already expects webhook-id/webhook-timestamp/webhook-signature? Use Standard Webhooks Signature Support 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.

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.

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; this section is signature lookup only.

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

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

Register payout address (payee, once)#

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)#

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.

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

// interactionPart is the body of the inbound `interaction.json` MIME attachment
const parsedChallenge = parseEmailChallengeFromPart(interactionPart);
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.

Low-level signing primitives#

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

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.

Spend policy#

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; updates merge, so only the fields you pass change. Full detail: Spend Policy and Payout Address Management.

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.

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.

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.

Next steps#

Was this page helpful?

© Primitive SDKs

Powered by Browzer