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

Build a provider adapter

Add an SMS provider by implementing the SmsAdapter contract, mapping responses to accepted, rejected, or unknown, and checking it with the contract harness.

To use a provider that is not built in, write an adapter, an object that implements SmsAdapter. The client handles validation, retries, fallback, idempotency, and hooks around it. You don’t need an adapter to send a parameter that a built-in adapter has no typed option for. Pass it in providerOptions.<adapter>.extra, described in providerOptions.

The Twilio adapter source is the most complete reference. Copy its structure.

The contract in one rule

send() must return one of three outcomes and must not throw:

Return When
{ kind: "accepted", providerId, delivery } The provider’s response proves it created the message, and includes its ID.
{ kind: "rejected", category, details, retryAfterMs? } The response proves the message was not created, such as a documented 4xx error.
{ kind: "unknown", reason, details?, cause? } Anything else: network error, timeout, 5xx, a 2xx without the ID, an unexpected status.

If you are not sure the provider refused the message, return unknown. The client never retries or fails over an unknown outcome, so a wrong unknown costs one manual check. A wrong rejected can send a message twice. The client treats a thrown error as unknown with reason: "adapter_exception". See Idempotency.

1. Declare capabilities

import type { SmsCapabilities } from "@opencoredev/sms-sdk";

export const ACME_CAPABILITIES: SmsCapabilities = {
  sendText: true,
  mms: false,
  scheduling: false,
  validityPeriod: null,
  webhookUrlOverride: false,
  inbound: false,
  deliveryReceipts: false,
  senderTypes: ["long_code", "alphanumeric"],
  nativeIdempotency: false,
};

The client checks every message against these before it calls send(), and throws UnsupportedFieldError for anything you do not declare. Declare only what you have implemented and tested. Leave nativeIdempotency false unless the provider documents send deduplication and you pass context.idempotencyKey to it.

2. Implement send

import type { AdapterSendOutcome, FetchLike, RejectionCategory, SmsAdapter, SmsFrom } from "@opencoredev/sms-sdk";
import { ConfigurationError } from "@opencoredev/sms-sdk";
import { ACME_CAPABILITIES } from "./acme-capabilities";

export type AcmeOptions = {
  readonly apiKey: string;
  readonly from?: SmsFrom;
  readonly baseUrl?: string;
  readonly fetch?: FetchLike;
};

export function acme(options: AcmeOptions): SmsAdapter {
  if (options.apiKey.length === 0) {
    throw new ConfigurationError("Acme apiKey is required.");
  }
  const url = `${options.baseUrl ?? "https://api.acme-sms.example"}/v1/messages`;
  const fetcher: FetchLike = options.fetch ?? ((input, init) => fetch(input, init));

  return {
    name: "acme",
    capabilities: ACME_CAPABILITIES,
    support: { status: "partial", notes: ["Error codes come from Acme's public error list; 5xx is treated as unknown."] },
    ...(options.from === undefined ? {} : { defaultFrom: options.from }),

    async send(message, context): Promise<AdapterSendOutcome> {
      let response: Response;
      try {
        response = await fetcher(url, {
          method: "POST",
          headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json" },
          body: JSON.stringify({ to: message.to, from: message.from.value, text: message.body }),
          signal: context.signal, // required: timeouts and aborts depend on it
        });
      } catch (cause) {
        return { kind: "unknown", reason: "network", cause };
      }

      const text = await response.text().catch(() => "");
      let body: unknown;
      try {
        body = JSON.parse(text);
      } catch {
        body = undefined;
      }

      if (response.ok) {
        const id = typeof body === "object" && body !== null && "id" in body && typeof body.id === "string" ? body.id : undefined;
        return id === undefined
          ? { kind: "unknown", reason: "malformed_response", details: { provider: "acme", httpStatus: response.status } }
          : { kind: "accepted", providerId: id, delivery: "queued" };
      }

      if (response.status >= 400 && response.status < 500) {
        const code = typeof body === "object" && body !== null && "code" in body ? String(body.code) : undefined;
        const category = acmeCategory(response.status, code);
        if (response.status === 429 && category !== "rate_limited") {
          // A 429 without Acme's rate-limit code may come from a proxy: it proves nothing.
          return { kind: "unknown", reason: "unexpected_status", details: { provider: "acme", httpStatus: 429 } };
        }
        const retryAfterSec = Number(response.headers.get("retry-after"));
        return {
          kind: "rejected",
          category,
          ...(category === "rate_limited" && retryAfterSec > 0 ? { retryAfterMs: retryAfterSec * 1000 } : {}),
          details: { provider: "acme", httpStatus: response.status, ...(code === undefined ? {} : { code }) },
        };
      }

      return { kind: "unknown", reason: response.status >= 500 ? "server_error" : "unexpected_status", details: { provider: "acme", httpStatus: response.status } };
    },
  };
}

function acmeCategory(status: number, code: string | undefined): RejectionCategory {
  if (status === 401 || status === 403) return "auth";
  if (code === "rate_limited") return "rate_limited";
  if (code === "invalid_sender") return "sender";
  if (code === "opted_out") return "compliance";
  return "request";
}

