> **The official NestJS module for sending Kakao Alimtalk, Brand Message and SMS**

`@sendgo/nestjs` wraps the [`@sendgo/node`](https://github.com/send-go/node) core as a **NestJS module**, so the client is registered once and injected wherever you need it.

Requires NestJS 10 or newer.

---

## Install

```bash
npm install @sendgo/nestjs
```

The core `@sendgo/node` comes along as a dependency — you do not install it separately.

---

## Register the module

### Static configuration

```typescript
import { Module } from '@nestjs/common';
import { SendgoModule } from '@sendgo/nestjs';

@Module({
  imports: [
    SendgoModule.forRoot({
      accessKey: process.env.SENDGO_ACCESS_KEY!,
      secretKey: process.env.SENDGO_SECRET_KEY!,
      kakaoSenderKey: process.env.SENDGO_KAKAO_SENDER_KEY,
      smsSenderKey: process.env.SENDGO_SMS_SENDER_KEY,
      apiVersion: 'v2',
    }),
  ],
})
export class AppModule {}
```

### Async configuration with ConfigService

Prefer this when configuration comes from `@nestjs/config`, Vault, or any async source:

```typescript
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SendgoModule } from '@sendgo/nestjs';

@Module({
  imports: [
    ConfigModule.forRoot(),
    SendgoModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        accessKey: config.getOrThrow<string>('SENDGO_ACCESS_KEY'),
        secretKey: config.getOrThrow<string>('SENDGO_SECRET_KEY'),
        kakaoSenderKey: config.get<string>('SENDGO_KAKAO_SENDER_KEY'),
        apiVersion: 'v2',
      }),
    }),
  ],
})
export class AppModule {}
```

`getOrThrow` makes a missing key fail at bootstrap rather than at the first send.

---

## Inject the service

```typescript
import { Injectable, Logger } from '@nestjs/common';
import { SendgoService } from '@sendgo/nestjs';

@Injectable()
export class OrderNotifier {
  private readonly logger = new Logger(OrderNotifier.name);

  constructor(private readonly sendgo: SendgoService) {}

  async confirmed(phone: string, orderNo: string) {
    await this.sendgo.alimtalk.send({
      templateCode: 'ORDER_CONFIRM_001',
      contacts: [{ contact: phone, var1: orderNo }],
    });
  }
}
```

`SendgoService` exposes each channel as a getter that forwards to the core client:

| Getter | Channel |
|--------|---------|
| `sendgo.alimtalk` | Kakao Alimtalk |
| `sendgo.friendtalk` | Kakao Friendtalk |
| `sendgo.brandMessage` | Kakao Brand Message (v2 only) |
| `sendgo.sms` | SMS / LMS / MMS |
| `sendgo.client` | the underlying `Sendgo` instance |

Use `sendgo.client` when you need something the service does not surface yet — it is the same singleton, so the token cache is shared.

---

## Brand Message

> **Friendtalk was discontinued on 2025-12-31.** Since 2026-01-01 Kakao delivers
> Friendtalk requests as Brand Message (free-form) automatically. `friendtalk` still
> works and is still the only path for free-form `FT`/`FI`/`FW` to individual
> recipients — use Brand Message for template-based rich types (`FL`/`FC`/`FM`/`FP`/`FA`),
> non-friend targeting (`N`/`I`) and broadcasts (`F`).
Brand Message is the successor channel to Friendtalk: it can reach recipients who are **not channel friends** (`targeting: 'N'`) and **broadcast to every consenting channel friend** (`targeting: 'F'`). Message types map one-to-one with Friendtalk (`FT`→`BT`, `FI`→`BI`, `FW`→`BW`, `FL`→`BL`, `FC`→`BC`, `FM`→`BM`, `FP`→`BP`, `FA`→`BA`) — pass the Friendtalk code and the server converts it.

> v2 only. Set `apiVersion: 'v2'` when registering the module.

```typescript
@Injectable()
export class CampaignService {
  constructor(private readonly sendgo: SendgoService) {}

  // Single send — channel friends
  sendToFriends(phone: string) {
    return this.sendgo.brandMessage.send({
      targeting: 'M',
      messageType: 'FL',
      friendTemplateUuid: '9cd5460b-6458-4edc-9b11-c26d3013c340',
      contacts: [{ contact: phone, var1: '29,000 KRW' }],
    });
  }

  // Broadcast — every consenting channel friend (no recipient list)
  broadcast() {
    return this.sendgo.brandMessage.broadcast({
      messageType: 'FW',
      friendTemplateUuid: '9cd5460b-6458-4edc-9b11-c26d3013c340',
    });
  }

  // Campaign lookups
  list() {
    return this.sendgo.brandMessage.campaigns({ count: 10 });
  }

  detail(campaignId: string) {
    return this.sendgo.brandMessage.campaign(campaignId);
  }
}
```

---

## Queued sending with BullMQ

Sending is an outbound HTTP call, so keep it out of the request cycle:

```typescript
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job, UnrecoverableError } from 'bullmq';
import { SendgoService } from '@sendgo/nestjs';
import { SendgoError } from '@sendgo/node';

@Processor('notifications')
export class NotificationProcessor extends WorkerHost {
  constructor(private readonly sendgo: SendgoService) {
    super();
  }

  async process(job: Job<{ phone: string; orderNo: string }>) {
    try {
      await this.sendgo.alimtalk.send({
        templateCode: 'ORDER_CONFIRM_001',
        contacts: [{ contact: job.data.phone, var1: job.data.orderNo }],
      });
    } catch (error) {
      if (error instanceof SendgoError && error.statusCode < 500) {
        // A 4xx will not succeed on retry — stop burning attempts.
        throw new UnrecoverableError(`${error.errorCode}: ${error.message}`);
      }
      throw error; // 5xx and network errors fall through to BullMQ's backoff
    }
  }
}
```

---

## Testing

Override the provider so no request leaves the test suite:

```typescript
import { Test } from '@nestjs/testing';
import { SendgoService } from '@sendgo/nestjs';

const sent: unknown[] = [];

const moduleRef = await Test.createTestingModule({
  providers: [OrderNotifier],
})
  .useMocker((token) => {
    if (token === SendgoService) {
      return {
        alimtalk: { send: async (payload: unknown) => { sent.push(payload); return { success: true }; } },
      };
    }
  })
  .compile();

await moduleRef.get(OrderNotifier).confirmed('01012345678', 'ORD-001');
expect(sent).toHaveLength(1);
```

---

## Error handling

```typescript
import { SendgoError } from '@sendgo/node';

try {
  await this.sendgo.alimtalk.send({ /* ... */ });
} catch (error) {
  if (!(error instanceof SendgoError)) throw error;

  switch (error.errorCode) {
    case 'INVALID_ACCESS_KEY':
    case 'INVALID_SECRET_KEY':
      this.logger.error('Check the Sendgo API keys');
      break;
    case 'PAYMENT_REQUIRED':
      this.logger.error('Out of Sendgo credit');
      break;
    case 'IP_NOT_ALLOWED':
      this.logger.error('IP is not allow-listed');
      break;
    default:
      this.logger.error(`Sendgo ${error.statusCode}: ${error.message}`);
  }
}
```

Branch on `errorCode`, not on the message text — messages can change, codes are the contract.
`TOKEN_EXPIRED` and `TOKEN_MISMATCH` are handled inside the SDK: the token is reissued and the request retried once.

---

## Configuration options

`forRoot()` and the factory return of `forRootAsync()` both take the core client options.

| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| `accessKey` | `string` | **required** | — | Sendgo access key |
| `secretKey` | `string` | **required** | — | Sendgo secret key |
| `kakaoSenderKey` | `string` | optional | — | Kakao sender profile key |
| `smsSenderKey` | `string` | optional | — | SMS caller ID key |
| `apiVersion` | `'v1' \| 'v2'` | optional | `'v1'` | API version |
| `baseUrl` | `string` | optional | `'https://sendgo.io'` | API base URL |

---

## Short URL

Short URLs shrink the links in your message body and count whether they were
actually clicked. SMS is billed by byte, so a shorter link leaves more room for copy.

> v2 only.

Shortening the same target URL again **returns the existing link**. Pass `forceNew`
to mint a new code when you want per-campaign reaction figures kept separate.

`deactivate` does not delete the link — it only stops the redirect. Use it when a link
in an already-sent message has to be killed; the accumulated stats stay, and visitors
to a stopped link get `410 Gone`.

```typescript
@Injectable()
export class LinkService {
  constructor(private readonly sendgo: SendgoService) {}

  async shorten(targetUrl: string) {
    const created = await this.sendgo.shortUrl.create({ targetUrl });

    return created.data.shortUrl;
  }

  stats(code: string) {
    return this.sendgo.shortUrl.stats(code);
  }
}
```

`stats` returns a daily series (`daily`) plus breakdowns by device (`byDevice`), referrer (`byReferer`) and country (`byCountry`). The daily series is read from a pre-aggregated table, so response time stays flat no matter how many clicks accumulate.

---

## Package information

- **Package**: `@sendgo/nestjs` (npm)
- **Repository**: [send-go/nestjs](https://github.com/send-go/nestjs)
- **Registry**: https://www.npmjs.com/package/@sendgo/nestjs
- **License**: MIT

### Getting your API keys

Sign in to Sendgo and open **API/SDK → API integration** to issue an access key and secret key.
Register a Kakao sender profile under **Kakao channel** to get your `kakaoSenderKey`, and a caller ID under **Sender numbers** for SMS.