npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

shipmail

v0.5.6

Published

Official TypeScript SDK for the Shipmail API

Readme

Shipmail TypeScript SDK

npm version npm downloads bundle size types license

Official TypeScript SDK for the Shipmail API. Zero runtime dependencies. Native fetch. Full TypeScript types. ESM and CommonJS.

Runtimes: Node.js 18+, Bun, Deno. Webhook verification uses node:crypto (enable nodejs_compat on Cloudflare Workers if you verify webhooks there).

Contents

Install

bun add shipmail
# or
npm install shipmail
# or
pnpm add shipmail

Quick start

import { ShipmailClient } from "shipmail";

const shipmail = new ShipmailClient({ apiKey: process.env.SHIPMAIL_API_KEY! });

const message = await shipmail.messages.send({
  mailbox_id: "mbx_...",
  to: [{ address: "[email protected]", name: "User" }],
  subject: "Hello",
  text: "Hi there",
  html: "<p>Hi there</p>",
  client_reference: "crm-123",
  metadata: { campaign: "onboarding" },
  source_rfc_message_id: "<[email protected]>",
});

const sameMessage = await shipmail.messages.list({ client_reference: "crm-123" });

The SDK does not auto-read environment variables. Pass the key explicitly.

You can also pass a key string directly:

const shipmail = new ShipmailClient("sm_live_...");

Configuration

const shipmail = new ShipmailClient({
  apiKey: process.env.SHIPMAIL_API_KEY!,
  baseUrl: "https://shipmail.to/api/v1",
  maxRetries: 2,
  timeout: 30_000,
  fetch: customFetch,
  defaultHeaders: { "x-app-name": "my-app" },
  organizationId: "00000000-0000-4000-8000-000000000123",
});

| Option | Type | Default | Description | | ---------------- | ------------------------ | ---------------------------- | --------------------------------------------------------------- | | apiKey | string | required | Shipmail API key (sm_live_...). | | baseUrl | string | https://shipmail.to/api/v1 | API base URL. | | maxRetries | number | 2 | Retry count on 5xx and 429. Total attempts is maxRetries + 1. | | timeout | number | 30_000 | Per-request timeout in ms. | | fetch | typeof fetch | globalThis.fetch | Custom fetch implementation. | | defaultHeaders | Record<string, string> | {} | Headers added to every request. | | organizationId | string | none | Delegated child organization for approved infrastructure calls. |

Domains

await shipmail.domains.create({ name: "example.com" });
await shipmail.domains.list({ limit: 10 });
await shipmail.domains.get("dom_...");
await shipmail.domains.getDnsRecords("dom_...");
await shipmail.domains.update("dom_...", { catch_all_mailbox_id: "mbx_..." });
await shipmail.domains.delete("dom_...");
await shipmail.domains.verify("dom_...");
await shipmail.domains.search({ keyword: "example" });
await shipmail.domains.register({
  name: "example.com",
  years: 1,
  contact: {
    first_name: "Jane",
    last_name: "Doe",
    address1: "1 Main St",
    /* ... */
  },
});

Mailboxes

await shipmail.mailboxes.create({
  domain_id: "dom_...",
  address: "hello",
  password: "StrongPass123",
  display_name: "Hello",
});
await shipmail.mailboxes.list({ domain_id: "dom_..." });
await shipmail.mailboxes.get("mbx_...");
await shipmail.mailboxes.update("mbx_...", { display_name: "New Name" });
await shipmail.mailboxes.suspend("mbx_...");
await shipmail.mailboxes.resume("mbx_...");
await shipmail.mailboxes.resetPassword("mbx_...", { password: "NewPassword1" });
const forwarding = await shipmail.mailboxes.createForwarding("mbx_...", {
  destination: "[email protected]",
});
const forwardingList = await shipmail.mailboxes.listForwarding("mbx_...");
await shipmail.mailboxes.deleteForwarding("mbx_...", forwarding.id);
const appPassword = await shipmail.mailboxes.createAppPassword("mbx_...", {
  name: "Desktop mail",
  expires_at: "2026-10-01T00:00:00Z",
});
const appPasswords = await shipmail.mailboxes.listAppPasswords("mbx_...");
await shipmail.mailboxes.revokeAppPassword("mbx_...", appPassword.id);
const folders = await shipmail.mailboxes.listFolders("mbx_...");
const folder = await shipmail.mailboxes.createFolder("mbx_...", {
  name: "VIP",
  parent_id: null,
});
await shipmail.mailboxes.updateFolder("mbx_...", folder.id, { name: "VIP Clients" });
await shipmail.mailboxes.deleteFolder("mbx_...", folder.id);
const identities = await shipmail.mailboxes.listIdentities("mbx_...");
const currentRules = await shipmail.mailboxes.getRules("mbx_...");
const updatedRules = await shipmail.mailboxes.updateRules("mbx_...", {
  rules: [
    ...currentRules.rules,
    {
      id: crypto.randomUUID(),
      name: "Flag invoices",
      enabled: true,
      position: currentRules.rules.length,
      match_mode: "all",
      stop: false,
      conditions: [{ type: "subject_contains", value: "invoice" }],
      actions: [{ type: "star" }, { type: "send_webhook" }],
    },
  ],
});
await shipmail.mailboxes.delete("mbx_...");

