Add partner SMS gateway via Gama SendQuick and document it.

Expose POST /public/sms/send with API key + domain allowlist for external backends like Balout, wire Meshkee OTP/message sends to Gama, and publish Partner SMS docs on /docs/website.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-04 11:09:21 +03:30
co-authored by Cursor
parent 267a218c26
commit f4295e780c
22 changed files with 803 additions and 16 deletions
+2
View File
@@ -34,6 +34,7 @@ import { InternalSslModule } from './internal-ssl/internal-ssl.module';
import { WebsiteDocsModule } from './website-docs/website-docs.module';
import { InvoicesModule } from './invoices/invoices.module';
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
import { PublicSmsModule } from './public-sms/public-sms.module';
@Module({
imports: [
@@ -72,6 +73,7 @@ import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
WebsiteModule,
WebsiteDocsModule,
InvoicesModule,
PublicSmsModule,
],
})
export class AppModule {}
+168 -11
View File
@@ -1,6 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export type SmsSendResult = {
serverId: string;
};
type GamaQuickResponse = {
success?: boolean;
message?: string | null;
errors?: unknown;
body?: number | string | null;
};
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
@@ -11,27 +27,168 @@ export class SmsService {
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
}
async sendVerificationCode(cellNumber: string, code: string): Promise<void> {
async sendVerificationCode(cellNumber: string, code: string): Promise<SmsSendResult> {
if (!this.isEnabled()) {
this.logger.warn(
`SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`,
);
return;
return { serverId: 'disabled' };
}
// TODO: integrate real SMS provider when API credentials are available
this.logger.log(`Sending SMS verification code to ${cellNumber}`);
throw new Error('SMS provider is not configured yet');
const message = `کد تایید شما: ${code}`;
return this.sendMessage(cellNumber, message);
}
async sendMessage(cellNumber: string, message: string): Promise<void> {
async sendMessage(cellNumber: string, message: string): Promise<SmsSendResult> {
if (!this.isEnabled()) {
this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`);
return;
return { serverId: 'disabled' };
}
// TODO: integrate real SMS provider when API credentials are available
this.logger.log(`Sending SMS message to ${cellNumber}: ${message}`);
throw new Error('SMS provider is not configured yet');
const text = message.trim();
if (!text) {
throw new BadRequestException('Message is empty');
}
if (text.length > 700) {
throw new BadRequestException('Message is too long (max 700 characters)');
}
const destination = toGamaMsisdn(cellNumber);
if (!destination) {
throw new BadRequestException('Invalid cellphone number');
}
return this.sendQuick(destination, text);
}
private async sendQuick(destination: string, message: string): Promise<SmsSendResult> {
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
const source = this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
const baseUrl = (
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
).replace(/\/$/, '');
if (!username || !password || !source) {
this.logger.error('SMS provider credentials are not configured');
throw new ServiceUnavailableException('SMS provider is not configured yet');
}
const url = `${baseUrl}/send/quick`;
const masked = maskMsisdn(destination);
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
message,
source,
destination,
delay: 0,
expire: null,
}),
signal: AbortSignal.timeout(15_000),
});
} catch (err) {
this.logger.error(
`Gama SendQuick network error for ${masked}: ${err instanceof Error ? err.message : err}`,
);
throw new ServiceUnavailableException('SMS provider is unreachable');
}
let payload: GamaQuickResponse;
try {
payload = (await response.json()) as GamaQuickResponse;
} catch {
this.logger.error(`Gama SendQuick returned non-JSON for ${masked} (HTTP ${response.status})`);
throw new ServiceUnavailableException('SMS provider returned an invalid response');
}
if (!response.ok || !payload.success) {
const providerMessage =
typeof payload.message === 'string' && payload.message.trim()
? payload.message.trim()
: `HTTP ${response.status}`;
this.logger.warn(`Gama SendQuick failed for ${masked}: ${providerMessage}`);
throw mapGamaError(providerMessage);
}
const serverId = String(payload.body ?? '');
if (!serverId) {
this.logger.warn(`Gama SendQuick succeeded without serverId for ${masked}`);
throw new ServiceUnavailableException('SMS provider returned an empty server id');
}
this.logger.log(`SMS sent to ${masked} via Gama (serverId=${serverId})`);
return { serverId };
}
}
/** Accept 09… / 9… / +989… / 989… → Gama MSISDN `989…` (no plus). */
export function toGamaMsisdn(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const digits = trimmed.replace(/\D/g, '');
if (!digits) return null;
let msisdn = digits;
if (msisdn.startsWith('0') && msisdn.length === 11) {
msisdn = `98${msisdn.slice(1)}`;
} else if (msisdn.length === 10 && msisdn.startsWith('9')) {
msisdn = `98${msisdn}`;
} else if (msisdn.startsWith('98') && msisdn.length === 12) {
// already MSISDN
} else if (trimmed.startsWith('+') && digits.length >= 10 && digits.length <= 15) {
msisdn = digits;
} else {
return null;
}
if (!/^98\d{10}$/.test(msisdn)) {
return null;
}
return msisdn;
}
export function maskMsisdn(msisdn: string): string {
if (msisdn.length < 6) return '***';
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
}
function mapGamaError(message: string): Error {
const lower = message.toLowerCase();
if (
lower.includes('authentication failed') ||
lower.includes('access denied') ||
lower.includes('inactive user')
) {
return new ServiceUnavailableException('SMS provider authentication failed');
}
if (
lower.includes('credit is not enough') ||
lower.includes('expired account')
) {
return new ServiceUnavailableException('SMS provider credit or account issue');
}
if (
lower.includes('cellphone') ||
lower.includes('valid cellphone') ||
lower.includes('invalid orig') ||
lower.includes('arguments')
) {
return new BadRequestException('Invalid SMS destination or sender');
}
if (lower.includes('message is empty')) {
return new BadRequestException('Message is empty');
}
return new ServiceUnavailableException('SMS provider rejected the request');
}
+19
View File
@@ -0,0 +1,19 @@
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
export class SendPublicSmsDto {
@IsString()
@IsNotEmpty()
@MaxLength(253)
domain!: string;
@IsString()
@IsNotEmpty()
@MaxLength(32)
to!: string;
@IsString()
@IsNotEmpty()
@MinLength(1)
@MaxLength(700)
message!: string;
}
+20
View File
@@ -0,0 +1,20 @@
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { SendPublicSmsDto } from './dto/send-public-sms.dto';
import { PublicSmsService } from './public-sms.service';
import { SMS_PARTNER_DOMAIN_KEY, SmsPartnerGuard } from './sms-partner.guard';
@Controller('public/sms')
export class PublicSmsController {
constructor(private readonly service: PublicSmsService) {}
@Post('send')
@UseGuards(SmsPartnerGuard)
send(
@Body() dto: SendPublicSmsDto,
@Req() req: Request & { [SMS_PARTNER_DOMAIN_KEY]?: string },
) {
const partnerDomain = req[SMS_PARTNER_DOMAIN_KEY] ?? dto.domain;
return this.service.send(partnerDomain, dto);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PublicSmsController } from './public-sms.controller';
import { PublicSmsService } from './public-sms.service';
import { SmsPartnerGuard } from './sms-partner.guard';
@Module({
imports: [AuthModule],
controllers: [PublicSmsController],
providers: [PublicSmsService, SmsPartnerGuard],
})
export class PublicSmsModule {}
+73
View File
@@ -0,0 +1,73 @@
import {
HttpException,
HttpStatus,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { RedisService } from '../redis/redis.service';
import { SmsService, toGamaMsisdn } from '../auth/sms.service';
import { SendPublicSmsDto } from './dto/send-public-sms.dto';
const PARTNER_LIMIT_PER_MINUTE = 30;
const DESTINATION_LIMIT_PER_MINUTE = 5;
const WINDOW_SECONDS = 60;
@Injectable()
export class PublicSmsService {
private readonly logger = new Logger(PublicSmsService.name);
constructor(
private readonly sms: SmsService,
private readonly redis: RedisService,
) {}
async send(partnerDomain: string, dto: SendPublicSmsDto) {
if (!this.sms.isEnabled()) {
throw new ServiceUnavailableException('SMS is currently disabled');
}
const msisdn = toGamaMsisdn(dto.to);
if (!msisdn) {
throw new HttpException('Invalid cellphone number', HttpStatus.BAD_REQUEST);
}
await this.assertRateLimits(partnerDomain, msisdn);
const result = await this.sms.sendMessage(msisdn, dto.message);
this.logger.log(
`Partner SMS accepted domain=${partnerDomain} serverId=${result.serverId}`,
);
return {
success: true,
serverId: result.serverId,
};
}
private async assertRateLimits(domain: string, msisdn: string): Promise<void> {
const partnerOk = await this.redis.incrementWithLimit(
`sms:rl:partner:${domain}`,
PARTNER_LIMIT_PER_MINUTE,
WINDOW_SECONDS,
);
if (!partnerOk) {
throw new HttpException(
'Too many SMS requests for this partner. Try again later.',
HttpStatus.TOO_MANY_REQUESTS,
);
}
const destOk = await this.redis.incrementWithLimit(
`sms:rl:dest:${msisdn}`,
DESTINATION_LIMIT_PER_MINUTE,
WINDOW_SECONDS,
);
if (!destOk) {
throw new HttpException(
'Too many SMS requests for this destination. Try again later.',
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
}
+48
View File
@@ -0,0 +1,48 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import {
normalizePartnerDomain,
parseSmsPartners,
timingSafeEqualString,
} from './sms-partners.util';
export const SMS_PARTNER_DOMAIN_KEY = 'smsPartnerDomain';
@Injectable()
export class SmsPartnerGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const partners = parseSmsPartners(this.config.get<string>('SMS_PARTNERS'));
if (partners.size === 0) {
throw new UnauthorizedException('SMS partner gateway is not configured');
}
const req = context.switchToHttp().getRequest<Request>();
const providedKey = String(req.headers['x-api-key'] ?? '').trim();
if (!providedKey) {
throw new UnauthorizedException('Missing X-Api-Key');
}
const body = (req.body ?? {}) as { domain?: unknown };
const domain = normalizePartnerDomain(String(body.domain ?? ''));
if (!domain) {
throw new UnauthorizedException('Missing or invalid domain');
}
const expectedKey = partners.get(domain);
if (!expectedKey || !timingSafeEqualString(providedKey, expectedKey)) {
throw new UnauthorizedException('Invalid API key or domain');
}
(req as Request & { [SMS_PARTNER_DOMAIN_KEY]?: string })[SMS_PARTNER_DOMAIN_KEY] =
domain;
return true;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { timingSafeEqual } from 'crypto';
/** Lowercase host, strip protocol/path/port, strip leading www. */
export function normalizePartnerDomain(raw: string): string {
let value = raw.trim().toLowerCase();
if (!value) return '';
value = value.replace(/^https?:\/\//, '');
value = value.split('/')[0] ?? '';
value = value.split(':')[0] ?? '';
if (value.startsWith('www.')) {
value = value.slice(4);
}
return value;
}
/** Parse `domain:apiKey,domain2:apiKey2` into a map keyed by normalized domain. */
export function parseSmsPartners(raw: string | undefined | null): Map<string, string> {
const map = new Map<string, string>();
if (!raw?.trim()) return map;
for (const part of raw.split(',')) {
const entry = part.trim();
if (!entry) continue;
const idx = entry.indexOf(':');
if (idx <= 0) continue;
const domain = normalizePartnerDomain(entry.slice(0, idx));
const apiKey = entry.slice(idx + 1).trim();
if (domain && apiKey) {
map.set(domain, apiKey);
}
}
return map;
}
export function timingSafeEqualString(a: string, b: string): boolean {
const left = Buffer.from(a);
const right = Buffer.from(b);
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
+16
View File
@@ -21,4 +21,20 @@ export class RedisService {
async deleteOtp(cellNumber: string): Promise<void> {
await this.redis.del(`otp:${cellNumber}`);
}
/**
* Sliding fixed-window counter. Returns true if the call is within `limit`
* for the given key over `windowSeconds`.
*/
async incrementWithLimit(
key: string,
limit: number,
windowSeconds: number,
): Promise<boolean> {
const count = await this.redis.incr(key);
if (count === 1) {
await this.redis.expire(key, windowSeconds);
}
return count <= limit;
}
}
+1
View File
@@ -26,6 +26,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M
4. Customer register body must include `"domain": "<WEBSITE_DOMAIN>"`.
5. Cell numbers are E.164 (`+98912...`).
6. Do not call dashboard/CMS routes (`/businesses/.../products` write APIs, media upload, domain-admin, etc.).
7. **Partner SMS** (`POST /public/sms/send`) is for external partner backends with an issued `X-Api-Key` only — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md
### Typical bootstrap sequence
1. `GET /tenants/{domain}` → branding + `businessId`
@@ -100,6 +100,10 @@
{
"key": "brandId",
"value": ""
},
{
"key": "smsApiKey",
"value": ""
}
],
"item": [
@@ -1669,6 +1673,34 @@
}
}
]
},
{
"name": "Partner SMS",
"description": "Server-to-server SMS for allowlisted partner domains. Set collection variable `smsApiKey`. Docs: https://api.meshkee.com/docs/website/SMS.md",
"item": [
{
"name": "Send SMS",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-Api-Key",
"value": "{{smsApiKey}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"domain\": \"baloutpastry.com\",\n \"to\": \"09127004945\",\n \"message\": \"سفارش شما به شماره ی ۱۲۱۱۳۲۲ اماده می باشد.\"\n}"
},
"url": "{{baseUrl}}/public/sms/send",
"description": "Requires allowlisted domain + matching API key. Not for browser JS."
}
}
]
}
]
}
+85
View File
@@ -0,0 +1,85 @@
# Partner SMS gateway
Server-to-server SMS via Meshkee → Gama (گاما). Use this when a **non-Meshkee** (or partner) backend needs to send SMS through the shared Meshkee account.
**Not for browser/storefront JavaScript.** Never put the API key in frontend code.
Hub: https://api.meshkee.com/docs/website
Endpoint docs also in OpenAPI / Postman under **Partner SMS**.
---
## Endpoint
```http
POST https://api.meshkee.com/api/v1/public/sms/send
Content-Type: application/json
X-Api-Key: <partner-secret>
```
### Body
| Field | Required | Description |
|-------|----------|-------------|
| `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) |
| `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` |
| `message` | yes | Free text, max 700 characters |
### Example (Balout)
```bash
curl -sS -X POST 'https://api.meshkee.com/api/v1/public/sms/send' \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: <YOUR_PARTNER_KEY>' \
-d '{
"domain": "baloutpastry.com",
"to": "09127004945",
"message": "سفارش شما به شماره ی ۱۲۱۱۳۲۲ اماده می باشد."
}'
```
### Success (200)
```json
{
"success": true,
"serverId": "1136923081051406337"
}
```
`serverId` is the Gama message id (delivery tracking).
### Errors
| HTTP | Meaning |
|------|---------|
| 400 | Invalid phone or empty/too-long message |
| 401 | Missing/invalid `X-Api-Key` or domain not allowlisted |
| 429 | Rate limit: **30**/partner/minute or **5**/destination/minute |
| 503 | SMS disabled, missing provider config, or Gama unreachable |
---
## Auth model
1. Meshkee configures `SMS_PARTNERS=domain:apiKey,...` on the API server.
2. Partner backend sends `X-Api-Key` + matching `domain` in the JSON body.
3. Key must match that domain (timing-safe compare). `www.baloutpastry.com` normalizes to `baloutpastry.com`.
First allowlisted partner: **baloutpastry.com**.
---
## Sender line (v1)
Uses the **service** shortcode only (`SendQuick`). Advertising / bulk / OTP pattern APIs are not exposed yet.
---
## Rules for partner backends
1. Call from your **server** only (Balout API → Meshkee API).
2. Keep the API key in server env / secrets — never in the website frontend.
3. Prefer short transactional messages (order ready, OTP-style text, etc.).
4. Respect rate limits; backoff on `429`.
5. Meshkee websites that already use customer auth OTP go through Meshkees own auth/SMS path — they do **not** need this partner endpoint.
+12
View File
@@ -77,6 +77,7 @@
<a class="btn" href="/docs/website/openapi.json">OpenAPI JSON</a>
<a class="btn secondary" href="/docs/website/Meshkee-Website-API.postman_collection.json">Download Postman</a>
<a class="btn secondary" href="/docs/website/AI_PROMPT.md">AI prompt</a>
<a class="btn secondary" href="/docs/website/SMS.md">Partner SMS</a>
</div>
<h2>Base URL</h2>
@@ -104,6 +105,17 @@
Postman → Import → Link → paste<br />
<code>https://api.meshkee.com/docs/website/Meshkee-Website-API.postman_collection.json</code>
</p>
<h2>Partner SMS gateway</h2>
<p>
External backends (e.g. Balout) can send transactional SMS through Meshkee → Gama.
Server-to-server only — API key per allowlisted domain. See
<a href="/docs/website/SMS.md">SMS.md</a>.
</p>
<div class="panel">
<p style="margin:0"><code>POST /api/v1/public/sms/send</code></p>
<p style="margin:0.5rem 0 0">Header <code>X-Api-Key</code> + body <code>{ domain, to, message }</code></p>
</div>
</main>
</body>
</html>
+64 -1
View File
@@ -36,7 +36,8 @@
{ "name": "Cities" },
{ "name": "Cart" },
{ "name": "Orders" },
{ "name": "Favorites" }
{ "name": "Favorites" },
{ "name": "Partner SMS" }
],
"components": {
"securitySchemes": {
@@ -44,6 +45,12 @@
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
},
"apiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "X-Api-Key",
"description": "Partner SMS API key (server-to-server only). Issued per allowlisted domain."
}
},
"parameters": {
@@ -897,6 +904,62 @@
],
"responses": { "200": { "description": "{ message }" } }
}
},
"/public/sms/send": {
"post": {
"tags": ["Partner SMS"],
"summary": "Send SMS via Meshkee (partner gateway)",
"description": "Server-to-server only. For external/partner backends (e.g. Balout) that need to send SMS through Meshkee → Gama. Not for browser/storefront JS. Requires an allowlisted `domain` + matching `X-Api-Key`. See /docs/website/SMS.md.",
"security": [{ "apiKeyAuth": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["domain", "to", "message"],
"properties": {
"domain": {
"type": "string",
"example": "baloutpastry.com",
"description": "Allowlisted partner apex (www. is stripped)"
},
"to": {
"type": "string",
"example": "09127004945",
"description": "Iranian mobile: 09…, 9…, +989…, or 989…"
},
"message": {
"type": "string",
"maxLength": 700,
"example": "سفارش شما به شماره ی ۱۲۱۱۳۲۲ اماده می باشد."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Accepted by Gama",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": { "type": "boolean", "example": true },
"serverId": { "type": "string", "example": "1136923081051406337" }
}
}
}
}
},
"400": { "description": "Invalid phone or message" },
"401": { "description": "Missing/invalid X-Api-Key or domain" },
"429": { "description": "Rate limited (30/partner/min or 5/destination/min)" },
"503": { "description": "SMS disabled or provider unreachable" }
}
}
}
}
}
@@ -14,6 +14,7 @@ const ALLOWED_FILES = new Set([
'index.html',
'openapi.json',
'AI_PROMPT.md',
'SMS.md',
'Meshkee-Website-API.postman_collection.json',
'Meshkee-Website-API.global.postman_environment.json',
]);