Points to copy:

  • Pass context.signal to fetch. The client uses it for timeoutMs and caller aborts, and the contract harness checks it.
  • Accept only with an ID. A 2xx without the documented message ID is unknown.
  • Map 5xx to unknown. Do this even when the provider says “try again”, because it may already have created the message.
  • Rate-limit a 429 only with the provider’s documented rate-limit error. A bare 429 can come from a proxy or load balancer, so it is unknown. The built-in adapters follow this rule.
  • Classify conservatively. Unlisted 4xx codes are request, which never falls back. auth, sender, and account trigger fallback, so use them only when the provider’s documentation proves the problem is specific to this account. Use compliance for opt-outs and blocks. It never falls back.
  • Keep details log-safe. No message bodies and no credentials, because the client does not redact details for you. Pass provider error text through redactText() from @opencoredev/sms-sdk, as the built-in adapters do. It masks phone numbers and cuts the text to 200 characters.
  • Set retryAfterMs on rate_limited rejections when the provider sends Retry-After.

3. Add provider-specific checks

The client already checks sender types and declared capabilities. Use the optional validate(message) for other rules that need no network, such as a body length limit or a sender format:

import type { AdapterMessage, ValidationIssue } from "@opencoredev/sms-sdk";

export function validateAcmeMessage(message: AdapterMessage): ValidationIssue[] {
  return message.body.length > 918
    ? [{ code: "invalid_field", field: "body", message: "Acme accepts bodies up to 918 characters." }]
    : [];
}

Issues returned here appear in sms.validate() and make send() throw before any request.

4. Run the contract harness

@opencoredev/sms-sdk/testing checks your adapter against the contract the built-in adapters pass. You describe the provider’s documented behavior as fixtures, and the harness drives your adapter through a fake fetch.

import { expect, test } from "bun:test";
import { smsAdapterContractCases, type AdapterContractFixtures } from "@opencoredev/sms-sdk/testing";
import { acme } from "./acme";

const fixtures: AdapterContractFixtures = {
  message: { to: "+14155550123", from: { kind: "phone_number", value: "+15550100001" }, body: "Hi", mediaUrls: [] },
  request: {
    url: "https://api.acme-sms.example/v1/messages",
    headers: { authorization: "Bearer test", "content-type": "application/json" },
    body: { kind: "json", value: { to: "+14155550123", from: "+15550100001", text: "Hi" } },
  },
  accepted: { response: { status: 201, body: { id: "msg_1" } }, providerId: "msg_1", delivery: "queued" },
  permanentRejection: { response: { status: 400, body: { code: "opted_out" } }, category: "compliance" },
  eligibleRejection: { response: { status: 400, body: { code: "invalid_sender" } }, category: "sender" },
  rateLimited: { response: { status: 429, body: { code: "rate_limited" }, headers: { "retry-after": "2" } }, retryAfterMs: 2000 },
  malformedSuccess: { status: 201, body: {} },
  serverError: { status: 503 },
};

for (const check of smsAdapterContractCases((fetch) => acme({ apiKey: "test", fetch }), fixtures)) {
  test(check.name, check.run);
}

Run it with bun test acme.test.ts. Every check passes for the example adapter. Remove the signal line or the Retry-After handling and the matching check fails.

The harness checks that:

  • capabilities are consistent, and the adapter builds the exact request and passes the abort signal to fetch.
  • an accepted response, a permanent rejection with no fallback, an eligible rejection, and a rate limit with Retry-After map correctly.
  • network errors, in-flight aborts, malformed or non-JSON 2xx responses, and 5xx responses are unknown.
  • undeclared capabilities throw UnsupportedFieldError before any request.
  • when you pass webhooks, each webhook case parses and a tampered request throws WebhookSignatureError.

It does not exercise provider options.

To get one ContractReport with every result, call runSmsAdapterContract(factory, fixtures) instead. See Testing reference.

5. Accept provider options

Provider options let callers pass typed, provider-specific parameters in providerOptions.<name>. Declare them in a ProviderOptionsSpec. List every wire name your adapter sets from portable fields in reserved, so neither typed options nor extra can override them:

import type { AdapterMessage, ProviderOptionsOf, ProviderOptionsSpec, ProviderOptionValue } 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>;
  }
}

export function buildAcmeBody(message: AdapterMessage): Record<string, ProviderOptionValue> {
  const body: Record<string, ProviderOptionValue> = { to: message.to, from: message.from.value, text: message.body };
  for (const field of message.providerFields ?? []) {
    body[field.wire] = field.value;
  }
  return body;
}

Then set providerOptions: ACME_PROVIDER_OPTIONS on the object acme() returns, and build the request body with buildAcmeBody(message).

The declare module block types providerOptions.acme for callers, the same way each built-in adapter subpath adds its key. The client checks the caller’s entry before any request and passes the result to send() as message.providerFields, a list of { wire, value } pairs that already includes extra. Apply them last and send each one as is. Without a spec, any providerOptions entry for your adapter throws UnsupportedFieldError. The validation rules are in the adapter contract reference.

6. Webhooks

parseSmsWebhook covers only the four built-in providers. For a new provider, verify the signature from the raw body yourself before reading any field. Then map the payload to the SmsEvent union from @opencoredev/sms-sdk/webhooks so the rest of your app handles it like the others.

Contribute it

Community adapters are maintained by their authors. To propose one for the main package, open an issue with links to the provider’s official API, error, and webhook documentation, and include contract fixtures built from those docs. See Contributing.

Next steps