await shipmail.mailboxes.updateAutoReply("mbx_...", {
  enabled: true,
  subject: "Out of office",
  body: "Back on Monday.",
  from_date: "2026-06-01",
  to_date: "2026-06-07",
});

const mailboxId = "550e8400-e29b-41d4-a716-446655440000";
const inbox = await shipmail.mailboxes.listInboxMessages(mailboxId, {
  after: "2025-07-20T00:00:00.000Z",
  before: "2026-07-20T00:00:00.000Z",
  limit: 50,
});
const exact = await shipmail.mailboxes.getInboxMessage(mailboxId, inbox.data[0].id);

const queue = await shipmail.mailboxes.listInboxThreads(mailboxId, {
  attention_state: "needs_reply",
  after: "2025-07-20T00:00:00.000Z",
});
const candidate = queue.data[0];
// `conversation_id` is the ID to store. `thread_id` is deprecated: it still works and its
// value is unchanged, but the mail server can re-thread it when conversations merge.
const draft = await shipmail.mailboxes.createInboxReplyDraft(mailboxId, candidate.conversation_id, {
  text: "Thanks for the note.",
  expected_version: candidate.version,
});
// Apply your approval policy before sending. Stale versions fail with 409 without delivery.
await shipmail.mailboxes.sendInboxReplyDraft(mailboxId, candidate.conversation_id, draft.id);

Messages

await shipmail.messages.send({
  mailbox_id: "mbx_...",
  to: [{ address: "[email protected]" }],
  cc: [{ address: "[email protected]" }],
  subject: "Hello",
  text: "Hi there",
  html: "<p>Hi there</p>",
});

await shipmail.messages.list({ mailbox_id: "mbx_...", limit: 25 });
const analytics = await shipmail.messages.listAnalytics({
  updated_after: "2026-07-01T00:00:00.000Z",
  limit: 100,
});
// Follow analytics.pagination.next_cursor, then persist analytics.pagination.snapshot_at.
await shipmail.messages.get("msg_...");

await shipmail.messages.reply("msg_...", {
  to: [{ address: "[email protected]" }],
  text: "Thanks for your email.",
});

Scheduled messages and attachments

Stage raw files up to 25 MB, then reference their opaque IDs from send or scheduled-update calls. The older base64 attachments field still works, but staged IDs avoid putting file bytes in JSON.

const bytes = await Bun.file("./invoice.pdf").arrayBuffer();
const attachment = await shipmail.mailboxes.stageAttachment("mbx_...", {
  filename: "invoice.pdf",
  content_type: "application/pdf",
  data: bytes,
});

const scheduled = await shipmail.messages.send({
  mailbox_id: "mbx_...",
  to: ["[email protected]"],
  subject: "Invoice",
  text: "Attached.",
  staged_attachment_ids: [attachment.id],
  scheduled_at: "2026-08-01T08:00:00.000Z",
});

const pending = await shipmail.scheduledMessages.list();
const detail = await shipmail.scheduledMessages.get(scheduled.id);
await shipmail.scheduledMessages.update(scheduled.id, {
  to: detail.to,
  subject: detail.subject,
  text: detail.text ?? "",
  staged_attachment_ids: [attachment.id],
  scheduled_at: "2026-08-02T08:00:00.000Z",
});
await shipmail.scheduledMessages.cancel(scheduled.id);

Staged IDs expire after 24 hours and are bound to the API key, organization, mailbox, and file metadata that created them.

For browser-hosted components that must keep the Shipmail API key off the page, prepare a five-minute, single-use upload URL:

const prepared = await shipmail.mailboxes.prepareStagedAttachmentUpload("mbx_...", {
  filename: "invoice.pdf",
  content_type: "application/pdf",
  size: bytes.byteLength,
  sha256: "<lowercase SHA-256 of the exact bytes>",
});

