---
title: "Adapter contract"
description: "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](/providers/writing-an-adapter) or to read `sms.capabilities()`. All are exported as types from `@opencoredev/sms-sdk`.

## `SmsAdapter`

```ts ignore="type listing"
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`](#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"`. |

```ts
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`](#adaptermessage).

```ts ignore="type listing"
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:

```ts
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`:

```ts ignore="type listing"
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:

```ts ignore="type listing"
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`

```ts ignore="type listing"
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`

```ts ignore="type listing"
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`

```ts ignore="type listing"
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](/reference/testing#contract-harness).
