diff --git a/packages/connectors/connector-gatewayapi-sms/README.md b/packages/connectors/connector-gatewayapi-sms/README.md
new file mode 100644
index 000000000..58a21165d
--- /dev/null
+++ b/packages/connectors/connector-gatewayapi-sms/README.md
@@ -0,0 +1,32 @@
+# GatewayAPI SMS connector
+
+The official Logto connector for GatewayAPI SMS.
+
+## Get started
+
+GatewayAPI is a cloud service provider in Europe, offering many cloud services, including SMS (short message service). GatewayAPI SMS Connector is a plugin provided by the Logto team to call the GatewayAPI SMS service, with the help of which Logto end-users can register and sign in to their Logto account via SMS verification code.
+
+## Set up in GatewayAPI
+
+> 💡 **Tip**
+>
+> You can skip some sections if you have already finished.
+
+### Create an GatewayAPI account
+
+Go to the [GatewayAPI website](https://www.gatewayapi.com/) and register your GatewayAPI account if you don't have one.
+
+### Enable account
+
+You may need to enable your account before using the SMS service. You can contact the GatewayAPI customer service to enable your account.
+
+### Get API token
+
+Go to the API Keys page from the GatewayAPI console, and find the API token or create a new API token.
+
+## Set up in Logto
+
+1. **Endpoint**: If your GatewayAPI account is in the EU region, you should use the endpoint `https://gatewayapi.com/rest/mtsms`. If your GatewayAPI account is in the US region, you should use the endpoint `https://gatewayapi.com/rest/mtsms`.
+2. **API Token**: The API token you created in the previous step.
+3. **Sender**: The sender you want to use to send the SMS.
+4. **Templates**: The templates you want to use to send the SMS, you can use the default templates or modify them as needed.
diff --git a/packages/connectors/connector-gatewayapi-sms/logo.svg b/packages/connectors/connector-gatewayapi-sms/logo.svg
new file mode 100644
index 000000000..a0ea14bbb
--- /dev/null
+++ b/packages/connectors/connector-gatewayapi-sms/logo.svg
@@ -0,0 +1 @@
+
diff --git a/packages/connectors/connector-gatewayapi-sms/package.json b/packages/connectors/connector-gatewayapi-sms/package.json
new file mode 100644
index 000000000..ceaa7d593
--- /dev/null
+++ b/packages/connectors/connector-gatewayapi-sms/package.json
@@ -0,0 +1,69 @@
+{
+ "name": "@logto/connector-gatewayapi-sms",
+ "version": "0.0.0",
+ "description": "GatewayAPI SMS connector implementation.",
+ "author": "Silverhand Inc. ",
+ "dependencies": {
+ "@logto/connector-kit": "workspace:^4.0.0",
+ "@silverhand/essentials": "^2.9.1",
+ "got": "^14.0.0",
+ "snakecase-keys": "^8.0.1",
+ "zod": "^3.23.8"
+ },
+ "main": "./lib/index.js",
+ "module": "./lib/index.js",
+ "exports": "./lib/index.js",
+ "license": "MPL-2.0",
+ "type": "module",
+ "files": [
+ "lib",
+ "docs",
+ "logo.svg",
+ "logo-dark.svg"
+ ],
+ "scripts": {
+ "precommit": "lint-staged",
+ "check": "tsc --noEmit",
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "lint": "eslint --ext .ts src",
+ "lint:report": "pnpm lint --format json --output-file report.json",
+ "test": "vitest src",
+ "test:ci": "pnpm run test --silent --coverage",
+ "prepublishOnly": "pnpm build"
+ },
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "eslintConfig": {
+ "extends": "@silverhand",
+ "settings": {
+ "import/core-modules": [
+ "@silverhand/essentials",
+ "got",
+ "nock",
+ "snakecase-keys",
+ "zod"
+ ]
+ }
+ },
+ "prettier": "@silverhand/eslint-config/.prettierrc",
+ "publishConfig": {
+ "access": "public"
+ },
+ "devDependencies": {
+ "@silverhand/eslint-config": "6.0.1",
+ "@silverhand/ts-config": "6.0.0",
+ "@types/node": "^20.11.20",
+ "@types/supertest": "^6.0.2",
+ "@vitest/coverage-v8": "^2.0.0",
+ "eslint": "^8.56.0",
+ "lint-staged": "^15.0.2",
+ "nock": "^13.3.1",
+ "prettier": "^3.0.0",
+ "supertest": "^7.0.0",
+ "tsup": "^8.1.0",
+ "typescript": "^5.5.3",
+ "vitest": "^2.0.0"
+ }
+}
diff --git a/packages/connectors/connector-gatewayapi-sms/src/constant.ts b/packages/connectors/connector-gatewayapi-sms/src/constant.ts
new file mode 100644
index 000000000..4cf3c2d1e
--- /dev/null
+++ b/packages/connectors/connector-gatewayapi-sms/src/constant.ts
@@ -0,0 +1,70 @@
+import type { ConnectorMetadata } from '@logto/connector-kit';
+import { ConnectorConfigFormItemType } from '@logto/connector-kit';
+
+export const endpoint = 'https://api.twilio.com/2010-04-01/Accounts/{{accountSID}}/Messages.json';
+
+export const defaultMetadata: ConnectorMetadata = {
+ id: 'gatewayapi-sms',
+ target: 'gatewayapi-sms',
+ platform: null,
+ name: {
+ en: 'GatewayAPI SMS Service',
+ },
+ logo: './logo.svg',
+ logoDark: null,
+ description: {
+ en: 'GatewayAPI accelerates development by removing the learning curve and guesswork, so you can get down to building right away with our APIs.',
+ },
+ readme: './README.md',
+ formItems: [
+ {
+ key: 'endpoint',
+ label: 'Endpoint',
+ type: ConnectorConfigFormItemType.Text,
+ required: true,
+ placeholder: 'https://gatewayapi.com/rest/mtsms',
+ defaultValue: 'https://gatewayapi.com/rest/mtsms',
+ },
+ {
+ key: 'apiToken',
+ label: 'API Token',
+ type: ConnectorConfigFormItemType.Text,
+ required: true,
+ },
+ {
+ key: 'sender',
+ label: 'Sender',
+ type: ConnectorConfigFormItemType.Text,
+ required: true,
+ placeholder: 'ExampleSMS',
+ },
+ {
+ key: 'templates',
+ label: 'Templates',
+ type: ConnectorConfigFormItemType.Json,
+ required: true,
+ defaultValue: [
+ {
+ usageType: 'SignIn',
+ content:
+ 'Your Logto sign-in verification code is {{code}}. The code will remain active for 10 minutes.',
+ },
+ {
+ usageType: 'Register',
+ content:
+ 'Your Logto sign-up verification code is {{code}}. The code will remain active for 10 minutes.',
+ },
+ {
+ usageType: 'ForgotPassword',
+ content:
+ 'Your Logto password change verification code is {{code}}. The code will remain active for 10 minutes.',
+ },
+ {
+ usageType: 'Generic',
+ content:
+ 'Your Logto verification code is {{code}}. The code will remain active for 10 minutes.',
+ },
+ ],
+ },
+ ],
+};
diff --git a/packages/connectors/connector-gatewayapi-sms/src/index.test.ts b/packages/connectors/connector-gatewayapi-sms/src/index.test.ts
new file mode 100644
index 000000000..dd2fb941e
--- /dev/null
+++ b/packages/connectors/connector-gatewayapi-sms/src/index.test.ts
@@ -0,0 +1,10 @@
+import createConnector from './index.js';
+import { mockedConfig } from './mock.js';
+
+const getConfig = vi.fn().mockResolvedValue(mockedConfig);
+
+describe('GatewayAPI SMS connector', () => {
+ it('init without throwing errors', async () => {
+ await expect(createConnector({ getConfig })).resolves.not.toThrow();
+ });
+});
diff --git a/packages/connectors/connector-gatewayapi-sms/src/index.ts b/packages/connectors/connector-gatewayapi-sms/src/index.ts
new file mode 100644
index 000000000..5b82ea049
--- /dev/null
+++ b/packages/connectors/connector-gatewayapi-sms/src/index.ts
@@ -0,0 +1,81 @@
+import { assert } from '@silverhand/essentials';
+import { got, HTTPError } from 'got';
+
+import type {
+ GetConnectorConfig,
+ SendMessageFunction,
+ CreateConnector,
+ SmsConnector,
+} from '@logto/connector-kit';
+import {
+ ConnectorError,
+ ConnectorErrorCodes,
+ validateConfig,
+ ConnectorType,
+ replaceSendMessageHandlebars,
+} from '@logto/connector-kit';
+
+import { defaultMetadata } from './constant.js';
+import { gatewayApiSmsConfigGuard, type GatewayApiSmsPayload } from './types.js';
+
+const sendMessage =
+ (getConfig: GetConnectorConfig): SendMessageFunction =>
+ async (data, inputConfig) => {
+ const { to, type, payload } = data;
+ const config = inputConfig ?? (await getConfig(defaultMetadata.id));
+ validateConfig(config, gatewayApiSmsConfigGuard);
+ const { endpoint, apiToken, sender, templates } = config;
+ const template = templates.find((template) => template.usageType === type);
+
+ assert(
+ template,
+ new ConnectorError(
+ ConnectorErrorCodes.TemplateNotFound,
+ `Cannot find template for type: ${type}`
+ )
+ );
+
+ const encodedAuth = Buffer.from(`${apiToken}:`).toString('base64');
+ const body: GatewayApiSmsPayload = {
+ sender,
+ message: replaceSendMessageHandlebars(template.content, payload),
+ recipients: [{ msisdn: to }],
+ };
+
+ try {
+ return await got.post(endpoint, {
+ headers: {
+ Authorization: `Basic ${encodedAuth}`,
+ },
+ json: body,
+ });
+ } catch (error: unknown) {
+ if (error instanceof HTTPError) {
+ const {
+ response: { body: rawBody },
+ } = error;
+ assert(
+ typeof rawBody === 'string',
+ new ConnectorError(
+ ConnectorErrorCodes.InvalidResponse,
+ `Invalid response raw body type: ${typeof rawBody}`
+ )
+ );
+
+ throw new ConnectorError(ConnectorErrorCodes.General, rawBody);
+ }
+
+ throw error;
+ }
+ };
+
+const createGatewayApiSmsConnector: CreateConnector = async ({ getConfig }) => {
+ return {
+ metadata: defaultMetadata,
+ type: ConnectorType.Sms,
+ configGuard: gatewayApiSmsConfigGuard,
+ sendMessage: sendMessage(getConfig),
+ };
+};
+
+export default createGatewayApiSmsConnector;
diff --git a/packages/connectors/connector-gatewayapi-sms/src/mock.ts b/packages/connectors/connector-gatewayapi-sms/src/mock.ts
new file mode 100644
index 000000000..eac8bd6c9
--- /dev/null
+++ b/packages/connectors/connector-gatewayapi-sms/src/mock.ts
@@ -0,0 +1,17 @@
+import type { GatewayApiSmsConfig } from './types.js';
+
+const mockedEndpoint = 'https://gatewayapi.com/rest/mtsms';
+const mockedApiToken = 'api-token';
+const mockedSender = 'sender';
+
+export const mockedConfig: GatewayApiSmsConfig = {
+ endpoint: mockedEndpoint,
+ apiToken: mockedApiToken,
+ sender: mockedSender,
+ templates: [
+ {
+ usageType: 'Generic',
+ content: 'This is for testing purposes only. Your verification code is {{code}}.',
+ },
+ ],
+};
diff --git a/packages/connectors/connector-gatewayapi-sms/src/types.ts b/packages/connectors/connector-gatewayapi-sms/src/types.ts
new file mode 100644
index 000000000..a0eb476a5
--- /dev/null
+++ b/packages/connectors/connector-gatewayapi-sms/src/types.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod';
+
+/**
+ * UsageType here is used to specify the use case of the template, can be either
+ * 'Register', 'SignIn', 'ForgotPassword', 'Generic'.
+ */
+const requiredTemplateUsageTypes = ['Register', 'SignIn', 'ForgotPassword', 'Generic'];
+
+const templateGuard = z.object({
+ usageType: z.string(),
+ content: z.string(),
+});
+
+export const gatewayApiSmsConfigGuard = z.object({
+ endpoint: z.string(),
+ apiToken: z.string(),
+ sender: z.string(),
+ templates: z.array(templateGuard).refine(
+ (templates) =>
+ requiredTemplateUsageTypes.every((requiredType) =>
+ templates.map((template) => template.usageType).includes(requiredType)
+ ),
+ (templates) => ({
+ message: `Template with UsageType (${requiredTemplateUsageTypes
+ .filter(
+ (requiredType) => !templates.map((template) => template.usageType).includes(requiredType)
+ )
+ .join(', ')}) should be provided!`,
+ })
+ ),
+});
+
+export type GatewayApiSmsConfig = z.infer;
+
+export type GatewayApiSmsPayload = {
+ sender: string;
+ message: string;
+ recipients: Array<{ msisdn: string }>;
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0386d7edf..8219ba2ce 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -933,6 +933,64 @@ importers:
specifier: ^2.0.0
version: 2.0.0(@types/node@20.11.20)(happy-dom@14.12.3)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
+ packages/connectors/connector-gatewayapi-sms:
+ dependencies:
+ '@logto/connector-kit':
+ specifier: workspace:^4.0.0
+ version: link:../../toolkit/connector-kit
+ '@silverhand/essentials':
+ specifier: ^2.9.1
+ version: 2.9.1
+ got:
+ specifier: ^14.0.0
+ version: 14.0.0
+ snakecase-keys:
+ specifier: ^8.0.1
+ version: 8.0.1
+ zod:
+ specifier: ^3.23.8
+ version: 3.23.8
+ devDependencies:
+ '@silverhand/eslint-config':
+ specifier: 6.0.1
+ version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.5.3)
+ '@silverhand/ts-config':
+ specifier: 6.0.0
+ version: 6.0.0(typescript@5.5.3)
+ '@types/node':
+ specifier: ^20.11.20
+ version: 20.12.7
+ '@types/supertest':
+ specifier: ^6.0.2
+ version: 6.0.2
+ '@vitest/coverage-v8':
+ specifier: ^2.0.0
+ version: 2.0.0(vitest@2.0.0(@types/node@20.12.7)(happy-dom@14.12.3)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8))
+ eslint:
+ specifier: ^8.56.0
+ version: 8.57.0
+ lint-staged:
+ specifier: ^15.0.2
+ version: 15.0.2
+ nock:
+ specifier: ^13.3.1
+ version: 13.3.1
+ prettier:
+ specifier: ^3.0.0
+ version: 3.0.0
+ supertest:
+ specifier: ^7.0.0
+ version: 7.0.0
+ tsup:
+ specifier: ^8.1.0
+ version: 8.1.0(@swc/core@1.3.52(@swc/helpers@0.5.1))(postcss@8.4.39)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3))(typescript@5.5.3)
+ typescript:
+ specifier: ^5.5.3
+ version: 5.5.3
+ vitest:
+ specifier: ^2.0.0
+ version: 2.0.0(@types/node@20.12.7)(happy-dom@14.12.3)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
+
packages/connectors/connector-github:
dependencies:
'@logto/connector-kit':
@@ -18809,7 +18867,7 @@ snapshots:
debug: 4.3.5
enhanced-resolve: 5.16.0
eslint: 8.57.0
- eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
+ eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.3
@@ -18821,7 +18879,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
- eslint-module-utils@2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
+ eslint-module-utils@2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7(supports-color@5.5.0)
optionalDependencies:
@@ -18860,7 +18918,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
+ eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0)
hasown: 2.0.2
is-core-module: 2.13.1
is-glob: 4.0.3