await fetch(prepared.upload_url, {
  method: "POST",
  body: bytes,
  headers: { "Content-Type": prepared.content_type },
  credentials: "omit",
  redirect: "error",
});

The upload succeeds only when filename, MIME type, byte size, and digest match the preparation request. The returned upload URL is a secret and cannot be replayed after a successful upload.

Sandbox

Create a test API key (sm_test_...) to simulate email without contacting real recipients. Test messages, threads, Assistant automations, webhooks, quota, suppressions, and reputation are isolated from live mode.

const testClient = new ShipmailClient("sm_test_...");

await testClient.messages.send({
  mailbox_id: "mbx_...",
  to: ["[email protected]"],
  subject: "Sandbox test",
  text: "Not delivered",
  sandbox_outcome: "bounced",
});

await testClient.mailboxes.injectSandboxInbound("mbx_...", {
  from: "[email protected]",
  subject: "Re: Sandbox test",
  text: "Fake inbound reply",
});

Threads

const threads = await shipmail.threads.list({ mailbox_id: "mbx_..." });
const threadId = threads.data[0].id;
const thread = await shipmail.threads.get(threadId, { mailbox_id: "mbx_..." });

await shipmail.threads.reply(threadId, {
  mailbox_id: "mbx_...",
  to: ["[email protected]"],
  text: "Thanks for your email.",
});

Reply scans

Use a durable, atomically captured scan for a historical window. Creation returns a completed snapshot; retry a 409 with bounded backoff while historical classification finishes, then follow every opaque next_cursor; never parse or fabricate cursors. Scans are retained for 30 days.

const scan = await shipmail.replyScans.create({
  mailbox_ids: ["550e8400-e29b-41d4-a716-446655440000"],
  after: new Date(Date.now() - 365 * 86_400_000).toISOString(),
});
const results = await shipmail.replyScans.listResults(scan.id, { limit: 100 });
console.log(results.data);

Webhooks

const webhook = await shipmail.webhooks.create({
  url: "https://example.com/webhook",
  events: ["message.received", "message.sent"],
  description: "Incoming email handler",
});
// webhook.secret is returned only on creation. Store it now.

await shipmail.webhooks.list();
await shipmail.webhooks.get("whk_...");
await shipmail.webhooks.update("whk_...", { active: false });
await shipmail.webhooks.delete("whk_...");

await shipmail.webhooks.rotateSecret("whk_...");
await shipmail.webhooks.test("whk_...");
await shipmail.webhooks.listDeliveries("whk_...");
await shipmail.webhooks.getDelivery("whk_...", "dlv_...");
await shipmail.webhooks.replayDelivery("whk_...", "dlv_...", {
  idempotencyKey: "replay-dlv-123",
});

Supported event types:

message.received
message.sent
message.delivered
message.bounced
message.complained
mailbox.rule_matched
domain.verified
domain.verification_failed
domain.degraded
org.reputation_warning
org.sending_throttled
org.sending_suspended
org.reputation_recovered

Suppressions

await shipmail.suppressions.list({ limit: 25 });
await shipmail.suppressions.remove("[email protected]");

Auto-paginate:

for await (const item of shipmail.suppressions.listAutoPaginating({ limit: 100 })) {
  console.log(item.email_address, item.reason);
}

Audiences

const audience = await shipmail.audiences.create({
  name: "Newsletter",
  consent_source: "Website signup form",
});

await shipmail.audiences.subscribers.add(audience.id, {
  email_address: "[email protected]",
  merge_fields: { plan: "pro" },
});

await shipmail.audiences.feeds.update(audience.id, {
  enabled: true,
  title: "Release notes",
  canonical_url: "https://example.com/feed.xml",
  entry_limit: 25,
});
// Graceful migration: the current URL redirects to the replacement.
await shipmail.audiences.feeds.rotate(audience.id);
// Leaked URL: immediately invalidate both current and previous URLs.
await shipmail.audiences.feeds.revoke(audience.id);

Newsletters

import { readFile } from "node:fs/promises";

const senderIdentities = await shipmail.newsletters.senderIdentities.list({ limit: 25 });
const assets = await shipmail.newsletters.assets.list({ kind: "image", q: "hero", limit: 25 });
const hero = await shipmail.newsletters.assets.upload({
  filename: "hero.png",
  content_type: "image/png",
  data: await readFile("hero.png"),
});
const existingHero = await shipmail.newsletters.assets.registerFromUrl({
  url: "https://cdn.shipmail.to/newsletter-images/org_123/hero.png",
  filename: "hero.png",
});

