Build your own adapter
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.
The Twilio adapter source 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. |
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 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
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.signaltofetch. The client uses it fortimeoutMsand 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, andaccounttrigger fallback, so use them only when the provider’s docs prove the problem is specific to this account. Opt-outs and blocks arecompliance, which never falls back. Unlisted codes arerequest. - Keep
detailslog-safe. The client does not redact them. Pass provider error text throughredactText()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:
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.
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:
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.
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.