Skip to content
SMS SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Adapter contract

The SmsAdapter interface and its types: SmsCapabilities, ProviderOptionsSpec, AdapterMessage, SmsSender, SendContext, AdapterSendOutcome, and RejectionCategory.

These types define an adapter. You need them to write a provider adapter or to read sms.capabilities(). All are exported as types from @opencoredev/sms-sdk.

SmsAdapter

interface SmsAdapter {
  readonly name: string;
  readonly capabilities: SmsCapabilities;
  readonly support: AdapterSupport;
  readonly defaultFrom?: SmsFrom;
  readonly providerOptions?: ProviderOptionsSpec;
  validate?(message: AdapterMessage): readonly ValidationIssue[];
  send(message: AdapterMessage, context: SendContext): Promise<AdapterSendOutcome>;
}
Member Meaning
name Stable lowercase name, such as "twilio". Used in results, errors, and hooks. Must be unique within a client.
capabilities What the adapter can send. The client checks every message against it before calling send.
support { status: "supported" | "partial"; notes: readonly string[] }. notes explains a partial status.
defaultFrom Sender used when a message has no from.
providerOptions The provider-specific options this adapter accepts under its name in providerOptions. See ProviderOptionsSpec. Without it, any entry for this adapter throws UnsupportedFieldError.
validate Optional provider-specific checks that need no network, such as sender ID formats or field combinations. Issues make send() throw before any request.
send Makes one provider request. Must pass context.signal to fetch. Must not throw. The client treats a thrown error as unknown with reason: "adapter_exception".
import { createSmsClient, type SmsAdapter } from "@opencoredev/sms-sdk";

const echo: SmsAdapter = {
  name: "echo",
  capabilities: {
    sendText: true,
    mms: false,
    scheduling: false,
    validityPeriod: null,
    webhookUrlOverride: false,
    inbound: false,
    deliveryReceipts: false,
    senderTypes: ["long_code"],
    nativeIdempotency: false,
  },
  support: { status: "partial", notes: ["Example adapter that accepts everything."] },
  defaultFrom: "+15550100001",
  async send(message, context) {
    console.log(`attempt ${context.attempt}: ${message.from.value} -> ${message.to}`);
    return { kind: "accepted", providerId: `echo_${Date.now()}`, delivery: "queued" };
  },
};

const sms = createSmsClient({ adapters: [echo] });
await sms.send({ to: "+14155550123", body: "Hi" });

SmsCapabilities

Field Type Meaning
sendText true Every adapter sends text.
mms boolean Accepts mediaUrls.
scheduling boolean Accepts sendAt. The adapter may add sender restrictions in validate.
validityPeriod { minSec: number; maxSec: number } | null Accepted validityPeriodSec range, or null when unsupported.
webhookUrlOverride boolean Accepts a per-message webhookUrl.
inbound boolean The provider can deliver inbound messages to a webhook this SDK parses.
deliveryReceipts boolean The provider sends status webhooks this SDK parses.
senderTypes readonly SenderType[] "long_code", "toll_free", "short_code", "alphanumeric", "messaging_service". A phone-number sender is allowed when long_code or toll_free is listed.
nativeIdempotency boolean The provider deduplicates sends by key and the adapter passes context.idempotencyKey. false for every built-in adapter.

Built-in values are exported as constants: TWILIO_CAPABILITIES, TELNYX_CAPABILITIES, PLIVO_CAPABILITIES, VONAGE_JWT_CAPABILITIES, VONAGE_BASIC_CAPABILITIES, each from its adapter’s subpath.

ProviderOptionsSpec

Declares an adapter’s provider-specific send options. The client parses the caller’s entry against it before any request and hands the result to send as AdapterMessage.providerFields.

type ProviderOptionsSpec = {
  readonly options: { readonly [option: string]: ProviderOptionField }; // keyed by camelCase name
  readonly reserved: readonly string[]; // wire names the adapter sets from portable fields
  readonly nestedPrefixes?: readonly string[]; // wire-name prefixes sent as a nested object, such as "sms."
};

type ProviderOptionField =
  | { readonly type: "boolean"; readonly wire: string }
  | { readonly type: "string"; readonly wire: string; readonly maxLength?: number; readonly pattern?: RegExp }
  | { readonly type: "integer"; readonly wire: string; readonly min: number; readonly max?: number }
  | { readonly type: "enum"; readonly wire: string; readonly values: readonly [string, ...string[]] };
Field Meaning
options Typed options by camelCase name. wire is the provider’s own parameter name. Do not name an option extra, which is the passthrough.
reserved Wire names the adapter sets itself, such as To or StatusCallback. Neither a typed option nor extra may set them.
nestedPrefixes Optional. Wire-name prefixes the adapter sends as a nested object, such as "sms." for Vonage’s sms object. An extra key under a prefix is also checked with the prefix removed.

