samva
v0.3.0
Published
TypeScript SDK for the Samva email API
Maintainers
Readme
samva
TypeScript SDK for Samva's public customer API.
Documentation
See AGENTS.md for concise guidance when using this package with coding agents.
Overview
The root entrypoint is Promise-based:
import { createClient } from "samva";The same Promise SDK is also available at samva/promises. Native Effect users import domain modules such as samva/effect/email and provide samva/effect/client. Both clients are generated from Samva's public OpenAPI document, while the Effect runtime does not wrap or execute the Promise client. Effect is an optional peer dependency, so Promise-only applications do not install or load it.
Installation
npm install samva
# or
bun add samva
# or
yarn add samvaQuick Start
import { createClient } from "samva";
const client = createClient({
baseUrl: "https://api.samva.dev",
apiKey: "<samva-api-key>",
});
const contact = await client.contacts.findOrCreate({
name: "Ada Lovelace",
email: "[email protected]",
});
const message = await client.messages.send({
to: [contact],
channel: "email",
email: {
subject: "Hello",
html: "<p>Hello World</p>",
text: "Hello World",
},
metadata: {
externalUserId: "user_123",
},
});
const messages = await client.messages.list({
page: "1",
limit: "20",
});Effect Quick Start
Install effect next to samva when using the Effect entrypoint:
bun add samva [email protected]import { Effect } from "effect";
import * as Client from "samva/effect/client";
import * as Email from "samva/effect/email";
const program = Email.send({
to: "[email protected]",
subject: "Welcome",
html: "<h1>Welcome to Samva</h1>",
}).pipe(Effect.provide(Client.layerFetch({ apiKey: "<samva-api-key>" })));
const message = await Effect.runPromise(program);Authentication
Samva supports two client auth modes. Use one per createClient instance.
API key (automation, CI, single org)
const client = createClient({
apiKey: "samva_sk_live_...",
baseUrl: "https://api.samva.dev",
});X-API-Keyis configured automatically fromapiKey.- Organization context is derived from the API key — do not send
x-org-slug.
OAuth bearer (interactive, multi-org)
const client = createClient({
authToken: sessionAccessToken,
baseUrl: "https://api.samva.dev",
headers: { "x-org-slug": "your-org-slug" },
});- Requests use
Authorization: Bearer <authToken>. - The token is user-scoped; send the target organization slug on every request via the
x-org-slugheader. - There is no SDK
orgSlughelper — pass the header directly.
For terminal workflows (samva login, samva org use, samva email send --dry-run), see the @samva/cli README.
Service Surface
The Promise client exposes:
apiKeyscampaignscontactsconversationsdesignemailmediamessagesoperationsorganizationsscheduledMessagestemplateswebhooks
The Effect SDK exposes Pascal-cased domain modules with lower-camel operations:
ApiKeysCampaignsContactsConversationsDesignEmailMediaMessagesOperationsOrganizationsScheduledMessagesTemplatesWebhooks
Effect operations take direct parameter objects: body, path, and query fields are not wrapped in payload or params envelopes. Import the modules you use from samva/effect/<lower-kebab> and provide Client.layerFetch once at the program boundary. Client.raw exposes the underlying generated client for advanced access, but ordinary workflows do not require it.
Email attachments use organization-owned media. Call media.create, upload the bytes with the
returned instruction, call media.complete, and pass the ready mediaId to email.send or
messages.send. A message accepts at most 10 attachments totaling 25 MiB of raw bytes; the same
budget covers inline base64 content and owned media references. Remote URL attachments are not.
Effect failures carry Samva's real tagged errors (RateLimitedError, ValidationError, PaymentRequiredError, …) directly in the error channel, so Effect.catchTag("RateLimitedError", …) works against any operation without enumerating status codes, and each error exposes its own fields (no wrapper cause). The SDK also exports category helpers such as isRetryable, isTransient, catchAuthError, and catchTransient. Success values decode to domain types: annotated absolute instants become Date, sensitive fields become Redacted, and unannotated wire timestamps remain strings.
Default-on retry
The Effect client retries safe operations out of the box, so you do not wrap calls in Effect.retry:
- What retries — read operations (
GET/HEAD) and sends. Sends are safe because the client attaches anIdempotency-Key(below), so the server deduplicates a replayed attempt. Every other mutating operation (create,update,remove, …) is keyless and is never auto-retried. - When — only for
isRetryablefailures: throttling (429), transient server errors (including a transientBillingProviderError), and request-transport failures.ValidationError,ConflictError,NotFound, auth, and billing-limitPaymentRequiredErrorfailures never retry. - How — jittered exponential backoff, bounded to four attempts total. A
429honors the server'sretryAfterSecondshint (capped at 60s) in place of the computed delay.
Retry policy is read from context via the Retry.Service service. When it is absent, the default applies, so no operation gains a requirement. Override it with a Layer:
import { Effect } from "effect";
import { isRetryable } from "samva/effect/categories";
import * as Retry from "samva/effect/retry";
// Turn auto-retry off for a program:
program.pipe(Effect.provide(Retry.layerDisabled));
// Or install a custom policy (any Effect.retry options):
program.pipe(Effect.provide(Retry.layer({ times: 6, while: isRetryable })));Idempotency keys
Each messages.send / email.send call generates an Idempotency-Key per logical call — stable across the built-in retries, distinct across calls — so a retried send never delivers twice. Pass an explicit key for cross-process deduplication (safe to replay the same request from a different machine):
const send = Email.send(input, { idempotencyKey: "order-4417-receipt" });Reusing a key with identical content replays the original response; reusing it with different content fails with ConflictError.
Stream pagination
Every list operation that returns the uniform { items, pagination: { page, limit, total, totalPages } } envelope carries two Stream companions, so you traverse pages without hand-rolling a page loop:
list.items(input?)— aStreamof individual items across all pages.list.pages(input?)— aStreamof raw page envelopes.
import { Stream } from "effect";
import * as Contacts from "samva/effect/contacts";
// Every contact, one page fetched at a time:
const allContacts = Stream.runCollect(Contacts.list.items());
// Bounded consumption is lazy — this fetches only as many pages as it needs:
const firstFifty = Stream.runCollect(Contacts.list.items().pipe(Stream.take(50)));
// Caller params (start page, limit, filters) are respected:
const activeFrom2 = Contacts.list.items({ page: "2", limit: "100", status: "active" });The companions are attached uniformly to every paginated list, including Messages.list, Contacts.list, Campaigns.list, Email.listDomains, and Webhooks.list. Each page fetch reuses the ambient Retry policy. Reads retry per page; pagination does not nest retries. Page and limit inputs retain their OpenAPI wire-string representation. The Promise client is unchanged; stream pagination is Effect-only.
Redacted secrets
Secret-bearing response fields — the plaintext API key returned by apiKeys.create / apiKeys.rotate, and the webhook signing secret from webhooks.regenerateSecret — decode to Redacted<string> on the Effect surface. The secret never leaks through logs, String(...), or JSON.stringify; unwrap it explicitly with Redacted.value when you need the plaintext:
import { Effect, Redacted } from "effect";
import * as ApiKeys from "samva/effect/api-keys";
const inspectKey = Effect.gen(function* () {
const created = yield* ApiKeys.create({ name: "CI" });
`${created.key}`; // "<redacted>" — safe to log
return Redacted.value(created.key); // the plaintext key, unwrapped on purpose
});The wire body and the Promise client keep plain strings; only the Effect surface is Redacted.
OpenAPI vendor extensions
The published openapi.json carries machine-readable semantics on top of the standard schema, derived from the same contract annotations the Effect SDK uses (no second taxonomy):
| Extension | Where | Meaning |
| -------------- | ----------------- | ------------------------------------------------------------------------------------------------- |
| x-category | error schemas | The error's semantic categories (e.g. ["throttling", "transient"]). Omitted when uncategorized. |
| x-retryable | error schemas | true when the error is safe to retry. |
| x-idempotent | operations | true when the operation accepts an Idempotency-Key header. |
| x-sensitive | schema properties | true for secret-bearing fields (plain string on the wire; Redacted in the Effect SDK). |
These let MCP and third-party consumers read retry/idempotency/secret semantics straight from the spec.
Usage Examples
Send Email Through Unified Messages
const contact = await client.contacts.findOrCreate({
name: "Ada Lovelace",
email: "[email protected]",
});
const response = await client.messages.send({
to: [contact],
channel: "email",
email: {
subject: "Welcome",
html: "<h1>Welcome to Samva</h1>",
text: "Welcome to Samva",
},
metadata: {
campaign: "welcome",
},
});Native Effect Send Email
import * as Email from "samva/effect/email";
const message =
yield *
Email.send({
to: "[email protected]",
subject: "Welcome",
html: "<h1>Welcome to Samva</h1>",
});List Conversations
const response = await client.conversations.list({
page: "1",
limit: "20",
status: "active",
});Schedule a Message
const scheduled = await client.scheduledMessages.create({
send: {
to: [{ email: "[email protected]" }],
channel: "email",
email: { subject: "Reminder", html: "<p>See you tomorrow.</p>" },
},
scheduledFor: "2026-08-01T09:00:00Z",
});
// Later, cancel it before it dispatches
await client.scheduledMessages.cancel({ id: scheduled.id });Create and Run a Campaign
const campaign = await client.campaigns.create({
name: "August newsletter",
channel: "email",
content: {
channel: "email",
email: { subject: "What's new", html: "<h1>Updates</h1>" },
},
audience: { includeTags: ["subscribers"], excludeTags: ["bounced"] },
});
const run = await client.campaigns.scheduleRun({
id: campaign.id,
scheduledFor: "2026-08-01T09:00:00Z",
});
const recipients = await client.campaigns.listRecipients({
id: campaign.id,
runId: run.id,
});Custom Headers
const client = createClient({
apiKey: "<samva-api-key>",
headers: {
"X-Custom-Header": "value",
},
});Error Handling
import { RateLimitedError, SamvaApiError, SamvaTransportError } from "samva";
try {
const message = await client.email.send({
to: "[email protected]",
subject: "Test",
html: "<p>Test</p>",
});
console.log(message.id);
} catch (error) {
if (error instanceof RateLimitedError) {
console.error(`Retry after ${error.retryAfterSeconds} seconds`);
} else if (error instanceof SamvaApiError) {
console.error(error._tag, error.status, error.message);
} else if (error instanceof SamvaTransportError) {
console.error("Transport failure", error.cause);
} else {
throw error;
}
}Every ergonomic method returns its decoded success value and throws a generated SamvaApiError
subclass for failures declared by OpenAPI. Network and malformed-response failures throw
SamvaTransportError.
Raw response access
const result = await client.raw.messages.send({
body: {
to: [{ contactId: "contact-id" }],
channel: "email",
email: { subject: "Hello", text: "Hello" },
},
});
if (result.error) console.error(result.response?.status, result.error);Use client.raw only when you need the generated { data, error, request, response } envelope or
transport-level request options.
Type Safety
All request and response types are generated from OpenAPI schemas:
import type { MessagesSendData } from "samva";
const requestData: MessagesSendData = {
body: {
to: [{ contactId: "contact-id" }],
channel: "email",
email: {
subject: "Hello",
html: "<p>Hello World</p>",
},
},
};
const response = await client.messages.send(requestData.body);