@primitivedotdev/sdk
v1.34.0
Published
Official Primitive Node.js SDK: webhook, api, openapi, contract, and parser runtime modules.
Downloads
55,518
Readme
@primitivedotdev/sdk
The official Node.js library for Primitive, an email API for sending and receiving programmatic mail. Typed client for receiving and verifying inbound webhooks, sending mail, parsing raw MIME, and calling the full HTTP API.
Looking for the CLI?
The primitive CLI ships as a separate package, primitive. Install it with:
npm install -g primitive
# or, no-install:
npx primitive@latest <command>This package no longer ships a primitive bin. Install primitive to get the CLI.
Install
npm install @primitivedotdev/sdkRequires Node.js 22 or newer.
Set your API key
Get a key from your dashboard and export it, then pass it to the client as apiKey (the examples below read it from process.env.PRIMITIVE_API_KEY).
export PRIMITIVE_API_KEY=prim_...Library
The default root import is intentionally small and centered on the two most common app-code use cases: receiving inbound webhook deliveries and sending mail.
import primitive from "@primitivedotdev/sdk";Receive and reply in a Next.js route
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 request body, verifies the HMAC-SHA256 signature against your account secret (rejecting expired or tampered deliveries), and returns a normalized email object. client.reply(email, ...) derives threading and the Re: subject from the parent message server-side.
Send a new email
import primitive from "@primitivedotdev/sdk";
const client = primitive.client({
apiKey: process.env.PRIMITIVE_API_KEY!,
});
const result = await client.send({
from: "Support <[email protected]>",
to: "[email protected]",
subject: "Hello",
bodyText: "Hi there",
wait: true,
waitTimeoutMs: 5000,
});
console.log(result.id, result.status, result.queueId, result.deliveryStatus);By default send, reply, and forward return as soon as Primitive accepts the message for delivery. On send and reply, pass wait: true to keep the HTTP request open until the first downstream SMTP delivery outcome; in that mode, configure your runtime or transport with a request timeout long enough for SMTP delivery, typically 30 to 60 seconds.
Per-call request options
Every client method accepts an optional second argument with cancellation, timeout, header, and idempotency controls:
interface RequestOptions {
// Cancel the in-flight request when this signal fires. Surfaces as AbortError.
signal?: AbortSignal;
// Per-call timeout in milliseconds. Composed with `signal` so either fires.
timeout?: number;
// Per-call headers merged on top of client-level headers. Last write wins.
headers?: Record<string, string>;
// Idempotency key for safe retries. Sent as the Idempotency-Key request header.
idempotencyKey?: string;
}Cap a single send at 15 seconds:
await client.send(
{ from, to, subject, bodyText },
{ signal: AbortSignal.timeout(15000) },
);Idempotency key for safe retries (reusing a key returns the original response, so a retried network call deduplicates against the first send):
await client.send(
{ from, to, subject, bodyText },
{ idempotencyKey: "customer-key-abc123" },
);Client-level config (default fetch, base URL, default headers passed to primitive.client({...})) still applies to every call. Per-call RequestOptions overrides or merges on top: headers merge (per-call wins on conflict), signal and timeout compose so the first to fire wins.
About wait mode
When wait: true, the call returns the first downstream SMTP outcome (or waitTimeoutMs, default 30000). Possible terminal deliveryStatus values:
deliveredaccepted by the receiving MTAbouncedrejected by the receiving MTA (the response is still 200 OK)deferredtemporary failure (receiver said 4xx); Primitive retries the delivery laterwait_timeoutno outcome was observed in time. Treat as "outcome unknown." The send may still complete after the response returns.
Reply from a different address
reply() defaults the From address to the inbound recipient (the address that received the email). When your verified outbound domain differs from your inbound domain, pass from explicitly:
await client.reply(email, {
text: "Thanks for your email.",
from: "[email protected]",
});HTML replies and waiting on the delivery outcome
reply() accepts html as a sibling of text, plus the same wait flag the top-level send() takes:
await client.reply(email, {
text: "Thanks for your email.",
html: "<p>Thanks for your email.</p>",
attachments: [
{
filename: "report.txt",
content_base64: Buffer.from("hello").toString("base64"),
},
],
wait: true,
});subject is intentionally 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(...) if you need full subject control.
If the inbound row is not in a state we can reply to (it was rejected at ingestion, its content was discarded, or it has no recipient recorded), the API returns inbound_not_repliable (HTTP 422) and the SDK throws.
Forward an inbound email
await client.forward(email, {
to: "[email protected]",
bodyText: "Can you take this one?",
});The normalized email object
primitive.receive(...) returns a normalized inbound email object that keeps the common case clean:
email.sender.address;
email.sender.name;
email.receivedBy;
email.receivedByAll;
email.replyTarget.address;
email.replySubject;
email.forwardSubject;
email.subject;
email.text;
email.thread.messageId;
email.thread.references;
email.raw;Use email.raw when you need the original validated webhook event shape.
Deciding whether to trust an inbound email
Every email.received event carries the server's SPF, DKIM, and DMARC results on event.email.auth. Two helpers turn those into decisions, and both are importable from @primitivedotdev/sdk/api so they work inside Primitive Functions:
validateEmailAuth(event.email.auth) computes an overall verdict (legit, suspicious, or unknown) with a confidence level and human-readable reasons. It answers "was this email authenticated?" but not which domain it was authenticated as: a fully authenticated email from any domain, including one an attacker registered, returns legit.
isTrustedSender(event, { domain, sender? }) anchors the verdict to an expected From domain, for handlers that gate an action on "this really came from our domain":
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 with a 5xx
// so webhook redelivery retries this email later.
} else {
console.warn("Untrusted:", trust.reason, trust.auth.reasons);
}trusted requires a legit verdict and a strict-parsed single From address in the exact expected domain (and exact sender when given). reason names the first check that failed.
The reported DMARC domain can be an organizational domain such as example.com
for mail from [email protected]. When it differs from the expected
From domain, the helper requires DMARC pass and a passing, aligned DKIM signature
from that exact expected domain. For a managed inbox such as
[email protected], it also accepts primitive.email as the
signer, relying on Primitive to authorize the sending identity. A sibling
signer, an arbitrary parent signer, or SPF alone cannot satisfy this exception.
The qualifying signature must use RSA-SHA256 with a reported key size of at
least 1024 bits, or Ed25519-SHA256. Missing RSA key size or an unknown algorithm
fails closed on this path. Continue passing the full subdomain as the expected domain.
Use this helper only with a verified Primitive webhook or an event obtained through the authenticated API. It consumes the server's authentication results; it does not verify DKIM or the webhook signature itself.
If you are writing your own check instead, three details matter:
- Do not regex the raw From header.
From: "[email protected]" <[email protected]>puts an allowlisted address in the display name while DMARC evaluates, and can pass for,evil.com. - Do not authorize based on
email.replyTargetoremail.smtp.mail_from; both are fully sender-controlled.email.senderis parsed leniently for display purposes and falls back tosmtp.mail_from, so it is not a safe authorization anchor either. - An
unknownverdict has two very different causes: a temporary DNS error (retry later) and a sender domain that publishes no DMARC record (permanent for that email).isTrustedSenderseparates them viaretryable.
The same helper is available in the Python SDK (is_trusted_sender) and the Go SDK (IsTrustedSender) with identical semantics.
Interaction envelopes
Use parseInteractionEnvelope(string | Uint8Array) from @primitivedotdev/sdk/interactions to read an
interaction.json attachment for display. The bounded parser returns valid,
unsupported, or invalid and preserves unknown protocols. It performs no
authentication, network requests, payments, or sends. See the
parser contract and limits.
x402 payments
The x402 client lets one agent request a USDC payment and another pay it. It is non-custodial: the payer signs an EIP-3009 transferWithAuthorization locally with their own key, and the key never leaves the caller. The platform resolves the real payee address, verifies every signed field against its own records, enforces the org's spend policy, and settles on chain.
The model in four steps:
- The payee registers a payout address once (proving control of it with a local signature).
- The payee creates a challenge with
charge(), which the platform fills in with the registered payout address. - The payer signs the challenge locally and submits it with
pay(). - The platform verifies and settles.
Amounts can be given as a human USDC string (amountUsdc: "0.01") or as token base units (amount: "10000", since USDC has 6 decimals). Networks are base (mainnet) and base-sepolia (testnet). A viem LocalAccount from privateKeyToAccount is a valid signer: pay() uses its signTypedData, and registerPayoutAddress() also uses its signMessage.
Construct the client from the x402 subpath import (or primitive.x402(...) from the root import):
import { createX402Client } from "@primitivedotdev/sdk/x402";
const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! });Register a payout address (payee, one time)
The signer proves control of its own address with an ownership message that binds your organization id, so a captured signature can never register the address under a different org. The recovered address becomes your default payout destination for that network, and charge() resolves its pay_to from this directory, so register before requesting payments. The org id is resolved automatically from your account, so you do not pass it (supply org only to override).
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 },
);Create a challenge (payee)
const challenge = await x402.charge({
amountUsdc: "0.01", // human USDC amount
network: "base-sepolia",
payerOrg: process.env.PAYER_ORG_ID, // org allowed to pay this challenge
description: "API call",
});Pass exactly one of amountUsdc (a human USDC string like "0.01") or amount (token base units, e.g. "10000"). amountUsdc is the easy path; amount remains available when you already have a base-unit value.
Hand the returned challenge object to the payer (for example over email or any out-of-band channel). getChallenge(id) re-hydrates a challenge by id, for example to retry pay() after a restart.
Pay a challenge (payer)
The payer signs the interaction-bound authorization locally and submits it. The key never leaves the caller.
import { privateKeyToAccount } from "viem/accounts";
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); // settled, on-chain tx hashEmail-native payments
The challenge can also ride a real email thread instead of a synthetic id. The payee issues the challenge as an email; the payer signs it into an interaction.json payment step and sends it back as an attachment on the reply.
The payee issues the challenge with createEmailChallenge. The pay_to payout wallet and the token asset are resolved server-side; you only supply the addresses, amount, and network:
const issued = await x402.createEmailChallenge({
from: "[email protected]", // your sending address (the funds receiver)
to: "[email protected]", // the payer's address
amountUsdc: "0.01",
network: "base-sepolia",
});
// issued.interaction_id is the email thread the payment is bound to;
// issued.challenge carries the payment_requirements + nonce_binding the payer signs.The payer receives the challenge as an interaction.json MIME part on an inbound email. Rather than hand-parsing it, pass the part bytes to parseEmailChallengeFromPart, which validates the envelope and returns the typed X402EmailChallenge:
import { parseEmailChallengeFromPart } from "@primitivedotdev/sdk/x402";
// `interactionPart` is the body of the inbound email's `interaction.json`
// attachment (a string, a Buffer/Uint8Array, or an already-parsed object).
const issued = parseEmailChallengeFromPart(interactionPart);The payer then signs the challenge locally with payEmailChallenge and sends the resulting envelope back as an interaction.json attachment. payEmailChallenge does not send anything; it returns the signed payment-step envelope and its canonical JSON bytes. The validity window is computed and clamped into the accepted band for you, so you never hand-set validBefore:
import { privateKeyToAccount } from "viem/accounts";
const payer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`);
const built = await x402.payEmailChallenge(issued, { signer: payer });
// `built.json` is the interaction.json body. The payer received the challenge
// as an inbound email; reply to it with the envelope attached as
// `interaction.json` using the email client's `reply` method (see the email
// client above). The platform reads the envelope, re-derives the
// interaction-bound nonce, and settles on chain.
import { Buffer } from "node:buffer";
import primitive from "@primitivedotdev/sdk";
const mail = primitive.client({ apiKey: process.env.PRIMITIVE_API_KEY! });
await mail.reply(challengeEmail, {
text: "Payment attached.",
attachments: [
{
filename: "interaction.json",
content_type: "application/json",
content_base64: Buffer.from(built.json, "utf8").toString("base64"),
},
],
});Signing primitives (lower level)
pay() builds and signs the payment for you. When you need to drive the signing yourself, for example to sign a challenge carried in an email reply and submit the payment separately, the same building blocks are exported directly:
deriveEip3009Nonce(binding)derives the interaction-bound EIP-3009 nonce. The byte layout (keccak256over the lowercasedinteraction_id, a0x00separator, the lowercasedchallenge_step_id, a0x00separator, and the 32 raw bytes of the challenge nonce) is locked to a normative vector the platform recomputes.parseEmailChallengeFromPart(part)validates an inboundinteraction.jsonchallenge part (string, bytes, or a parsed object) and returns the typedX402EmailChallengeready forpayEmailChallenge, so you never hand-parse the envelope.computePaymentValidityWindow({ challengeExpiresAtSec, nowSec })returns the{ validAfter, validBefore }window. By default it lands the window inside the band the platform accepts:validBeforekeeps at least a minimum settlement headroom (60s) so a near-expired challenge is not signed into a guaranteed rejection, and the total window is clamped to the 24h cap so a far-future expiry never produces an "authorization window too wide" rejection. Pass an explicitvalidBeforeSec/validAfterSecto pin a bound; withclamp: falsean out-of-band pinned value throws a specific error naming which bound was violated instead of silently signing a doomed authorization.signInteractionPayment({ sign, payer, domain, payTo, amount, nonceBinding, validAfter, validBefore })derives the bound nonce, assembles the authorization, and signs it with yoursigncallback. Returns{ authorization, signature }. The key never leaves the caller.buildExactEvmPaymentPayload({ network, authorization, signature })assembles the exact-EVM x402 wire payload, validating the nonce and signature shape.
import {
buildExactEvmPaymentPayload,
computePaymentValidityWindow,
signInteractionPayment,
} from "@primitivedotdev/sdk/x402";
import { privateKeyToAccount } from "viem/accounts";
const payer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`);
const pr = challenge.payment_requirements;
const nowSec = Math.floor(Date.now() / 1000);
const { validAfter, validBefore } = computePaymentValidityWindow({
challengeExpiresAtSec: Math.floor(Date.parse(challenge.expires_at) / 1000),
nowSec,
});
const { authorization, signature } = await signInteractionPayment({
sign: (typedData) => payer.signTypedData(typedData),
payer: payer.address,
domain: {
name: pr.extra.name,
version: pr.extra.version,
chainId: 84532, // base-sepolia
verifyingContract: pr.asset as `0x${string}`,
},
payTo: pr.payTo as `0x${string}`,
amount: BigInt(pr.maxAmountRequired),
nonceBinding: {
interactionId: challenge.nonce_binding.interaction_id,
challengeStepId: challenge.nonce_binding.challenge_step_id,
challengeNonce: challenge.nonce_binding.challenge_nonce,
},
validAfter,
validBefore,
});
const payment = buildExactEvmPaymentPayload({
network: "base-sepolia",
authorization,
signature,
});
// submit `payment` to /v1/x402/challenges/{id}/payRead and set the spend policy
The spend policy guards outbound payments: a paused kill-switch, per-payment and daily caps (token base units, or null for no cap), and a payee allowlist (null means any on-net payee, [] denies all). setSpendPolicy merges: only the fields you pass change, and omitted fields keep their current value. Pass null to clear a cap.
await x402.setSpendPolicy({ paused: false, max_per_payment: "5000000" });
const policy = await x402.getSpendPolicy();
await x402.listPayoutAddresses();Errors
Every method throws X402Error on a client-side, transport, or non-2xx server error. It carries status (the HTTP status, or 0 for a request that never reached the server), body (the parsed error envelope when present), and retryAfter (the Retry-After header, when the server sent one). On pay(), a status === 0 error means the request may not have been sent, so the payment outcome is indeterminate.
Lower-level surfaces
Explicit receive form
If your framework does not expose a standard Request, use the lower-level form:
const email = primitive.receive({
body: req.body,
headers: req.headers,
secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
});Generated API client
The full HTTP API is exposed as a generated client. Use it when the high-level helpers don't cover what you need:
import { PrimitiveApiClient, getAccount } from "@primitivedotdev/sdk/api";
const api = new PrimitiveApiClient({ apiKey: process.env.PRIMITIVE_API_KEY });
const result = await getAccount({ client: api.client });Primitive Memories
The high-level API client exposes Primitive Memories as durable JSON key-value
records under client.memories. Memories default to org scope. Inside a
Primitive Function, omitted scope resolves to that Function id automatically;
explicit function scope uses the function id UUID, not the function name.
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" },
});
await client.memories.set({
key: "state",
value: { step: 2 },
scope: { type: "function", id: functionId },
});
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. Use client.semanticSearch(...) for mail search. The raw
generated operations (setMemory, getMemory, searchMemories,
deleteMemory) remain exported from @primitivedotdev/sdk/api for callers who
want the exact OpenAPI operation shape.
Webhook signature verification
primitive.receive(...) handles verification automatically. If you need to verify a delivery yourself (a different language reverse-proxying through Node, a one-off audit, etc.), the wire format is:
- Header:
Primitive-Signature: t=<unix-seconds>,v1=<hex>. The same value also ridesX-Primitive-SignatureandX-Webhook-Signature; the SDK additionally accepts the retiredMyMX-Signaturename when verifying older captured deliveries. - Signed string:
${timestamp}.${rawBody}whererawBodyis the exact request bytes before any JSON decoding. - Signature: HMAC-SHA256, hex-encoded.
- Secret: returned by
GET /account/webhook-secret. Use as a UTF-8 string; do not base64-decode despite the base64-shaped output. - Tolerance: reject deliveries with a timestamp more than 5 minutes off your wall-clock.
The Node helper:
import { verifyWebhookSignature } from "@primitivedotdev/sdk/webhook";
verifyWebhookSignature({
rawBody: rawBodyString,
signatureHeader: req.headers["primitive-signature"] as string,
secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
});rawBody must be the exact bytes of the HTTP body (string or Buffer) before any JSON parsing. signatureHeader is the value of the Primitive-Signature header verbatim. Throws WebhookVerificationError on mismatch, expired timestamp, or malformed input. Pass toleranceSeconds to override the default 300-second replay window.
For most app-code callers, primitive.receive(...) from the root import handles both the body extraction and verification in one call (see "Receive and reply in a Next.js route" above). Reach for verifyWebhookSignature directly when your framework doesn't expose a standard Request and you've already pulled the raw body and header value yourself.
For the full reference (response codes, replay protection details), see the API-level "Webhook signing" section in the OpenAPI spec.
Payment and interaction webhook events
Webhooks are not email-only. The same endpoint also receives payment.* settlement notifications and interaction.x402.* events from the x402-over-email flow. The event name is carried in the X-Webhook-Event header for every family. The body is sent verbatim with no envelope, so it is the header (not a body field) that names the event: an email.* body carries event, a payment.* body carries the name in type, and an interaction.* body is just { interaction: { ... } } with no event/type field at all.
handleWebhookEvent verifies the signature over the raw body first, then keys on the header to return a typed event for known types and an UnknownEvent (it does not throw) for the rest:
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)) {
// typed PaymentSettledEvent: flat fields, amount in token base units
console.log("settled", event.challenge_id, event.amount, event.settle_tx);
} else if (isInteractionX402Event(event)) {
// typed interaction.x402.* event (challenge/payment/settled/...)
console.log(event.event, event.interaction);
}The full catalog of header values is the WebhookEventType union, also exported as the WEBHOOK_EVENT_TYPES array:
email.received,email.bounced,email.tls_report,email.dmarc_report,email.dmarc_failurepayment.settled,payment.failedinteraction.x402.challenge,interaction.x402.payment,interaction.x402.settled,interaction.x402.rejected,interaction.x402.declined,interaction.x402.expired,interaction.x402.verify_timeoutinteraction.ack.received,interaction.ack.requested,interaction.ack.acked,interaction.ack.canceled,interaction.ack.expired
Signature verification runs on the raw body and is independent of the event type, so it works identically for payment.* and interaction.* bodies. Each delivery is signed once; the same t=...,v1=... value is sent on the primary Primitive-Signature header plus X-Primitive-Signature and X-Webhook-Signature for non-SDK consumers. The SDK verifies Primitive-Signature, and still accepts the retired MyMX-Signature header name when verifying older captured deliveries. handleWebhook remains hard-typed to email.received for backward compatibility; reach for handleWebhookEvent when you need the full event union.
Other subpath imports
@primitivedotdev/sdk/openapiexports the OpenAPI document and the operation manifest as JSON. Useful for tools that want the spec inline.@primitivedotdev/sdk/contractbuilds and signs webhook payloads. Useful for tests or replaying inbound events through your own handler.@primitivedotdev/sdk/parserparses raw.emlfiles and bundles attachments. Useful when you receive inbound mail through a different path (forwarded.emlfiles, archived storage) and want the same normalization the webhook receiver applies.
Going further
- primitive.dev/docs for product docs (quickstart, webhook payload reference, FAQ).
- api.primitive.dev/v1/openapi for the machine-readable OpenAPI spec.
primitive list-operationsfor the same spec as a JSON manifest, fetched from the bundled SDK.primitive describe <command>for the inlined request/response schema of a single operation, including per-field descriptions.
Development
From sdks/sdk-node:
pnpm install
pnpm generate
pnpm typecheck
pnpm test
pnpm buildOr from repo root sdks/:
make node-generate
make node-check
make node-buildDelete mailbox history
Use deleteSentEmail and removeAgentConnection from @primitivedotdev/sdk/api.
Sent-mail deletion removes sender history and owned attachments, not recipient
copies or delivery already admitted. Cancel scheduled sends first; ineligible
states return 409. Retrying a completed DELETE succeeds. After 500 or 503, files
may already be removed; retry the same DELETE.
Connection removal requires an owner or admin session or OAuth token and a previously revoked connection. It preserves mail, notes and the external runtime. Active connections return 409; missing records return 404.
Send and reply preserve HTTP 410 sent_email_deleted and its
details.idempotent_replay value. This is a refusal to resend deleted history;
do not replace the idempotency key or retry as a fresh message to bypass it.
Receive events in your process
Use a stable subscription name and an async handler. The SDK registers the subscription, connects over WebSocket, reconnects, and acknowledges after the handler returns. No public URL, local HTTP server, or CLI is required.
import { PrimitiveClient } from "@primitivedotdev/sdk/api";
const client = new PrimitiveClient({ apiKey: process.env.PRIMITIVE_API_KEY });
const listener = await client.events.listen(async (event, { signal }) => {
await app.receive(event, { signal });
}, { subscription: "my-agent", events: ["email.received"] });
// In a standalone receiver. Embedded apps can supervise this promise.
await listener.closed;
// During application shutdown: await listener.close();Accept or enqueue each event within 30 seconds. Returning from the handler means
acceptance, not completion of longer background work. Delivery is at least once;
use event.id to deduplicate side effects. Events retain their exact body and
headers, alongside canonical id, type, and parsed data.
To wait for one event, keep the same subscription name and acknowledge explicitly:
const delivery = await client.events.wait({ subscription: "my-agent", timeoutMs: 60_000 });
if (delivery) {
await app.receive(delivery.event, { signal: delivery.signal });
await delivery.ack(); // Or delivery.retry() if acceptance failed.
}Timeout returns null. Caller cancellation uses signal. A pending delivery
blocks another receiver on that client and subscription until settled or expired.
listen() resolves when ready; start it before sending an event you need to see.
Same name shares work; different names receive independent copies. New names do
not backfill history. Closing preserves pending work. Optional onStatus reports
reconnects, handler errors, and retained gap counts; onGap: "error" stops on a gap.
Use transport: "poll" explicitly when WebSocket access is unavailable.
Connected-agent listeners
Use the agent's existing connected-address credential with the same listener API. The server automatically restricts its private subscription to inbound email for that address. No owner credential, recipient filter, or relay is required. Names are isolated per credential. Revoking or replacing the credential removes its subscriptions and queued deliveries; reconnect with the new credential to start receiving new events.
Address-scoped events contain parsed message content in email.parsed.
email.content.raw and email.content.download are null. Signed download links,
account routing metadata, and other SMTP envelope recipients are not exposed.
Attachments can be fetched through the authenticated email attachment API.