For each adapter that has an entry, the client checks the entry before any request:

  • An entry for an adapter without a spec, or an unknown key, is unsupported_field.
  • An entry that is not an object, or a typed value of the wrong type, is invalid_field. Strings must be non-empty and within maxLength and pattern. Integers must be within min and max. Enums must be one of values.
  • An extra key that matches a reserved name or a typed option’s wire, in any letter case, is unsupported_field. With nestedPrefixes, the key is also checked with the prefix removed, so Vonage’s sms.failover is rejected like failover.
  • An extra value that is not a string, finite number, or boolean, or an empty extra key, is invalid_field.

Every issue has field: "providerOptions" and the adapter’s provider. Messages name option keys, never values.

Declare the spec with as const satisfies ProviderOptionsSpec, derive the caller-facing type with ProviderOptionsOf, and add it to SmsProviderOptions so callers get types for your key:

import type { ProviderOptionsOf, ProviderOptionsSpec } from "@opencoredev/sms-sdk";

export const ACME_PROVIDER_OPTIONS = {
  options: {
    clientRef: { type: "string", wire: "client_ref", maxLength: 100 },
    priority: { type: "enum", wire: "priority", values: ["normal", "high"] },
  },
  reserved: ["to", "from", "text"],
} as const satisfies ProviderOptionsSpec;

declare module "@opencoredev/sms-sdk" {
  interface SmsProviderOptions {
    readonly acme?: ProviderOptionsOf<typeof ACME_PROVIDER_OPTIONS>;
  }
}

Set providerOptions: ACME_PROVIDER_OPTIONS on the adapter. ProviderOptionsOf makes every typed option optional and adds extra?: ProviderExtra, an object of string | number | boolean values keyed by wire name. The built-in specs are exported as TWILIO_PROVIDER_OPTIONS, TELNYX_PROVIDER_OPTIONS, PLIVO_PROVIDER_OPTIONS, and VONAGE_PROVIDER_OPTIONS.

AdapterMessage

The validated message the client passes to send:

type AdapterMessage = {
  readonly to: E164;
  readonly from: SmsSender;
  readonly body: string;
  readonly mediaUrls: readonly string[]; // empty when there is no media
  readonly sendAt?: Date;
  readonly validityPeriodSec?: number;
  readonly webhookUrl?: string;
  readonly providerFields?: readonly ProviderWireField[];
};

type ProviderWireField = { readonly wire: string; readonly value: string | number | boolean };

providerFields is this adapter’s providerOptions entry after parsing: typed options in the order the spec declares them, then extra in insertion order. It is absent when the caller passed no entry for this adapter, or when the entry parses to no fields (for example { twilio: {} }). Send every field as is, applied last when you build the request. The client has already rejected unknown keys, wrong types, and collisions with reserved names.

SmsSender

The resolved sender, with its kind made explicit:

type SmsSender =
  | { readonly kind: "phone_number"; readonly value: E164 }
  | { readonly kind: "short_code"; readonly value: string }
  | { readonly kind: "alphanumeric"; readonly value: string }
  | { readonly kind: "messaging_service"; readonly value: string };

resolveSender(from) turns an SmsFrom into { kind: "ok", sender } | { kind: "missing" } | { kind: "invalid", message }, and supportsSender(capabilities, sender) tells whether an adapter can use it. Both are exported from @opencoredev/sms-sdk.

SendContext

type SendContext = {
  readonly signal: AbortSignal; // aborts on caller cancellation or timeoutMs
  readonly attempt: number; // 1-based, per adapter, within one logical send
  readonly idempotencyKey?: string;
};

AdapterSendOutcome

type AdapterSendOutcome =
  | { readonly kind: "accepted"; readonly providerId: string; readonly delivery: Delivery }
  | { readonly kind: "rejected"; readonly category: RejectionCategory; readonly retryAfterMs?: number; readonly details: ProviderErrorInfo }
  | { readonly kind: "unknown"; readonly reason: UnknownReason; readonly details?: ProviderErrorInfo; readonly cause?: unknown };
  • Return accepted only when the response proves the message was created and includes its ID.
  • Return rejected only when the response proves it was not created.
  • Return unknown for everything else, including any response that does not prove either.

UnknownReason is "network" | "timeout" | "aborted" | "malformed_response" | "server_error" | "unexpected_status" | "adapter_exception". The client replaces the reason with timeout or aborted when its own signal fired.

RejectionCategory

Category Fallback-eligible Meaning
auth Yes Credentials missing, invalid, or forbidden.
rate_limited Yes, after same-adapter retries A documented rate or queue limit refused the request.
sender Yes The sender is invalid, not provisioned, or not usable for this destination.
account Yes Balance, trial, region permission, spend limit, or a disabled account.
recipient No The destination is invalid or unreachable for this route.
compliance Never Opt-out, block list, or content block.
request No Malformed request or invalid field; any unlisted 4xx.

isFallbackEligible(category) returns the second column.

ProviderErrorInfo

type ProviderErrorInfo = {
  readonly provider: string;
  readonly httpStatus?: number;
  readonly code?: string;
  readonly message?: string;
  readonly requestId?: string;
};

It ends up in thrown errors and logs. Never put message bodies or credentials in it. Pass provider text through redactText(), which masks phone numbers and cuts the text to 200 characters. The built-in adapters do this.

Check an adapter

Run the contract harness from @opencoredev/sms-sdk/testing against your adapter. See Testing.