# Get workspace context Source: https://docs.senderkit.com/api-reference/context/get-workspace-context https://www.senderkit.com/openapi.yaml get /v1/context Returns the workspace the API key belongs to and the environment the key operates in. Useful to confirm which workspace and mode subsequent calls will affect before sending. # Claim a custom inbound domain Source: https://docs.senderkit.com/api-reference/inbound/claim-a-custom-inbound-domain https://www.senderkit.com/openapi.yaml post /v1/inbound/domains Claims a domain or subdomain you own for receiving mail. Publish the returned `records`, then poll `GET /v1/inbound/domains/{id}` until `status` is `verified`. Publishing our MX replaces wherever the domain's mail currently goes. If the domain already has a live mail host, this call 409s with `existing_mx` and the current hosts; retry with `acknowledgeExistingMx: true` once you've confirmed the redirect is intended. # Create an inbound address Source: https://docs.senderkit.com/api-reference/inbound/create-an-inbound-address https://www.senderkit.com/openapi.yaml post /v1/inbound/addresses Provisions a new address on the workspace's shared receiving domain. The domain itself is created lazily on first call. Omit `localPart` to get an unguessable generated one (`rcv-xxxxxxxxxx`); a caller-supplied `localPart` must be unique on the domain. # Delete an inbound address Source: https://docs.senderkit.com/api-reference/inbound/delete-an-inbound-address https://www.senderkit.com/openapi.yaml delete /v1/inbound/addresses/{publicId} Soft-deletes the address; mail sent to it afterward is dropped like any other unmatched recipient. # Delete an inbound domain Source: https://docs.senderkit.com/api-reference/inbound/delete-an-inbound-domain https://www.senderkit.com/openapi.yaml delete /v1/inbound/domains/{id} Soft-deletes the domain and its addresses; mail sent to it afterward is dropped like any other unmatched recipient. The auto-provisioned shared domain cannot be deleted. # Get a received message Source: https://docs.senderkit.com/api-reference/inbound/get-a-received-message https://www.senderkit.com/openapi.yaml get /v1/inbound/messages/{publicId} Retrieve a single received message, including parsed body, headers, verdicts, and links to its attachments and raw MIME source. # Get an attachment Source: https://docs.senderkit.com/api-reference/inbound/get-an-attachment https://www.senderkit.com/openapi.yaml get /v1/inbound/messages/{publicId}/attachments/{index} Streams one attachment's bytes, extracted on demand from the raw MIME (attachments are not stored separately). `index` matches the `attachments[].index` on the message resource. Subject to the same 30-day retention as the raw message. # Get an inbound domain Source: https://docs.senderkit.com/api-reference/inbound/get-an-inbound-domain https://www.senderkit.com/openapi.yaml get /v1/inbound/domains/{id} Retrieve one inbound domain, including its DNS records and verification status. # Get the raw MIME source Source: https://docs.senderkit.com/api-reference/inbound/get-the-raw-mime-source https://www.senderkit.com/openapi.yaml get /v1/inbound/messages/{publicId}/raw Returns the original message as stored in S3 (`message/rfc822`). The object expires 30 days after receipt; after that this returns `410`. # List inbound addresses Source: https://docs.senderkit.com/api-reference/inbound/list-inbound-addresses https://www.senderkit.com/openapi.yaml get /v1/inbound/addresses Returns every inbound address provisioned on the workspace's shared receiving domain (`{slug}.in.senderkit.email`), oldest first. # List inbound domains Source: https://docs.senderkit.com/api-reference/inbound/list-inbound-domains https://www.senderkit.com/openapi.yaml get /v1/inbound/domains Returns every inbound domain on the workspace — the auto-provisioned shared receiving domain plus any claimed custom domains — oldest first. # List received messages Source: https://docs.senderkit.com/api-reference/inbound/list-received-messages https://www.senderkit.com/openapi.yaml get /v1/inbound/messages Returns received-mail summaries for the workspace and API key environment, newest first. # API Reference Source: https://docs.senderkit.com/api-reference/introduction The SenderKit public REST API for sending and inspecting messages. This page introduces the SenderKit public REST API. The endpoint pages in this section are generated from the OpenAPI specification and cover dispatching messages, listing message history, and reading or rendering stored templates. ## Base URL ``` https://api.senderkit.com ``` ## Authentication All endpoints require a Bearer API key. The `sk_live_` / `sk_test_` prefix selects the environment; the prefix is a hint for humans, the secret is the full token. ```bash theme={null} Authorization: Bearer sk_live_xxx ``` See [Authentication](/authentication) for how to create and manage keys. ## Sends are asynchronous `POST /v1/send` enqueues the message and returns `202` with `status: "queued"` (or `"scheduled"` when the request includes a future `scheduledAt`). Delivery happens out of band — poll `GET /v1/messages` to observe progress. # Cancel a message Source: https://docs.senderkit.com/api-reference/messages/cancel-a-message https://www.senderkit.com/openapi.yaml delete /v1/messages/{id} Cancel a still-pending message before it is dispatched. Only messages whose status is `scheduled` or `queued` can be canceled; anything past that (rendered, dispatched, sent, delivered, failed, opted_out, suppressed, blocked, canceled) is already committed to a provider or terminal. Cancellation is race-safe: if the dispatcher advances the message while the cancel request is in flight, the request fails with `409` and the message's freshly observed status. # Get a message Source: https://docs.senderkit.com/api-reference/messages/get-a-message https://www.senderkit.com/openapi.yaml get /v1/messages/{id} Retrieve a single message by its public ID. The message must belong to the authenticated workspace and match the API key's environment (live or test). # List messages Source: https://docs.senderkit.com/api-reference/messages/list-messages https://www.senderkit.com/openapi.yaml get /v1/messages Returns messages for the authenticated workspace and environment, newest first, with cursor pagination. Setting `tail=1` switches the endpoint to a Server-Sent Events stream (`text/event-stream`) that backfills the most recent 50 messages and then pushes new ones live. Streams are closed by the server after 30 minutes; EventSource clients reconnect automatically and receive a fresh backfill. The schemas below describe the non-streaming JSON response. # Send a message Source: https://docs.senderkit.com/api-reference/send/send-a-message https://www.senderkit.com/openapi.yaml post /v1/send Enqueue a message for delivery. Provide **either** `template` (send a stored template) **or** `content` (inline raw content) — never both. Returns `202` immediately; the message is dispatched asynchronously. # Get a template Source: https://docs.senderkit.com/api-reference/templates/get-a-template https://www.senderkit.com/openapi.yaml get /v1/templates/{slug} # List templates Source: https://docs.senderkit.com/api-reference/templates/list-templates https://www.senderkit.com/openapi.yaml get /v1/templates # Render a template Source: https://docs.senderkit.com/api-reference/templates/render-a-template https://www.senderkit.com/openapi.yaml post /v1/templates/{slug}/render Render the template's current published version with the supplied variables, without sending. Returns the rendered output for the template's channel plus any variable paths that were referenced but not provided. # Authentication Source: https://docs.senderkit.com/authentication Create API keys and authenticate requests to SenderKit. Every request to the SenderKit API authenticates with an API key passed as a Bearer token: ``` Authorization: Bearer sk_live_... ``` ## Getting a key Create keys in the [dashboard](https://senderkit.com/app/dashboard). The plaintext secret is shown once at creation and stored only as a SHA-256 hash afterward — copy it then, because it can't be retrieved later. The SDK and CLI read the key from the `SENDERKIT_API_KEY` environment variable. ## Live and test keys Keys carry an `sk_live_` or `sk_test_` prefix that selects the environment: * **`sk_live_`** delivers real notifications through your connected providers. * **`sk_test_`** never calls providers — use it for local development and CI. The prefix is only a hint for humans; the secret is the full token. SenderKit derives live-versus-test mode from the prefix server-side, so the same code path behaves correctly just by swapping the key. ## Scopes API keys carry an optional scope set that limits what a credential can do. You pick scopes in the dashboard at creation time; all four are selected by default. | Scope | Authorizes | | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `read` | `GET /v1/messages`, `GET /v1/messages/{id}`, `GET /v1/context`, `GET /v1/templates`, `GET /v1/templates/{slug}`, and template render | | `send` | `POST /v1/send`, `POST /v1/send-raw`, and draft template operations | | `cancel` | `DELETE /v1/messages/{id}` | | `inbound` | Managing [inbound addresses](/concepts/inbound) and reading received mail — `GET`/`POST /v1/inbound/addresses`, `DELETE /v1/inbound/addresses/{id}`, `GET /v1/inbound/messages`, `GET /v1/inbound/messages/{id}`, and their attachment/raw sub-resources | A key created without selecting any explicit scope is **unscoped** and has full access — all keys minted before scope enforcement are unscoped by default and continue to work without changes. A scoped key used outside its grant returns `403` with `code: "insufficient_scope"`. The SDK surfaces this as [`SenderKitPermissionError`](/sdks/typescript#error-handling), distinct from the `SenderKitAuthenticationError` raised for `401`s, so you can handle the two cases separately. ## Revocation To retire a key, revoke it in the dashboard — a revoked or otherwise invalid key returns `401 Unauthorized`. There's no in-place rotation: to rotate, create a new key, deploy it, then revoke the old one. ## Authenticating a request ```bash curl theme={null} curl https://api.senderkit.com/v1/send \ -H "Authorization: Bearer $SENDERKIT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "template": "welcome", "to": "user@example.com", "vars": { "name": "Ada" } }' ``` ```ts SDK (from env var) theme={null} import { SenderKit } from "@senderkit/sdk"; // Reads the key you pass in from the environment. const senderkit = new SenderKit({ apiKey: process.env.SENDERKIT_API_KEY! }); ``` ```ts SDK (explicit key) theme={null} import { SenderKit } from "@senderkit/sdk"; const senderkit = new SenderKit({ apiKey: "sk_live_..." }); ``` Treat API keys as secrets. Keep them server-side only — never ship them in client-side bundles or commit them to source control. Store them in your platform's environment variables or a secrets manager. # Changelog Source: https://docs.senderkit.com/changelog Notable changes to SenderKit's client libraries. This page tracks releases of SenderKit's developer libraries — the [TypeScript SDK](/sdks/typescript), the [PHP SDK](/sdks/php), the [CLI](/cli/installation), and the [MCP server](/mcp/overview). The SenderKit API and dashboard are still pre-launch. Only the client libraries have a public release history today, so that's all this page documents. New entries land here as each library ships a version. ## Full-text search over your message history `GET /v1/messages` (and the dashboard's **Logs** page) now accepts a `search` query param: a case-insensitive substring match across the message id, recipient, template, and your `metadata` keys/values. It composes with the existing `status`/`channel`/`template`/`metadata` filters and cursor pagination, and is capped at 512 characters. See [Querying messages](/concepts/messages#querying-messages). ## SDK/MCP release: an `outputSchema` on every tool **`@senderkit/sdk@0.16.0`** * **Every MCP manifest tool now declares an `outputSchema`** (`McpToolSpec.outputSchema`, plus one exported `*Output` Zod shape per tool in `@senderkit/sdk/mcp`) describing its structured result. The hosted server at `mcp.senderkit.com` advertises these via `tools/list` and returns every result as `structuredContent` alongside the existing JSON text. See [Structured results](/mcp/tools#structured-results). * **Message results no longer include internal identifiers** on the hosted MCP server: `senderkit_messages_list` and `senderkit_messages_get` return the public `publicId` but not the database row id, workspace id, or provider-connection id. The REST API's `GET /v1/messages` responses are unchanged. * Exports the `TEMPLATE_STATUSES`, `INBOUND_MESSAGE_STATUSES`, `INBOUND_DOMAIN_KINDS`, and `INBOUND_DOMAIN_STATUSES` enums the shapes use. * The CLI-bundled stdio server is unchanged in this release (it does not declare output schemas yet). ## Mailgun connections no longer strand messages on a temporary delay A Mailgun connection could previously mark a message `failed` (and fire a `message.failed` [webhook](/webhooks)) for what was actually a temporary delivery delay — greylisting or a transient rejection the receiving mail server was still going to retry. If Mailgun went on to deliver the message anyway, that later delivery could no longer update the message's status, so it stayed stuck at `failed` despite having arrived. Only a genuinely permanent failure is now treated as terminal; a temporary delay leaves the message in its prior status until a real `delivered` or `failed` outcome arrives. No action is needed on your end. ## Twilio SMS delivery status tracking now works Twilio SMS connections could never record delivery status — messages stayed at `sent`, and the `message.delivered` / `message.failed` [webhooks](/webhooks) never fired for SMS sends, regardless of how the connection was configured. This is fixed. Delivery status tracking for a Twilio connection is opt-in: set the connection's **Status callback URL (optional)** field to the exact status-callback URL configured in Twilio for that number or Messaging Service (Twilio signs it verbatim, so it must match character-for-character, including any trailing slash). Sends work the same either way — this field only affects whether status updates come back. ## SDK/CLI/MCP release: explicit safety hints on every tool **`@senderkit/sdk@0.15.0` / `@senderkit/cli@0.10.0`** * **Every MCP tool now states all three safety hints explicitly** — `readOnlyHint`, `openWorldHint`, and `destructiveHint` — instead of leaving some to a client's inferred defaults. [`senderkit_send`](/mcp/tools#senderkit_send) and [`senderkit_send_raw`](/mcp/tools#senderkit_send_raw) are the only tools with `openWorldHint: true`, since they're the only ones that deliver messages to recipients outside SenderKit; every read-only tool now also states `openWorldHint: false` and `destructiveHint: false` directly. For TypeScript consumers, this widens the SDK's `ToolAnnotations` type: code that previously constructed a partial value (e.g. `{ readOnlyHint: true }`) must now supply all three hints. * The inbound message [`verdicts`](/concepts/inbound#reading-received-mail) field description is now provider-neutral — it describes scanning verdicts (spam/virus/SPF/DKIM) without naming the underlying receiving infrastructure. The field's shape and runtime values are unchanged. * [`senderkit_send_raw`](/mcp/tools#senderkit_send_raw)'s `scheduledAt` field documents the same "must be in the future, within 30 days" constraint as `senderkit_send`, and its `subject`/`html` fields now state they're required when `channel` is `"email"`. The Python and PHP SDKs are unaffected — these are MCP-manifest-only changes. ## SDK/CLI/MCP release: SMS recipient format **`@senderkit/sdk@0.14.2` / `@senderkit/cli@0.9.2`** The hosted API rejects a non-E.164 SMS `to` up front with `400 invalid_recipient` — for both template and raw sends — instead of accepting it and failing later at dispatch. This release brings the SDK/CLI/MCP surface in line with that contract: * **[`senderkit_send_raw`](/mcp/tools#senderkit_send_raw)'s `to` field** now spells out the per-channel recipient formats, including the E.164 requirement for `sms` — matching [`senderkit_send`](/mcp/tools#senderkit_send), which already documented it. This flows through to the `senderkit send-raw` CLI help. * The SDK README's SMS example now notes the E.164 requirement and the `400 invalid_recipient` response. No behavior change in the client itself — recipient validation stays server-side. The Python and PHP SDKs are unaffected. ## SDK/CLI/MCP release: inbound tool corrections **`@senderkit/sdk@0.14.1` / `@senderkit/cli@0.9.1`** A follow-up pass on the inbound email tools in the MCP manifest, now also the single source powering the hosted MCP server's inbound tool suite: * **[`senderkit_inbound_addresses_create`](/mcp/tools#senderkit_inbound_addresses_create) is annotated `destructiveHint: false`** — creating an address is additive and fully reversed by deleting it, so well-behaved clients no longer prompt for confirmation on it. * **`localPart` documents the real format**: 1-64 characters of `a-z`, `0-9`, `.`, `_`, `-`, must start and end alphanumeric, lowercased automatically, some names reserved — plus the `"*"` catch-all shorthand. * **`webhookEndpointId` documents the full binding behavior**: a bound endpoint receives `message.received` even if it isn't itself subscribed to that event, but it must be active and share the address's `livemode`; left unset, the event fans out to every active subscribed endpoint in the address's mode. * **`livemode` no longer implies a quota exemption.** The field now states plainly that every inbound address, test or live, counts toward the plan's inbound-address limit, and that a test-mode address's `forwardTo` is recorded as a test send rather than actually delivered. * [`senderkit_inbound_messages_list`](/mcp/tools#senderkit_inbound_messages_list) and [`senderkit_inbound_messages_get`](/mcp/tools#senderkit_inbound_messages_get) were retitled "List Inbound Messages" / "Get Inbound Message" for consistency with the rest of the tool suite. The Python and PHP SDKs are unaffected — these are MCP-manifest-only changes. ## SDK/CLI/MCP release: manifest wire-contract fixes **`@senderkit/sdk@0.14.0` / `@senderkit/cli@0.9.0`** A pass over the MCP tool manifest tightened several tool schemas to state the real wire contract: * **`cc` / `bcc` are documented as JSON arrays, capped at 50 recipients.** [`senderkit_send`](/mcp/tools#senderkit_send) and [`senderkit_send_raw`](/mcp/tools#senderkit_send_raw) now say so directly, matching the API's existing limit. The CLI keeps accepting comma-separated values via its own flag help — nothing changes for CLI users. * **List limits are enforced in the schema, not just the description.** [`senderkit_messages_list.limit`](/mcp/tools#senderkit_messages_list) is bounded 1–200 (default 50); [`senderkit_inbound_messages_list.limit`](/mcp/tools#senderkit_inbound_messages_list) enforces the 1–100 range its description already claimed. An out-of-range value now fails validation instead of being silently clamped server-side. * **Additive writes carry an honest annotation.** Tools that only create something new — `senderkit_templates_create` and `senderkit_inbound_addresses_create` — now declare `destructiveHint: false` instead of `true`, so well-behaved MCP clients don't demand confirmation for a reversible, non-destructive call. The Python and PHP SDKs are unaffected — these are MCP-manifest-only changes. ## Clearer send errors, plus stricter recipient and cursor validation A few consistency fixes to the hosted MCP server and the REST API it wraps — no SDK/CLI version bump required: * **Field-specific validation errors.** A malformed [`senderkit_send`](/mcp/tools#senderkit_send) / [`senderkit_send_raw`](/mcp/tools#senderkit_send_raw) call — e.g. an email send missing `subject` or `html` — now names the offending field (`invalid_request: subject Required`) instead of a generic `Invalid input` message. The equivalent `POST /v1/send` / `POST /v1/send-raw` REST error `issues` array gained the same precision. * **SMS recipients are validated as E.164.** `to` on an SMS send must now be a valid E.164 phone number (e.g. `+14155551234`); previously a malformed value was accepted and simulated as delivered instead of being rejected. * **`before` on inbound message listing is validated strictly.** [`senderkit_inbound_messages_list.before`](/mcp/tools#senderkit_inbound_messages_list) and `GET /v1/inbound/messages?before=` now reject a non-ISO-8601 value with a clear `invalid_request` error instead of silently returning an empty page. ## SDK/CLI/MCP release: inbound namespace + suppressed status type **`@senderkit/sdk@0.13.0` / `@senderkit/cli@0.8.0`** This release brings the TypeScript SDK, CLI, and MCP server in step with three changes already covered by earlier entries on this page: * **`client.inbound`** — a new namespace with `addresses` (`list`, `create`, `delete`), `messages` (`list`, `get`, `raw`, `attachment`), and `domains` (`list`, `create`, `delete`), matching the [Inbound Email](/concepts/inbound) API in full — including the custom-domain and catch-all support from the July 26 entry below. The CLI gains eight `senderkit inbound *` commands, and the MCP server eight matching tools; see the [Tools reference](/mcp/tools#inbound) for parameters. * **`suppressed`** is now included in the SDK's exported `Message.status` values (the status itself was introduced in the July 26 entry below). * **`openedAt`** / **`clickedAt`** on `Message` — the fields the July 8 entry below described ship in this release. The Python and PHP SDKs receive the equivalent inbound/suppressed/engagement support in their own upcoming releases. ## Retry a failed send from the Logs page Failed messages in the dashboard's **Logs** view now have a **Retry send** action in the row menu. It re-queues the message for another dispatch attempt — the status badge flips to `queued` and the message follows the normal lifecycle from there, including the usual suppression and content checks. Only messages with a `failed` status are retryable. ## `opted_out` now only reflects sends skipped before dispatch A recipient unsubscribing or complaining **after** a message was already `delivered` (or `failed`) previously flipped that message's `status` to `opted_out`, discarding its actual delivery outcome. `opted_out` now means exactly one thing: the send was skipped before dispatch because the recipient had already opted out. A delivered message stays `delivered` even if the recipient unsubscribes from it afterward. * **New `message.complained` [webhook event](/webhooks#events)**, separate from `message.opted_out`. A spam complaint now fires both events; a plain unsubscribe fires `message.opted_out` alone. * `messages.list({ status: "opted_out" })` and the equivalent CLI/MCP status filters now match only pre-send suppressions, not post-delivery unsubscribes or complaints. * See [Messages → lifecycle](/concepts/messages#the-message-lifecycle) for the updated status reference. Filtering by `status: "opted_out"` was the de facto way to find a recipient's unsubscribe/complaint history; this fix narrows it to pre-send skips only, with no replacement query yet. Subscribe to `message.opted_out` / `message.complained` to track consent events going forward. ## `suppressed` message status Managed sending (AWS SES) now runs **SES email validation** against every recipient before attempting delivery. When AWS rejects a recipient as invalid, or as already on the account's suppression list, the message lands in a new terminal `suppressed` status instead of `failed` — keeping bounce metrics and your logs accurate about what was actually attempted versus what never left the building. A per-workspace suppression-rate signal is shown on the channel detail page. `suppressed` only applies to managed sending; bring-your-own-provider connections are unaffected. The `Message.status` type in the TypeScript, PHP, and Python SDKs will include `"suppressed"` in an upcoming release — until then, treat it as a value the API and dashboard can already return. Update: as of `@senderkit/sdk@0.13.0` (see the [July 29, 2026 entry](#july-29-2026) above), `suppressed` is included in the TypeScript SDK's exported `Message.status` values. The Python and PHP SDKs pick it up in their own upcoming releases. Update: a matching **`message.suppressed`** [webhook event](/webhooks#events) fires whenever a send lands in this status, so you can react to it without polling. See the [Webhooks → Events](/webhooks#events) reference. ## Custom inbound domains and catch-all addresses Two additions to inbound email: * **Custom inbound domains** — receive mail at addresses on your own domain (e.g. `inbound.acme.com`) instead of only the shared `{slug}.in.senderkit.email`. Claim a domain from the dashboard or `POST /v1/inbound/domains`, publish the DNS records it returns (an MX, plus a DKIM TXT proving ownership — skipped if the domain already has a verified sending domain, which reuses its identity), and a background sweep verifies it. New `senderkit_inbound_domains_list` / `_create` / `_delete` MCP tools mirror the REST surface. * **Catch-all addresses** — create an address with `localPart: "*"` to receive mail sent to any local part on a domain that no exact address already claims. An exact-match address always takes priority over the catch-all. Both count against the same plan-gated inbound address cap as any other address. Update: full reference detail for both — DNS records, verification states, and API/MCP parameters — is now live on [Inbound Email → Custom domains](/concepts/inbound#custom-domains) and in the [MCP tools reference](/mcp/tools#inbound). ## Receive email, not just send it Workspaces can now provision **inbound addresses** on a shared receiving domain (`{slug}.in.senderkit.email`) and get mail sent to them parsed and delivered as a new **`message.received`** [webhook event](/webhooks#events) — with an optional forward to a real inbox. * **New Inbound dashboard page** — create and remove addresses, optionally binding one to a specific webhook endpoint or a forwarding address. * **New `/v1/inbound/*` API routes** — list/create/delete addresses, list and read received messages (parsed body, headers, SES spam/virus/SPF/DKIM verdicts), and fetch attachments or the original raw MIME source (30-day retention). Requires an API key with the new **`inbound`** [scope](/authentication#scopes). * **Plan-gated address caps** — Free 1, Starter 10, Pro 50. A received live message counts against the same monthly message quota as outbound sends. See [Inbound Email](/concepts/inbound) for the full reference. ## Style emails without touching HTML Email templates gained a new **Design** tab in the editor for setting a per-template visual theme: * **Fonts** — independent heading and body fonts, picked from a curated set of web-safe and Google fonts, or a custom Google Font by name. * **Colors** — page background, content background, and body, heading, and link text colors. * **Content width** — the email's maximum content width, 320–900px. The theme applies consistently in the editor canvas, the Preview tab, and sent messages. It's fully optional — templates with no theme set render exactly as before. Mail clients without web-font support (Gmail, Outlook) fall back to a close web-safe equivalent for custom fonts. See [Templates → Styling with the Design tab](/concepts/templates#styling-with-the-design-tab-email-only). ## Resized images no longer squeeze on narrow screens Images resized in the email editor previously kept a fixed height on mobile, so shrinking the width to fit a narrow screen distorted the aspect ratio. Resized images now scale proportionally on any screen size. A new size toggle appears when an image is selected, letting you choose whether width or height stays fixed after a resize — useful for a fixed-height banner or badge that shouldn't scale like a normal inline image. ## Bounce/complaint/delivery events fixed for identity-level SES notifications Bring-your-own-provider SES connections that follow AWS's default bounce/complaint setup — SNS notifications wired directly onto the verified identity, without a configuration set — previously had their bounce, complaint, and delivery events silently dropped. Affected messages stayed stuck at `sent`, and complaint suppression / the `message.opted_out` [webhook](/webhooks) never fired. Both notification shapes SES can send are now recognized; no changes needed on your end. ## Preheader now renders as the actual inbox preview snippet The **preheader** field (set in the template editor, and previously visible only in the editor's own inbox preview mock) now reaches recipients' inboxes. Mail clients build the preview snippet shown next to the subject line from the message itself, and that wasn't happening consistently — so the snippet recipients saw was often scraped from the body instead of the preheader you set. This is fixed across every provider connection; no changes needed on your end. ## `cc`, `bcc`, `replyTo`, and `attachments` now work on every connection type These [send-time envelope fields](/sdks/typescript#send) previously had no effect on some provider connections: the send would succeed, but cc/bcc recipients wouldn't receive the message, replies would go to the From address instead of `replyTo`, and attachments would be silently dropped. All four fields now behave identically across every supported provider, including managed sending. No API or field changes — this is a behavior fix only. ## Sender display names are now RFC-correct on every provider Display names containing commas or other special characters (for example, `Acme, Inc.`) are now correctly quoted in the `From` header on every provider connection — previously an unquoted name with a comma could produce a malformed header. Display names (`fromName`) are also now consistently applied on every provider connection. ## Recipient reactivations are no longer treated as an opt-out A resubscribe/reactivation event from a provider could previously be misclassified the same way as an unsubscribe, incorrectly flipping the message to `opted_out` status and firing a `message.opted_out` [webhook](/webhooks). Reactivation events are no longer treated as opt-outs; only genuine suppressions do. ## More reliable status updates on inbound provider events Some provider delivery, bounce, and engagement webhook events could fail to match their originating message, leaving affected messages from that provider connection stuck at `sent` instead of progressing to `delivered` / `failed`, with opens and clicks not recorded. This correlation issue is fixed; no action is needed on your end. ## Provider-reported opens and clicks on messages SenderKit now surfaces provider-reported email **opens** and **link clicks** on messages. Both are read-only and set once, on the first occurrence — later opens/clicks never update them. * `Message` gains **`openedAt`** and **`clickedAt`** — ISO 8601 strings, or `null` until the event happens — returned by `messages.get` and `messages.list`. * `senderkit messages get` prints both fields. * Two new subscribable [webhook](/webhooks) events: **`message.opened`** and **`message.clicked`**. These are engagement signals only — they never change a message's `status` (`delivered` stays the terminal state). The `message.clicked` payload additionally carries the clicked `link`. This ships as a minor version bump — `@senderkit/sdk@0.13.0` / `@senderkit/cli@0.8.0` — in the next release. The Python and PHP SDKs receive the equivalent `openedAt`/`clickedAt` fields in their own upcoming releases. Update: `@senderkit/sdk@0.13.0` / `@senderkit/cli@0.8.0` has shipped — see the [July 29, 2026 entry](#july-29-2026) above. ## Per-message From overrides, now identical on templated and raw sends **`@senderkit/sdk@0.12.0` / `@senderkit/cli@0.7.0`** Email sends can now override both parts of the From header, on **both** `send()` (templated) and `sendRaw()` (raw): * **`from`** — From address override (bare address). Previously accepted on `sendRaw()` only; now also accepted on `send()`. * **`fromName`** — new on both — From display name override, rendered as `Name
`. Max 128 characters; no control characters or angle brackets. Either can be set on its own; both fall back to the provider connection's configured values. On managed sending, the `from` address is honored only on the workspace's verified sending domain, while `fromName` always applies. * **CLI:** `senderkit send` and `senderkit send-raw` gain `--from` / `--from-name`. * **MCP:** `senderkit_send` and `senderkit_send_raw` gain matching `from` / `fromName` inputs. The Python and PHP SDKs receive the equivalent fields in their own releases. ## `blockedReason` removed from the SDK type **`@senderkit/sdk@0.11.0` / `@senderkit/cli@0.6.4`** The [June 19 entry](#june-19-2026) below noted that `blockedReason` was retained on the `Message` type but never populated by the customer API. As of this release the property is removed from the type entirely — referencing `message.blockedReason` in TypeScript is now a compile error rather than an always-`undefined` runtime value. Nothing else changes: `blocked` remains a valid `Message.status` value, and `messages.list({ status: "blocked" })` / `--status blocked` / `senderkit_messages_list` still accept it as a filter. If you already branch on `status === "blocked"` you're unaffected; if you referenced `blockedReason` anywhere, remove that reference. ## Inbound webhook retries are now deduplicated Sources that deliver to a SenderKit inbound hook endpoint — including the [Supabase auth email hook](/guides/supabase-auth-emails) — retry a delivery whenever they don't receive a prompt `2xx`, including after transient network errors. Previously, a retried delivery could enqueue the underlying email a second time, so a recipient occasionally received a duplicate confirmation, magic-link, or recovery email. Retried deliveries of the same event are now recognized and deduplicated automatically — a retry resolves without sending again. No configuration changes are needed. ## Message preview in Logs The **Dashboard → Logs** detail drawer now includes a **Preview** action — click the kebab menu on any selected message to see what was actually sent: * **Email** — renders the HTML in a sandboxed iframe (no remote resources, tracking pixels, or external fonts load inside the preview). * **SMS / push / web-push** — shows the message body as plain text. The preview is fetched on demand when you open it, so the logs list loads as fast as before. **Fidelity note:** if the template uses a shared reusable block, the preview renders the block's *current* content rather than a snapshot of what it was at send time. The drawer flags this with an **"approximate"** badge so you know the preview may differ slightly from what the recipient received. ## `blockedReason` is operator-only The `blockedReason` field is not returned in any customer-facing message read (`messages.list`, `messages.get`, the SDK, the SSE tail, or the Logs page). When the outbound abuse scanner halts a send, the customer-visible message timeline records only a generic notice: **"Blocked by automated content safety checks."** The detailed signal breakdown (which heuristic fired, LLM confidence score, etc.) is kept operator-only in the admin console and is intentionally not disclosed to senders — surfacing detection signals would help bad actors evade them. The `Message.blockedReason` property in the SDK type (`string | null | undefined`) is retained as an optional field but is never populated in customer API responses. (Update: as of the [July 6, 2026 entry](#july-6-2026) above, the property has been removed from the SDK type entirely.) The earlier June 19 changelog entry described `blockedReason` as containing human-readable detail (e.g. `"High-confidence phishing content detected"`) — that description was incorrect and has been superseded by this entry. ### What still works * `messages.list({ status: "blocked" })` — filtering for blocked messages works as documented. Only `blockedReason` is withheld; every other message field is present. * `Message.status === "blocked"` — detection and the status transition are unchanged. ## Load older message history in Logs The **Dashboard → Logs** page previously showed only the live tail — a streaming view of your most recent sends with no way to page backward to investigate earlier messages. Older history now loads beneath the live tail as one continuous list: * **Scroll to load** — as you scroll toward the bottom, the next 50 messages load automatically. A manual **"Load older messages"** button is available as a fallback. * **Live updates while reading back** — when you're scrolled away from the top, incoming live messages are buffered behind a **"↑ N new messages"** pill instead of shifting the viewport. Returning to the top flushes the buffer and resumes the live tail. * **"Beginning of history"** footer appears once all available records have loaded. * The client-side text filter applies to all loaded messages combined. The underlying API (`GET /v1/messages?cursor=…`) is unchanged — the dashboard now surfaces its pagination to you directly. ## `blocked` message status **`@senderkit/sdk@0.10.0` / `@senderkit/cli@0.6.3`** SenderKit now runs outbound anti-phishing detection over email and SMS content before handing a message to a provider. A flagged send is halted and the message lands in a new terminal `blocked` status. ### What's new * **`blocked` status** — `Message.status` can now be `"blocked"`. A blocked message was stopped by the abuse scanner before provider dispatch and will not be retried. The message timeline records a generic notice; detection details are operator-only (see the [June 19 entry](#june-19-2026) for clarification). * **`messages.list({ status: "blocked" })`** — the status filter now accepts `"blocked"` in the SDK, CLI (`--status blocked`), and MCP (`senderkit_messages_list`). Before this release, filtering by `"blocked"` was rejected as an invalid enum value. The synchronous send response is unchanged — a blocked message still starts as `queued`; the abort happens asynchronously during content scanning. See [Messages → lifecycle](/concepts/messages#the-message-lifecycle) for the full status reference. ## Starter plan + pricing restructure SenderKit's pricing now has four tiers: | Plan | Price | Templates | Messages/month | Custom domain | | ------- | ------- | --------- | -------------- | ------------- | | Free | \$0 | 10 | 500 | No | | Starter | \$5/mo | Unlimited | 5,000 | Yes | | Pro | \$19/mo | Unlimited | 50,000 | Yes | | Custom | Contact | Unlimited | Unlimited | Yes | ### What changed * **Free** — template cap raised from 5 → **10**; message cap lowered from 3,000 → **500**/month. The free tier is now scoped for evaluation and low-volume personal projects rather than production transactional mail. * **Starter** ✨ **new** — \$5/month: unlimited templates, 5,000 messages/month, and access to **custom sending domains** for DKIM alignment. * **Pro** — now has an enforced **50,000 messages/month** cap (was uncapped). Price and unlimited templates are unchanged. * **Custom sending domains** are now available on **any paid plan** (Starter and Pro), not just Pro. See [Channels → Custom sending domains](/concepts/channels-and-providers#custom-sending-domains). ### Built-in email sender caps The built-in `@tx.senderkit.email` sender enforces caps to protect the shared domain's reputation. These caps are **separate from and lower than** the plan's overall message quota — verifying a custom sending domain unlocks the higher tier: | Plan | Shared `@tx.senderkit.email` | Verified custom domain | | ------- | ---------------------------- | ----------------------- | | Free | 500/month, 50/day | — | | Starter | 1,500/month, 150/day | 5,000/month, 500/day | | Pro | 15,000/month, 1,500/day | 50,000/month, 5,000/day | Connecting your own BYOP email provider (Postmark, SES, etc.) routes through that provider instead and bypasses the built-in caps entirely. See [Channels → Built-in email sender](/concepts/channels-and-providers#built-in-email-sender) for full details. ### Upgrading Existing free workspaces are not automatically migrated — you keep your current limits until you choose to upgrade. Select a plan from **Dashboard → Billing**. ## Route Supabase auth emails through SenderKit A new **Integrations** page in the SenderKit dashboard lets you connect your Supabase project and route all of its auth emails through SenderKit in one click — sign-up confirmation, magic link, password reset, invite, email change, and reauthentication. ### One-click setup Go to **Dashboard → Integrations → Connect Supabase**. Authorize SenderKit, pick a project, and SenderKit automatically: * Provisions a signed inbound webhook endpoint * Seeds and publishes six curated auth templates (`supabase-signup`, `supabase-magiclink`, `supabase-recovery`, `supabase-invite`, `supabase-email-change`, `supabase-reauthentication`) * Configures Supabase's Send Email Hook to point at the endpoint After connecting, customize the seeded templates in **Templates** to match your brand. Verify a custom sending domain under **Channels → Domains** for DKIM alignment and best inbox placement. See the [Supabase auth emails guide](/guides/supabase-auth-emails) for template variable reference, limits, and troubleshooting. ## MCP tool manifest sync Minor improvements to the tool descriptions in `@senderkit/sdk` and the CLI-bundled MCP server, keeping them in sync with the hosted endpoint at `mcp.senderkit.com`: * **`senderkit_send.to`** now explicitly covers all four channels: email address, E.164 phone number, push device token, and JSON-encoded web-push `PushSubscription`. * **`senderkit_messages_list.status`** is now a strict enum in the tool schema (`scheduled`, `queued`, `rendered`, `dispatched`, `sent`, `delivered`, `failed`, `opted_out`, `canceled`), matching the API's behavior of rejecting unknown statuses with `400`. The CLI validates `--status` client-side and lists choices in `--help`. * **Template slugs** are documented as always lowercase in the `senderkit_send` `template` parameter and `senderkit_templates_get` `slug` parameter. Slugs are canonicalized to lowercase on every write path (existing mixed-case slugs continue to resolve, but new slugs are created lowercase). ## PHP SDK: `senderkit/senderkit-php` v0.2.0 The official PHP SDK is now available. It ships as three Composer packages: * **`senderkit/senderkit-php`** — framework-agnostic core (PSR-18 HTTP client, PHP 8.1+). * **`senderkit/senderkit-laravel`** — service provider, `SenderKit` facade, `senderkit` notification channel (all four channels), `senderkit` mail transport for existing Mailables, and `VerifyWebhookSignature` middleware. * **`senderkit/senderkit-symfony`** — bundle with autowiring and a `RequestVerifier` webhook helper. ```bash theme={null} composer require senderkit/senderkit-php # core composer require senderkit/senderkit-laravel # Laravel composer require senderkit/senderkit-symfony # Symfony ``` The `Client` class mirrors the TypeScript SDK: `send()`, `sendRaw()`, `sendBatch()`, `context()`, `messages->*`, and `templates->*`. A PSR-18 HTTP client (Guzzle or `symfony/http-client`) is auto-discovered or can be injected. ```php theme={null} use SenderKit\Client; use SenderKit\Request\TemplateSend; $sk = new Client(apiKey: getenv('SENDERKIT_API_KEY')); $result = $sk->send(new TemplateSend('welcome', 'user@example.com', ['name' => 'Ada'])); echo $result->id; // msg_… ``` See the [PHP SDK reference](/sdks/php) for the full API, Laravel notification channel, mail transport, and Symfony bundle setup. ## Custom sending domains Pro workspaces can now connect their own sending domains and send from any address on a verified domain — without the shared `@tx.senderkit.email` address appearing to recipients. **How it works:** * Add a domain in **Dashboard → Channels → Add domain** (Pro-gated; free workspaces see an upgrade prompt). * Publish four DNS records: a white-label DKIM TXT record at `senderkit._domainkey.{domain}`, an MX + SPF pair on `send.{domain}` for the custom MAIL FROM, and optionally a root SPF and DMARC record. The dashboard shows the exact values to copy and probes DNS live once records are detected. * SenderKit verifies the domain (polls every 5 minutes; **Check now** button for immediate re-check). Verification completes within minutes once records propagate; claims expire after 72 hours. * Once verified, the domain activates as a **managed connection** in Channels. It can be set as the workspace default or targeted per template. The shared `tx.senderkit.email` sender stays as the implicit fallback. **From-address override** — raw sends (`POST /v1/send-raw`, `senderkit send-raw`, `senderkit_send_raw`) now honor a `from` address whose domain exactly matches a verified custom domain: ```ts theme={null} await senderkit.sendRaw({ channel: "email", to: "customer@example.com", from: "billing@acme.com", // acme.com must be verified content: { subject: "Your invoice", html: "…" }, }); ``` Custom-domain connections have their own per-connection cap tier and billing meter, separate from the built-in shared sender caps. See [Channels → Custom sending domains](/concepts/channels-and-providers#custom-sending-domains). ## Least-privilege API key scopes API keys now carry an enforced scope set. Previously, scopes were stored but never checked — every valid key had full access. Starting with this release, a scoped key used outside its grant returns `403` with `code: "insufficient_scope"`. ### Scope model Three scopes: `read` · `send` · `cancel`. All three are granted by default when creating a key; the dashboard scope picker lets you restrict to fewer. Keys minted without explicit scopes remain **unscoped** (full access) — no migration needed, no existing integrations break. | Scope | Covers | | -------- | --------------------------------------------------------------- | | `read` | Messages, templates, context reads | | `send` | `POST /v1/send`, `POST /v1/send-raw`, draft template operations | | `cancel` | `DELETE /v1/messages/{id}` | ### TypeScript SDK (`@senderkit/sdk` v0.8.0) **New `SenderKitPermissionError`** — `403` responses now throw `SenderKitPermissionError` instead of the previous `SenderKitAuthenticationError`. Because it extends `SenderKitApiError`, existing `catch (SenderKitApiError)` blocks still handle it; add a specific `SenderKitPermissionError` branch to distinguish scope errors from auth errors: ```ts theme={null} import { SenderKitPermissionError, SenderKitAuthenticationError, } from "@senderkit/sdk"; try { await senderkit.send(…); } catch (err) { if (err instanceof SenderKitPermissionError) { // Key is valid but lacks the required scope } else if (err instanceof SenderKitAuthenticationError) { // Key is bad/missing/revoked } } ``` **New `ApiScope` type** — `"read" | "send" | "cancel"`, exported from `@senderkit/sdk`. ### CLI (`@senderkit/cli` v0.6.1) A `403` is now reported as **"Permission denied: … The API key is valid but lacks the required scope."** (JSON: `type: "permission"`) instead of the previous misleading "Authentication failed" message. See [Authentication → Scopes](/authentication#scopes) and the [SDK error reference](/sdks/typescript#error-handling). ## `senderkit mcp install` now defaults to hosted OAuth `senderkit mcp install` now writes a **hosted endpoint config** (`url`-only, pointing at `https://mcp.senderkit.com`) with OAuth by default — no API key is stored on disk and your MCP client handles sign-in. Previously the default was a local stdio subprocess with an API key. ```bash theme={null} # Hosted OAuth config for every detected client (new default — no API key stored) senderkit mcp install # Hosted with API key bearer auth instead of OAuth senderkit mcp install --api-key-auth # Local stdio subprocess (previous default, now opt-in) senderkit mcp install --local ``` Codex users get a native streamable-HTTP `url` in `config.toml` (no longer bridged through `mcp-remote`), and can sign in with `codex mcp login senderkit`. The `--remote` flag is kept as a no-op alias for back-compat (it now just selects the new default behavior). The `--local` + `--url` combination is rejected with a clear error. ## Lean template and message reads (`TemplateVersion.content` removed) **`@senderkit/sdk@0.7.0` / `@senderkit/cli@0.5.1`** `templates.get()` no longer includes a `content` field in the response — it was already `undefined` at runtime after the server stopped returning the raw template body. This release makes the TypeScript types reflect reality. ### What changed * **`TemplateVersion.content` is removed** from the `Template` type. Code that accessed `template.currentVersion.content` will now produce a TypeScript compile error. The rest of `currentVersion` (`versionNumber`, `variables`, `publishedAt`) is unchanged. * `messages.list()` and `messages.get()` also omit rendered content; `vars`, `timeline`, and `metadata` are still returned on every message. **No CLI behavior change** — `senderkit templates get` only surfaced version metadata, never raw content. ## AI-powered template creation via MCP Two new tools on the **hosted MCP endpoint** let an AI agent author SenderKit templates from a plain-language brief, without the user opening the dashboard. ### `senderkit_templates_create` Generate a new template from a brief and save it as a **draft**. The tool composes channel-native content server-side and returns a deep link to the editor for review. Nothing is sent or published until the user approves the draft. Enforces the workspace's template cap before the AI call runs. ### `senderkit_templates_regenerate` Fully rewrite an existing **draft** template from a new brief, reusing any already-declared variables. Only draft templates can be regenerated — published templates are left untouched. Both tools are available on the **hosted endpoint** (`mcp.senderkit.com`) and via OAuth; they are not available on the CLI-bundled stdio server because they require server-side AI generation. See the [Tools reference](/mcp/tools) for parameters. ## senderkit\_context tool + mode on send results ### TypeScript SDK No SDK-visible changes in this release. ### CLI New `senderkit context` command reports the active connection's live/test mode. Useful in scripts and in MCP sessions to confirm which key is active before sending. ```bash theme={null} senderkit context # mode: test # livemode: false ``` ### MCP server **New `senderkit_context` tool** — returns `{ workspaceId, mode, livemode }`. Call it before sending to confirm whether the connection is live (real delivery) or test (recorded only). Available on both the CLI stdio server and the hosted endpoint. **`mode` field on send results** — `senderkit_send` and `senderkit_send_raw` results now include a `mode` field alongside the message `id` and `status`, giving the model a post-send confirmation of which mode the message was dispatched in. **Tool titles and behaviour annotations** — every tool now exposes a human-readable `title` and a behaviour hint (`readOnlyHint` or `destructiveHint`) per the MCP protocol. These appear in compatible client UIs and are required for the Anthropic Claude Connectors Directory. **OAuth connection for claude.ai** — `mcp.senderkit.com` now acts as a full OAuth 2.1 authorization server, so claude.ai and other OAuth-only MCP clients can connect without a manual API key. Sign in with your SenderKit account and pick a workspace + mode on the consent screen. See [Installation → Connect from claude.ai](/mcp/installation#connect-from-claude-ai-oauth). ## Workspace identity in context ### TypeScript SDK **New `client.context()` method** — calls `GET /v1/context` and returns a `SenderKitContext` (`{ workspace: { id, slug, name }, mode }`). Use it to confirm which workspace an API key belongs to before sending: ```ts theme={null} const ctx = await senderkit.context(); console.log(ctx.workspace.name); // "Acme Inc" console.log(ctx.mode); // "live" ``` `SenderKitContext` is now a named export alongside the existing types. ### CLI `senderkit context` now calls `GET /v1/context` (was a local key-prefix check) and includes the workspace name and slug in its output: ```bash theme={null} senderkit context # workspace: Acme Inc # slug: acme # mode: live ``` `--json` returns `{ workspace: { id, slug, name }, mode }`. ### MCP server `senderkit_context` now returns `{ workspace: { id, slug, name }, mode }`. The `workspaceId` and `livemode` fields from v0.4.0 are replaced by the structured `workspace` object and a single `mode` string. ## Default URL corrections ### TypeScript SDK `DEFAULT_BASE_URL` changed from `https://senderkit.com/api` to `https://api.senderkit.com`. The SDK now targets `https://api.senderkit.com/v1/send` (and sibling routes) by default, matching the OpenAPI spec's canonical server. The previous apex URL triggered an apex→www redirect that stripped the `Authorization` header, causing 401s. Callers passing an explicit `baseUrl` are unaffected. ### CLI The hosted MCP default corrected from `https://mcp.senderkit.com/mcp` to `https://mcp.senderkit.com`. On the dedicated MCP host the subdomain root is the endpoint — any other path (including `/mcp`) returns 404. ## Web Push channel `web-push` (browser notifications via the Web Push Protocol + VAPID) is now a first-class channel, distinct from the mobile `push` channel. ### TypeScript SDK * `Channel` now includes `"web-push"`. * New `RawWebPushContent` type: `{ title, body, icon?, clickUrl?, badge?, data? }`. * `sendRaw({ channel: "web-push", to, content })` — `to` is the JSON-encoded browser `PushSubscription` (endpoint + `p256dh`/`auth` keys). Your application collects these after the user grants browser permission. ### CLI * `senderkit send-raw --channel web-push` with `--title`, `--body`, `--icon`, `--click-url`, `--badge`, and `--push-data` flags. ### MCP server * `senderkit_send_raw` and `senderkit_messages_list` `channel` enums now accept `"web-push"`. The `icon` and `clickUrl` inputs are available on `senderkit_send_raw`. Both the CLI stdio server and the app-hosted HTTP MCP server are updated. ### Dashboard / API The `web-push` channel is also fully supported in the SenderKit dashboard and REST API: connect a VAPID keypair, create web-push templates, and send via `POST /v1/send` or `POST /v1/send-raw`. ## CLI cc/bcc/attachments flags fixed `--cc`, `--bcc`, and `--attachments` flags on `senderkit send` and `senderkit send-raw` were advertised in `--help` but threw a Zod validation error at runtime — the CLI adapter passed them as strings while the validator expected arrays or objects. They now work correctly: * **`--cc` / `--bcc`** — accept a comma-separated string (`a@x.com,b@x.com`) or a JSON array string (`'["a@x.com","b@x.com"]'`). * **`--attachments`** — accepts a JSON array string of attachment objects. The MCP server path (real arrays/objects) is unchanged. Also fixes the SDK's `VERSION` constant and the CLI's `--version` output, which were reporting stale values. Both are now derived directly from `package.json` at build time and cannot drift. ## Initial public release The first public release of the SenderKit developer libraries. All three libraries are versioned together and published to npm at `0.2.0`. ### TypeScript SDK — `@senderkit/sdk@0.2.0` * `SenderKit` client with `send()`, `sendRaw()`, and `sendBatch()` for multi-channel delivery (email, SMS, push). * `messages.list()` (with metadata filtering), `messages.get(id)`, and `messages.cancel(id)`. * `templates.list()` and `templates.get(slug)`. * `scheduledAt` on `send` / `sendRaw` for scheduled delivery. * `client.mode` (`"live"` / `"test"`), derived from the API key prefix; the client validates that keys start with `sk_live_` or `sk_test_` at construction. * Typed error hierarchy: `SenderKitApiError`, `SenderKitAuthenticationError`, `SenderKitValidationError`, `SenderKitRateLimitError`, `SenderKitTimeoutError`, and `SenderKitNetworkError`. See the [SDK reference](/sdks/typescript) for full usage. ### CLI — `@senderkit/cli@0.2.0` * `senderkit login` for interactive authentication (API key input is hidden). * `senderkit send` and `senderkit send-raw`. * `senderkit templates list` / `get`. * `senderkit messages list` / `get` / `cancel`. * `--json` flag for structured, machine-readable output (including errors). See the [CLI docs](/cli/installation) to get started. ### MCP server — bundled in `@senderkit/cli@0.2.0` Exposes SenderKit to AI agents over the Model Context Protocol, via the `senderkit mcp` command (stdio and HTTP transports). Tools: * `senderkit_send` and `senderkit_send_raw` * `senderkit_templates_list` and `senderkit_templates_get` * `senderkit_messages_list`, `senderkit_messages_get`, and `senderkit_cancel_message` See the [MCP overview](/mcp/overview) and [tools reference](/mcp/tools). # context Source: https://docs.senderkit.com/cli/context Report the connected workspace and send mode. `senderkit context` fetches the workspace your API key belongs to and whether the connection is in live or test mode. The values come from the API rather than being derived locally, so the output is authoritative — it tells you exactly which workspace subsequent `send` calls will affect. ```bash theme={null} senderkit context ``` No arguments or flags. The command exits non-zero if the key is invalid or the API is unreachable. ## Output ``` workspace: Acme Inc slug: acme mode: live ``` | Field | What it means | | ----------- | ------------------------------------------------------------------------------------------------------- | | `workspace` | Human-readable workspace name | | `slug` | URL-safe workspace identifier | | `mode` | `live` — messages are really delivered; `test` — messages are recorded but not dispatched to a provider | `--json` returns the structured API response: ```json theme={null} { "workspace": { "id": "ws_…", "slug": "acme", "name": "Acme Inc" }, "mode": "live" } ``` ## When to use it * **Before sending** in a script — confirm the key points at the right workspace and mode before dispatching real messages. * **Debugging** — `sk_live_` / `sk_test_` key prefixes tell you mode, but not which workspace the key belongs to. `context` resolves both. ```bash theme={null} # Check before a bulk send senderkit context --json | jq -r '"workspace: \(.workspace.name), mode: \(.mode)"' ``` ## Mirrors The same information is available as: * **SDK**: `senderkit.context()` — see the [TypeScript SDK](/sdks/typescript#context). * **MCP tool**: [`senderkit_context`](/mcp/tools#senderkit_context) — call it from an AI agent before sending to confirm which workspace and mode are active. * **REST endpoint**: `GET /v1/context` — see the [API Reference](/api-reference/introduction). Dispatch a message after confirming context. Live vs test mode and what each means for delivery. # Installation Source: https://docs.senderkit.com/cli/installation Install @senderkit/cli, authenticate, and learn the global flags. `@senderkit/cli` is a single-binary `senderkit` command that sends notifications, lists templates, and inspects message history from your terminal. It wraps the same REST surface as the [TypeScript SDK](/sdks/typescript) and bundles the [MCP server](/mcp/overview). ## Install ```bash npm theme={null} npm install -g @senderkit/cli ``` ```bash pnpm theme={null} pnpm add -g @senderkit/cli ``` ```bash bun theme={null} bun add -g @senderkit/cli ``` ```bash npx theme={null} npx @senderkit/cli --help ``` Requires Node.js 18 or newer. After installing, verify it's on your path: ```bash theme={null} senderkit --version ``` ## Authenticate Every command (except `--help` and `--version`) needs an API key. The CLI looks in three places, in this order: 1. The `--api-key ` flag on the command. 2. The `SENDERKIT_API_KEY` environment variable. 3. `~/.senderkit/config.json` — populated by `senderkit login` or `senderkit config set apiKey …`. For interactive setup, `login` is the easiest path. It prompts, verifies the key against the API by listing your templates, and saves it with `0600` permissions: ```bash theme={null} senderkit login # SenderKit API key: sk_test_… # ✓ Saved API key to /Users/you/.senderkit/config.json ``` For non-interactive setup (CI provisioning, dotfiles), set the value directly: ```bash theme={null} senderkit config set apiKey sk_test_… senderkit config list # API key shown masked, e.g. sk_test_xyz…ABCD ``` Or pass it inline for one-off scripts: ```bash theme={null} SENDERKIT_API_KEY=sk_test_… senderkit templates list senderkit templates list --api-key sk_test_… ``` Mode (live vs test) is encoded in the key prefix — `sk_live_…` sends real mail; `sk_test_…` synthesizes the full [lifecycle](/concepts/messages) without leaving the building. See [Environments](/concepts/environments). ## Global flags These work on every command and resolve before the subcommand runs. API key. Overrides `SENDERKIT_API_KEY` and `~/.senderkit/config.json`. Override the API base URL. Also resolvable via `SENDERKIT_BASE_URL` or `senderkit config set baseUrl …`. Useful for staging environments and self-hosted deployments. Print the raw JSON response instead of human-readable text. The shape matches the [REST response](/api-reference/introduction) for the underlying endpoint, so it pipes cleanly into `jq` or a downstream script. Print the CLI version and exit. Print contextual help. Works at any level — `senderkit --help`, `senderkit messages list --help`. ## Managing config `~/.senderkit/config.json` stores `apiKey` and `baseUrl`. Three subcommands manage it: ```bash theme={null} senderkit config set apiKey sk_test_… senderkit config set baseUrl https://api.staging.senderkit.com senderkit config get apiKey # printed masked senderkit config list # prints all values, apiKey masked ``` The file is written with `0600` permissions so other users on the machine can't read your key. ## Output and exit codes The CLI is built for both humans and pipelines: * **Human mode (default)** prints aligned tables for list commands and `key: value` blocks for object responses, with a green `✓` on successful sends. * **JSON mode (`--json`)** prints the raw SDK response — same shape as the [REST API](/api-reference/introduction). * **Exit codes:** `0` on success, `1` on any error (auth failure, validation, rate limit, network, anything else). Errors print to `stderr`; results print to `stdout`, so you can pipe results without losing error visibility. ```bash theme={null} senderkit templates list --json | jq '.[].slug' senderkit messages list --status failed --json > failed.json ``` ## Utility commands ### `senderkit context` Report the connected workspace and the active send mode: ```bash theme={null} senderkit context # workspace: Acme Inc # slug: acme # mode: test ``` Fetches `GET /v1/context` so the result is authoritative and workspace-aware. Add `--json` to get `{ workspace: { id, slug, name }, mode }` as structured output. ## MCP server The CLI also bundles the SenderKit MCP server, exposed as `senderkit mcp` and `senderkit mcp install`. That surface is documented separately — see [MCP Server](/mcp/overview). Fire a template or raw send from the terminal. List and inspect templates by slug. Query message history and filter by status, channel, or template. Hand the same operations to Claude, Cursor, and other AI clients. # messages Source: https://docs.senderkit.com/cli/messages Inspect message history from the terminal. `senderkit messages` queries the [message](/concepts/messages) log — the durable record of every send and its status. The CLI exposes the list endpoint with filters and cursor pagination, paired with `--json` for piping into `jq`, spreadsheets, or downstream alerting. ## `senderkit messages list` List messages newest-first. With no flags, returns a recent page across all channels and statuses. ```bash theme={null} senderkit messages list [options] ``` ### Options Maximum messages to return (a positive integer). The server has its own default and maximum — see the [API Reference](/api-reference/introduction). Pagination cursor. Use the `nextCursor` from a previous response to fetch the next page. Filter by [status](/concepts/messages#the-message-lifecycle), e.g. `queued`, `rendered`, `sent`, `delivered`, `failed`, `opted_out`. Filter by channel. Filter by template slug, e.g. `welcome`. Filter by the [metadata](/cli/send) you attached at send time, as a JSON object — e.g. `'{"orderId":"ord_9"}'`. Every key/value pair must match. ### Examples ```bash theme={null} # Recent messages, table view senderkit messages list # Everything that failed, by email senderkit messages list --status failed --channel email # All sends of a specific template senderkit messages list --template welcome --limit 100 # Filter by metadata attached at send time senderkit messages list --metadata '{"orderId":"ord_9"}' # Paginate senderkit messages list --limit 50 senderkit messages list --limit 50 --cursor "msg_01HZ…" ``` Human output: ``` ID STATUS CHANNEL TEMPLATE TO CREATED msg_01HZ… delivered email welcome user@example.com 2026-05-28T10:14:02Z msg_01HY… failed email password-reset jane@example.com 2026-05-28T10:13:55Z next cursor: msg_01HX… ``` ### Scripting with `--json` `--json` emits the full SDK response — a `data` array and a `nextCursor` string — which makes `jq` workflows clean: ```bash theme={null} # Count failed messages in the latest page senderkit messages list --status failed --json | jq '.data | length' # Pull every recipient that bounced today senderkit messages list --status failed --json | jq -r '.data[].recipient' # Walk every page cursor="" while :; do page=$(senderkit messages list --limit 100 ${cursor:+--cursor "$cursor"} --json) echo "$page" | jq '.data[]' cursor=$(echo "$page" | jq -r '.nextCursor // empty') [ -z "$cursor" ] && break done ``` ## `senderkit messages get` Fetch a single message by its public ID. ```bash theme={null} senderkit messages get ``` Public message ID, e.g. `msg_01HZ…` — the `id` returned by [`send`](/cli/send) or a row from `messages list`. ```bash theme={null} senderkit messages get msg_01HZ… ``` ``` id: msg_01HZ… status: delivered channel: email template: welcome recipient: user@example.com openedAt: 2026-05-28T10:16:40Z clickedAt: — createdAt: 2026-05-28T10:14:02Z ``` Add `--json` for the full message object. ## `senderkit messages cancel` Cancel a still-pending message. Only `scheduled` or `queued` messages are cancelable — once a message has moved past that (e.g. `sent`), the server returns a `409` and the command fails. ```bash theme={null} senderkit messages cancel ``` Public message ID of a scheduled or queued message. ```bash theme={null} senderkit messages cancel msg_01HZ… ``` ``` ✓ Canceled msg_01HZ… id: msg_01HZ… status: canceled ``` There's no live tail (`messages tail`) in the CLI. For real-time delivery monitoring, use the SSE stream (`GET /v1/messages?tail=1`) described in the [API Reference](/api-reference/introduction); from the terminal, poll `messages list` with a filter. Messages are retained for [a limited window](/concepts/messages#retention) — 3 days on Free, 14 days on Pro. If you need history beyond that, export with `--json` on a schedule and persist in your own system, correlating via the `metadata` you attached at send time. The lifecycle and retention rules behind these records. Fire a send, then query its status here. Why a queued message hasn't been delivered yet. The list endpoint, status enum, and live tail stream. # send Source: https://docs.senderkit.com/cli/send Dispatch a templated or raw message from the terminal. `senderkit send` fires a [send](/concepts/sending) from your terminal — the same operation as the SDK's `send()` and `sendRaw()`. Useful for smoke-testing templates while you build them, scripting one-off blasts, and triggering messages from cron or CI. Like the SDK, both commands return as soon as the API has accepted the message — the `id` you get back is a [queued message](/concepts/messages), not a delivered one. Watch it with [`messages list`](/cli/messages). ## `senderkit send` Send a [templated message](/concepts/templates), filling in [variables](/concepts/variables) at send time. ```bash theme={null} senderkit send