---
title: "Request Options and Idempotency"
canonical: "https://test.abhinandan.one/node-sdk-request-options"
markdown_url: "https://test.abhinandan.one/node-sdk-request-options.md"
publisher: "Primitive SDKs"
kind: "guide"
content_type: "reference"
category: "Node.js SDK"
description: "Pass signal, timeout, headers, and idempotencyKey as the second argument to client.send/reply/forward in @primitivedotdev/sdk for safe retries."
keywords: ["RequestOptions", "idempotencyKey", "AbortSignal.timeout", "Idempotency-Key header", "client.send request options", "per-call timeout Node SDK"]
last_modified: "2026-08-21T18:22:43.359885+00:00"
published_at: "2026-08-11T18:54:51.967277+00:00"
source_files:
  - "sdk-node/README.md"
  - "sdk-node/src/api/index.ts"
sections:
  - {anchor: "what-requestoptions-carries", title: "What `RequestOptions` carries"}
  - {anchor: "step-cap-a-slow-call-with-a-per-call-timeout", title: "Cap a slow call with a per-call timeout"}
  - {anchor: "step-or-cancel-with-your-own-abortsignal", title: "Or cancel with your own AbortSignal"}
  - {anchor: "step-attach-custom-headers", title: "Attach custom headers"}
  - {anchor: "step-make-a-send-retry-safe-with-an-idempotency-key", title: "Make a send retry-safe with an idempotency key"}
  - {anchor: "putting-it-together", title: "Putting it together"}
  - {anchor: "wait-is-a-body-field-not-a-request-option", title: "`wait` is a body field, not a request option"}
  - {anchor: "next-steps", title: "Next steps"}
---

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

# Request Options and Idempotency

Configure per-call timeouts, abort signals, custom headers, and idempotency keys on any @primitivedotdev/sdk client method so slow calls fail fast and retries never double-send.

Every method on the `@primitivedotdev/sdk` client, `client.send`, `client.reply`, `client.forward`, accepts an optional second argument, `RequestOptions`, that controls cancellation, timeouts, headers, and idempotent retries for that one call. Reach for it whenever you need a call to fail fast, carry a tracing header, or survive a network retry without double-sending mail.

## What `RequestOptions` carries

```typescript
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;
}
```

Client-level config, the `fetch` implementation, base URL, and default headers you passed to `primitive.client({...})`, still applies to every call. Per-call `RequestOptions` compose on top: `headers` merge with the per-call value winning on conflict, and `signal`/`timeout` compose so whichever fires first wins.

### 1. Cap a slow call with a per-call timeout

Pass `timeout` (milliseconds) as the second argument to bound how long a single call waits before it aborts:

```typescript
import primitive from "@primitivedotdev/sdk";

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

await client.send(
  {
    from: "Support <support@example.com>",
    to: "alice@example.com",
    subject: "Hello",
    bodyText: "Hi there",
  },
  { timeout: 15000 },
);
```

When the timeout fires, the promise rejects with an `AbortError`. Catch it the same way you'd catch any other rejected promise.

### 2. Or cancel with your own AbortSignal

Pass `signal` when you need external cancellation, for example, tying the request to a user action or an upstream request's own abort signal:

```typescript
const controller = new AbortController();

const sendPromise = client.send(
  {
    from: "Support <support@example.com>",
    to: "alice@example.com",
    subject: "Hello",
    bodyText: "Hi there",
  },
  { signal: controller.signal },
);

// Elsewhere: cancel the request.
controller.abort();
```

You can pass both `signal` and `timeout` together. They compose via `AbortSignal.any`, so whichever fires first cancels the call.

### 3. Attach custom headers

Pass `headers` to merge extra headers on top of any client-level defaults. Per-call headers win on a key conflict:

```typescript
await client.send(
  {
    from: "Support <support@example.com>",
    to: "alice@example.com",
    subject: "Hello",
    bodyText: "Hi there",
  },
  { headers: { "X-Trace-Id": "trace-abc123" } },
);
```

### 4. Make a send retry-safe with an idempotency key

Pass `idempotencyKey`, a string unique per logical send, as `RequestOptions.idempotencyKey`. It's sent as the `Idempotency-Key` request header:

```typescript
await client.send(
  {
    from: "Support <support@example.com>",
    to: "alice@example.com",
    subject: "Hello",
    bodyText: "Hi there",
  },
  { idempotencyKey: "customer-key-abc123" },
);
```

Reusing the same key on a retried call returns the **original** response instead of sending a second email. Use one key per logical send: a network blip that forces you to retry the exact same request should reuse the key; a genuinely new email needs a new key.

> **Tip:** Check `result.deliveryStatus` and the `idempotentReplay` flag documented on the send result to tell a fresh send apart from a replayed one. See [Sending, Replying, and Forwarding Email](https://test.abhinandan.one/node-sdk-sending-email.md) for the full `SendResult` shape.

## Putting it together

Combine timeout and idempotency key on the same call, the common pattern for a retry loop around a flaky network:

```typescript
import primitive from "@primitivedotdev/sdk";

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

async function sendWithRetry() {
  try {
    return await client.send(
      {
        from: "Support <support@example.com>",
        to: "alice@example.com",
        subject: "Hello",
        bodyText: "Hi there",
      },
      { timeout: 15000, idempotencyKey: "customer-key-abc123" },
    );
  } catch (err) {
    if (err instanceof Error && err.name === "AbortError") {
      // Safe to retry with the SAME idempotencyKey: a replay returns the
      // original response instead of sending twice.
      return await client.send(
        {
          from: "Support <support@example.com>",
          to: "alice@example.com",
          subject: "Hello",
          bodyText: "Hi there",
        },
        { timeout: 15000, idempotencyKey: "customer-key-abc123" },
      );
    }
    throw err;
  }
}
```

> **Warning:** Never generate a fresh `idempotencyKey` inside a retry loop. If the key changes between attempts, a retry after a timeout can produce a duplicate email, the whole point of the key is that it stays fixed across retries of the *same* logical send.

## `wait` is a body field, not a request option

`wait: true` and `waitTimeoutMs` control whether `send`/`reply` hold the response open for the first downstream SMTP delivery outcome. They're passed on the send/reply input itself, not in `RequestOptions`. See the [wait mode concept](https://test.abhinandan.one/email-model.md) and [Sending, Replying, and Forwarding Email](https://test.abhinandan.one/node-sdk-sending-email.md) for that behavior; when you combine `wait: true` with a `timeout` here, make the timeout comfortably longer than the 30-second default `waitTimeoutMs` so the request option doesn't cut the call off before the server-side wait resolves.
