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 withinmaxLengthandpattern. Integers must be withinminandmax. Enums must be one ofvalues. - An
extrakey that matches areservedname or a typed option’swire, in any letter case, isunsupported_field. WithnestedPrefixes, the key is also checked with the prefix removed, so Vonage’ssms.failoveris rejected likefailover. - An
extravalue that is not a string, finite number, or boolean, or an emptyextrakey, isinvalid_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
acceptedonly when the response proves the message was created and includes its ID. - Return
rejectedonly when the response proves it was not created. - Return
unknownfor 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.