> **The official ASP.NET Core extension for sending Kakao Alimtalk, Brand Message and SMS**

`Sendgo.AspNetCore` wraps the [`Sendgo.SDK`](https://www.nuget.org/packages/Sendgo.SDK) core with an `AddSendgo` extension that registers `SendgoClient` as a singleton.

Targets .NET 8.0.

---

## Install

```bash
dotnet add package Sendgo.AspNetCore
```

The core `Sendgo.SDK` comes along as a dependency.

---

## Register the client

### From configuration

```csharp
// Program.cs
builder.Services.AddSendgo(builder.Configuration.GetSection("Sendgo"));
```

```json
// appsettings.json
{
  "Sendgo": {
    "AccessKey": "your_access_key",
    "SecretKey": "your_secret_key",
    "KakaoSenderKey": "your_kakao_sender_key",
    "SmsSenderKey": "your_sms_sender_key",
    "ApiVersion": "v2"
  }
}
```

Keep the keys out of `appsettings.json` in source control — bind them from user secrets in development and from environment variables or a vault in production:

```bash
dotnet user-secrets set "Sendgo:AccessKey" "your_access_key"
dotnet user-secrets set "Sendgo:SecretKey" "your_secret_key"
```

Environment variables use the double-underscore separator: `Sendgo__AccessKey`.

### With a lambda

```csharp
builder.Services.AddSendgo(options =>
{
    options.AccessKey = builder.Configuration["Sendgo:AccessKey"]!;
    options.SecretKey = builder.Configuration["Sendgo:SecretKey"]!;
    options.KakaoSenderKey = builder.Configuration["Sendgo:KakaoSenderKey"];
    options.ApiVersion = "v2";
});
```

Both overloads register the same singleton, so the token cache is shared across the whole app. `SendgoClient` implements `IDisposable`, and the DI container disposes the singleton on shutdown — do not wrap it in a `using`.

---

## Inject the client

```csharp
using Sendgo;
using Sendgo.Models;

public class OrderNotifier(SendgoClient sendgo, ILogger<OrderNotifier> logger)
{
    public Task ConfirmedAsync(string phone, string orderNo, CancellationToken ct = default) =>
        sendgo.SendAlimtalkAsync(new AlimtalkRequest
        {
            TemplateCode = "ORDER_CONFIRM_001",
            Contacts = new[] { new Contact { PhoneNumber = phone, Var1 = orderNo } },
        }, ct);
}
```

```csharp
builder.Services.AddScoped<OrderNotifier>();
```

Note that `Contact.PhoneNumber` serialises as `contact` on the wire — the property is named for C# readability.

### Minimal API endpoint

```csharp
app.MapPost("/notify", async (
    NotifyRequest body,
    SendgoClient sendgo,
    CancellationToken ct) =>
{
    await sendgo.SendAlimtalkAsync(new AlimtalkRequest
    {
        TemplateCode = "ORDER_CONFIRM_001",
        Contacts = new[] { new Contact { PhoneNumber = body.Phone, Var1 = body.OrderNo } },
    }, ct);

    return Results.Accepted();
});

record NotifyRequest(string Phone, string OrderNo);
```

Passing the endpoint's `CancellationToken` through means an aborted request also cancels the outbound call.

---

## 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 reaches recipients who are **not channel friends** (`Targeting = "N"`) and can **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"`.

```csharp
app.MapPost("/campaigns/targeted", async (
    string phone, SendgoClient sendgo, CancellationToken ct) =>
{
    var result = await sendgo.SendBrandMessageAsync(new BrandMessageRequest
    {
        Targeting = "M",
        MessageType = "FL",
        FriendTemplateUuid = "9cd5460b-6458-4edc-9b11-c26d3013c340",
        Contacts = new[] { new Contact { PhoneNumber = phone, Var1 = "29,000 KRW" } },
    }, ct);

    return Results.Ok(result);
});

app.MapPost("/campaigns/broadcast", async (SendgoClient sendgo, CancellationToken ct) =>
{
    // No recipient list — Kakao expands the audience
    var result = await sendgo.BroadcastBrandMessageAsync(new BrandMessageRequest
    {
        MessageType = "FW",
        FriendTemplateUuid = "9cd5460b-6458-4edc-9b11-c26d3013c340",
    }, ct);

    return Results.Accepted(value: result);
});

app.MapGet("/campaigns", (SendgoClient sendgo, CancellationToken ct) =>
    sendgo.GetBrandMessagesAsync(count: 10, ct: ct));

app.MapGet("/campaigns/{campaignId}", (string campaignId, SendgoClient sendgo, CancellationToken ct) =>
    sendgo.GetBrandMessageAsync(campaignId, ct));
```

A broadcast is processed asynchronously upstream, so the send response only acknowledges acceptance — poll the detail endpoint for progress.

---

## Background sending

Sending is an outbound HTTP call, so keep it off the request path for anything the caller does not need to wait on:

```csharp
public class NotificationQueue
{
    private readonly Channel<AlimtalkRequest> _channel =
        Channel.CreateBounded<AlimtalkRequest>(1000);

    public ValueTask EnqueueAsync(AlimtalkRequest request, CancellationToken ct) =>
        _channel.Writer.WriteAsync(request, ct);

    public IAsyncEnumerable<AlimtalkRequest> ReadAllAsync(CancellationToken ct) =>
        _channel.Reader.ReadAllAsync(ct);
}

public class NotificationWorker(
    NotificationQueue queue,
    SendgoClient sendgo,
    ILogger<NotificationWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var request in queue.ReadAllAsync(stoppingToken))
        {
            try
            {
                await sendgo.SendAlimtalkAsync(request, stoppingToken);
            }
            catch (SendgoException e) when (e.StatusCode < 500)
            {
                // A 4xx will not succeed on retry — drop it and record why.
                logger.LogError("Sendgo {Code}: {Message}", e.ErrorCode, e.Message);
            }
            catch (SendgoException e)
            {
                logger.LogWarning("Transient Sendgo failure {Status}, re-queueing", e.StatusCode);
                await queue.EnqueueAsync(request, stoppingToken);
            }
        }
    }
}
```

```csharp
builder.Services.AddSingleton<NotificationQueue>();
builder.Services.AddHostedService<NotificationWorker>();
```

---

## Testing

`SendgoClient` is `sealed` and creates its own `HttpClient` internally, so it cannot be subclassed or handed a stub message handler. Put your own interface in front of it and mock that — which is also what keeps your call sites independent of the SDK:

```csharp
public interface IOrderNotifier
{
    Task ConfirmedAsync(string phone, string orderNo, CancellationToken ct = default);
}

public class OrderNotifier(SendgoClient sendgo) : IOrderNotifier
{
    public Task ConfirmedAsync(string phone, string orderNo, CancellationToken ct = default) =>
        sendgo.SendAlimtalkAsync(new AlimtalkRequest
        {
            TemplateCode = "ORDER_CONFIRM_001",
            Contacts = new[] { new Contact { PhoneNumber = phone, Var1 = orderNo } },
        }, ct);
}
```

```csharp
builder.Services.AddScoped<IOrderNotifier, OrderNotifier>();
```

```csharp
public class TestFactory : WebApplicationFactory<Program>
{
    public Mock<IOrderNotifier> Notifier { get; } = new();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            services.RemoveAll<IOrderNotifier>();
            services.AddScoped(_ => Notifier.Object);
        });
    }
}
```

For an end-to-end test that must exercise the SDK itself, point `Sendgo:BaseUrl` at a local stub server (WireMock.Net, or a second `WebApplication`) instead of mocking the client.

---

## Error handling

Map Sendgo errors onto HTTP status codes rather than letting them surface as 500s:

```csharp
using Sendgo.Exceptions;

app.UseExceptionHandler(handler => handler.Run(async context =>
{
    var error = context.Features.Get<IExceptionHandlerFeature>()?.Error;

    if (error is not SendgoException e)
    {
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        return;
    }

    context.Response.StatusCode = e.ErrorCode switch
    {
        // Our configuration is wrong, not the caller's request.
        "INVALID_ACCESS_KEY" or "INVALID_SECRET_KEY" or "IP_NOT_ALLOWED"
            => StatusCodes.Status500InternalServerError,
        "PAYMENT_REQUIRED" => StatusCodes.Status402PaymentRequired,
        _ => e.StatusCode >= 500 ? StatusCodes.Status502BadGateway : StatusCodes.Status400BadRequest,
    };

    await context.Response.WriteAsJsonAsync(new { error = e.ErrorCode });
}));
```

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

---

## Configuration reference

The configuration section binds directly onto `SendgoOptions`.

| Key | Required | Default | Description |
|-----|----------|---------|-------------|
| `Sendgo:AccessKey` | **required** | — | Sendgo access key |
| `Sendgo:SecretKey` | **required** | — | Sendgo secret key |
| `Sendgo:KakaoSenderKey` | optional | `null` | Kakao sender profile key |
| `Sendgo:SmsSenderKey` | optional | `null` | SMS caller ID key |
| `Sendgo:ApiVersion` | optional | `v1` | API version (`v1` \| `v2`) |
| `Sendgo:BaseUrl` | 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`.

```csharp
app.MapPost("/shorten", async (
    string targetUrl, SendgoClient sendgo, CancellationToken ct) =>
{
    var created = await sendgo.CreateShortUrlAsync(new ShortUrlRequest
    {
        TargetUrl = targetUrl,
    }, ct);

    return Results.Ok(created);
});

app.MapGet("/shorten/{code}/stats", (string code, SendgoClient sendgo, CancellationToken ct) =>
    sendgo.GetShortUrlStatsAsync(code, ct: ct));
```

`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.AspNetCore` (NuGet)
- **Repository**: [send-go/aspnetcore](https://github.com/send-go/aspnetcore)
- **Registry**: https://www.nuget.org/packages/Sendgo.AspNetCore
- **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 `Sendgo:KakaoSenderKey`, and a caller ID under **Sender numbers** for SMS.