console.log(assets.storage.used_bytes, assets.storage.limit_bytes);

const newsletter = await shipmail.newsletters.create({
  audience_id: "aud_...",
  sender_identity_id: senderIdentities.data[0].id,
  name: "July changelog",
  subject: "What shipped in July",
  preview_text: "A quick product update",
  blocks: [
    { type: "heading", level: 1, text: "July updates" },
    {
      type: "callout",
      variant: "info",
      title: "Quick note",
      body: "A <strong>short</strong> intro.",
    },
    {
      type: "paragraph",
      body: 'Read the <a href="https://example.com/launch">full announcement</a>.',
    },
    { type: "image", url: hero.url, alt: "Product screenshot" },
    { type: "image", url: existingHero.url, alt: "Existing CDN screenshot" },
    {
      type: "columns",
      ratio: "50-50",
      left: { title: "For teams", image_url: hero.url, image_fit: "natural" },
      right: { title: "For agents", image_url: existingHero.url, image_fit: "contain" },
    },
  ],
});

await shipmail.newsletters.preview(newsletter.id);

await shipmail.newsletters.sendTest(newsletter.id, {
  recipient_email: "[email protected]",
});

await shipmail.newsletters.preflight(newsletter.id);

await shipmail.newsletters.schedule(newsletter.id, {
  scheduled_at: "2026-08-01T09:00:00.000Z",
});

Newsletter test sends and schedules must pass preflight. Guardrail failures throw ValidationError with the failed preflight items in error.details. Preflight responses include url_breakdown so you can see which links, image URLs, and video thumbnails contribute to deliverability checks. Paragraph, quote, callout, list-item, and column bodies accept bare text or sanitized inline HTML. Use <p> and <br> for line breaks. Allowed tags are a, b, br, code, em, i, p, s, span, strong, and u. Use body_html or custom_html for a fully custom email-safe layout. Concurrent newsletter updates can throw ConflictError (409). Fetch the latest newsletter, merge your changes, and retry the update.

Partner beta

Approved partner accounts can create isolated operator-owned organizations and read consolidated usage:

const child = await shipmail.partner.createOrganization(
  {
    name: "Operator",
    external_reference: "operator_123",
    owner_email: "[email protected]",
    mailbox_limit: 3,
    data_classification: "internal_test",
  },
  { idempotencyKey: "operator-123" },
);

const delegated = new ShipmailClient({
  apiKey: process.env.SHIPMAIL_API_KEY!,
  organizationId: child.organization_id,
});

await delegated.domains.list();
await delegated.mailboxes.create({
  domain_id: "dom_...",
  address: "support",
  generate_password: true,
});
const grants = await shipmail.partner.listMailboxCredentialGrants();
const credential = await shipmail.partner.consumeMailboxCredentialGrant(grants.data[0]!.id, {
  name: "Embedded webmail",
});
await shipmail.partner.usage();

Use a separate client for delegated infrastructure. Partner target context is not accepted by message, thread, calendar, contacts, export, suppression, billing, or password endpoints. The beta requires Shipmail approval and externally owned domains. Delegated mailbox creation must use generate_password: true; the generated primary password is never returned to the partner. The operator creates a one-time credential grant. Consuming it requires the exact partner:mailbox_credentials:issue scope and returns the app-password secret once. App-password creation and grant consumption do not accept idempotency keys because their plaintext response must never be cached.

Status

const status = await shipmail.status.get();

Pagination

List methods return { data, pagination } with cursor-based pagination:

const page = await shipmail.domains.list({ limit: 10 });
page.data; // Domain[]
page.pagination; // { next_cursor, has_more }

if (page.pagination.has_more) {
  const next = await shipmail.domains.list({
    cursor: page.pagination.next_cursor,
    limit: 10,
  });
}

Cursors are opaque and operation-specific. Return them unchanged to the same operation. Inbox and reply-queue cursors are bound to their mailbox, time window, sort, and filters; omit those filters on later pages or repeat them exactly.

Auto-paginate over all pages:

for await (const domain of shipmail.domains.listAutoPaginating({ limit: 25 })) {
  console.log(domain.name);
}

listAutoPaginating is available on domains, mailboxes, messages, threads, webhooks, webhooks.listDeliveriesAutoPaginating, suppressions, newsletters.domains, and newsletters.assets.

Webhook verification

Verify incoming webhook signatures without instantiating a client:

import { verifyWebhook, WebhookVerificationError } from "shipmail";

try {
  const event = await verifyWebhook(rawBody, request.headers, webhookSecret);
  event.event_type; // typed WebhookEventType union
  event.data;
} catch (err) {
  if (err instanceof WebhookVerificationError) {
    // signature mismatch, missing header, expired timestamp, etc.
  }
}

