---
title: "Build your own adapter"
description: "Add an SMS provider by implementing the SmsAdapter contract, mapping responses to accepted, rejected, or unknown, and checking it with the contract test runner."
---

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 just to send a parameter a built-in adapter has no typed option for. Pass it in `providerOptions.<adapter>.extra`, described in [`providerOptions`](/reference/send#provideroptions).

The [Twilio adapter source](https://github.com/opencoredev/sms-sdk/blob/main/packages/sms-sdk/src/providers/twilio.ts) is the most complete reference. Copy its structure.

## The one rule

`send()` returns one of three outcomes and never throws:

| Return | When |
| --- | --- |
| `{ kind: "accepted", providerId, delivery }` | The response proves the provider 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. |

:::warning
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. A thrown error counts as `unknown` with `reason: "adapter_exception"`.
:::

## 1. Declare capabilities

```ts acme-capabilities.ts
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 calling `send()` and throws `UnsupportedFieldError` for anything undeclared. 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

```ts acme.ts
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";
}
```

What matters in this code:

- **Pass `context.signal` to `fetch`.** The client uses it for `timeoutMs` and caller aborts.
- **Accept only with an ID**, and map every 5xx to `unknown`, even when the provider says "try again". It may already have created the message.
- **Treat a 429 as a rate limit only with the provider's documented rate-limit error.** A bare 429 can come from a proxy.
- **Classify conservatively.** `auth`, `rate_limited`, `sender`, and `account` trigger fallback, so use them only when the provider's docs prove the problem is specific to this account. Opt-outs and blocks are `compliance`, which never falls back. Unlisted codes are `request`.
- **Keep `details` log-safe.** The client does not redact them. Pass provider error text through `redactText()` from `@opencoredev/sms-sdk`, which masks phone numbers and cuts the text to 200 characters.

For other local rules, such as a body length limit, add an optional `validate(message)` that returns `ValidationIssue[]`. Its issues appear in `sms.validate()` and make `send()` throw before any request.

## 3. Run the contract tests

`@opencoredev/sms-sdk/testing` checks your adapter against the contract every built-in adapter passes. You describe the provider's documented behavior as fixtures, and the test runner drives your adapter through a fake `fetch`:

```ts acme.test.ts ignore="depends on the acme.ts module above and bun:test"
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. Remove the `signal` line or the `Retry-After` handling and the matching check fails.

The runner checks that capabilities are consistent; that the adapter builds the exact request and passes the abort signal; that accepted, permanent, fallback-eligible, and rate-limited responses map correctly; that network errors, aborts, malformed or non-JSON 2xx responses, and 5xx responses are `unknown`; and that undeclared capabilities throw `UnsupportedFieldError` before any request. When you pass `webhooks` fixtures, it also parses each case and checks that a tampered request throws `WebhookSignatureError`. It does not exercise provider options.

For one `ContractReport` with every result, call `runSmsAdapterContract(factory, fixtures)` instead. See the [testing reference](/reference/testing#contract-harness).

## 4. Accept provider options

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

```ts acme-options.ts
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;
}
```

Set `providerOptions: ACME_PROVIDER_OPTIONS` on the adapter and build the request with `buildAcmeBody(message)`. The client validates the caller's entry before any request and passes it to `send()` as `message.providerFields`, `{ wire, value }` pairs that already include `extra`. Apply them last. Without a spec, any `providerOptions` entry for your adapter throws `UnsupportedFieldError`. The rules are in the [adapter contract reference](/reference/adapter-contract#provideroptionsspec).

## 5. Webhooks

`parseSmsWebhook` covers only the six built-in providers. For a new provider, verify the signature against 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, see [Contributing and support](/advanced/contributing#propose-a-provider).

## Next steps

- [Adapter contract reference](/reference/adapter-contract)
- [Testing reference](/reference/testing)