Next.js Route Handler example

App Router consumes request.text() to get the raw body. Do not parse to JSON before verifying.

// app/api/webhooks/shipmail/route.ts
import { verifyWebhook, WebhookVerificationError } from "shipmail";

export async function POST(request: Request) {
  const rawBody = await request.text();
  const secret = process.env.SHIPMAIL_WEBHOOK_SECRET!;

  try {
    const event = await verifyWebhook(rawBody, request.headers, secret);
    // handle event...
    return new Response("ok");
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return new Response("invalid signature", { status: 401 });
    }
    throw err;
  }
}

Per-request options

Every method accepts a final options argument:

type MethodOptions = {
  timeout?: number;
  signal?: AbortSignal;
  headers?: Record<string, string>;
  idempotencyKey?: string;
  organizationId?: string;
};
await shipmail.messages.send(params, {
  timeout: 5_000,
  headers: { "x-trace-id": traceId },
});

Idempotency

Pass idempotencyKey on mutating calls to make them safe to retry:

await shipmail.messages.send(
  {
    mailbox_id: "mbx_...",
    to: [{ address: "[email protected]" }],
    subject: "Receipt",
    text: "Thanks for your purchase.",
  },
  { idempotencyKey: `receipt-${orderId}` },
);

The SDK adds the key as the Idempotency-Key header. Reuse the same key to retry without sending a duplicate email. Keys are scoped per API key.

Cancellation

Pass an AbortSignal to cancel an in-flight request. The signal also cancels SDK-internal retries:

const controller = new AbortController();
setTimeout(() => controller.abort(), 2_000);

await shipmail.messages.send(params, { signal: controller.signal });

Custom fetch and proxies

Inject a custom fetch implementation for proxies, observability, or testing:

const shipmail = new ShipmailClient({
  apiKey: process.env.SHIPMAIL_API_KEY!,
  fetch: async (url, init) => {
    const start = Date.now();
    const res = await fetch(url, init);
    metrics.histogram("shipmail.fetch.duration_ms", Date.now() - start);
    return res;
  },
});

The custom fetch receives the same arguments as the global fetch and must return a Response.

Errors

The SDK throws typed errors that map to HTTP responses. All inherit from ShipmailError:

import {
  ShipmailError,
  AuthenticationError,
  AuthorizationError,
  ValidationError,
  NotFoundError,
  ConflictError,
  RateLimitError,
  QuotaExceededError,
  InternalServerError,
  ConnectionError,
} from "shipmail";

try {
  await shipmail.messages.send(params);
} catch (err) {
  if (err instanceof ValidationError) {
    err.message;
    err.details; // field-level validation errors
  }
  if (err instanceof RateLimitError) {
    err.retryAfter; // seconds
  }
  if (err instanceof ShipmailError) {
    err.status; // HTTP status
    err.type; // error type string
    err.requestId; // include this when contacting support
    err.retryable;
  }
  throw err;
}

| Error | When | | --------------------- | -------------------------------------------------------------- | | AuthenticationError | 401. Bad or missing API key. | | AuthorizationError | 403. Key lacks permission for the resource. | | ValidationError | 400 or 422. See details for per-field errors. | | NotFoundError | 404. | | ConflictError | 409. Resource already exists or state conflict. | | RateLimitError | 429. Read retryAfter (seconds). | | QuotaExceededError | 402. Plan or sending quota exceeded. | | InternalServerError | 5xx. Retried automatically up to maxRetries. | | ConnectionError | Network error, timeout, or DNS failure. Retried automatically. |

Retries

The SDK retries on 5xx, 429, and connection errors with exponential backoff and jitter. Retry-After is honored when present. Default is 2 retries (3 total attempts).

new ShipmailClient({ apiKey, maxRetries: 0 }); // disable retries

Retries respect any AbortSignal you pass via MethodOptions.signal.

Bundling

The package ships ESM and CommonJS via exports, with "sideEffects": false for tree-shaking. Importing a single resource pulls in only what it needs. The published bundle has no runtime dependencies.

Testing

Mock by injecting a custom fetch at construction time:

const shipmail = new ShipmailClient({
  apiKey: "sm_live_test",
  fetch: async () =>
    new Response(JSON.stringify({ id: "msg_123", status: "queued" }), {
      status: 200,
      headers: { "content-type": "application/json" },
    }),
});

This is the recommended pattern for unit tests. No HTTP interception or mocking library required.

License

MIT.

Links