> **The official FastAPI extension for sending Kakao Alimtalk, Brand Message and SMS**

`sendgo-fastapi` wraps the [`sendgo-python`](https://github.com/send-go/python) core as a FastAPI **dependency**, configured through a pydantic-settings model.

Requires FastAPI 0.110+ and Python 3.10+.

---

## Install

```bash
pip install sendgo-fastapi
```

The core `sendgo-python` comes along as a dependency.

---

## Configure

`SendgoSettings` reads these environment variables, so there is nothing to wire up in code:

```env
SENDGO_ACCESS_KEY=your_access_key
SENDGO_SECRET_KEY=your_secret_key
SENDGO_KAKAO_SENDER_KEY=your_kakao_sender_key
SENDGO_SMS_SENDER_KEY=your_sms_sender_key
SENDGO_API_VERSION=v2
```

`access_key` and `secret_key` have no defaults, so pydantic raises a validation error at startup if either is missing — a misconfigured deploy fails fast instead of at the first send.

---

## Quick start

```python
from fastapi import Depends, FastAPI
from sendgo_fastapi import SendgoDep

app = FastAPI()

@app.post("/notify")
async def notify(phone: str, order_no: str, sendgo = Depends(SendgoDep)):
    return sendgo.alimtalk.send(
        template_code="ORDER_CONFIRM_001",
        contacts=[{"contact": phone, "var1": order_no}],
    )
```

The dependency yields the core client, so every channel is available:

| Attribute | Channel |
|-----------|---------|
| `sendgo.alimtalk` | Kakao Alimtalk |
| `sendgo.friendtalk` | Kakao Friendtalk |
| `sendgo.brand_message` | Kakao Brand Message (v2 only) |
| `sendgo.sms` | SMS / LMS / MMS |

### Annotated form

With `Annotated` the dependency reads as part of the type, which keeps long signatures tidy:

```python
from typing import Annotated
from fastapi import Depends
from sendgo import Sendgo
from sendgo_fastapi import SendgoDep

SendgoClient = Annotated[Sendgo, Depends(SendgoDep)]

@app.post("/notify")
async def notify(phone: str, order_no: str, sendgo: SendgoClient):
    return sendgo.alimtalk.send(
        template_code="ORDER_CONFIRM_001",
        contacts=[{"contact": phone, "var1": order_no}],
    )
```

---

## 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 `SENDGO_API_VERSION=v2`.

```python
@app.post("/campaigns/targeted")
async def targeted(phone: str, sendgo: SendgoClient):
    return sendgo.brand_message.send(
        targeting="M",
        message_type="FL",
        friend_template_uuid="9cd5460b-6458-4edc-9b11-c26d3013c340",
        contacts=[{"contact": phone, "var1": "29,000 KRW"}],
    )

@app.post("/campaigns/broadcast")
async def broadcast(sendgo: SendgoClient):
    # No recipient list — Kakao expands the audience
    return sendgo.brand_message.broadcast(
        message_type="FW",
        friend_template_uuid="9cd5460b-6458-4edc-9b11-c26d3013c340",
    )

@app.get("/campaigns")
async def campaigns(sendgo: SendgoClient, count: int = 10):
    # `from` is a Python keyword, so the argument is `from_`.
    return sendgo.brand_message.campaigns(count=count)

@app.get("/campaigns/{campaign_id}")
async def campaign(campaign_id: str, sendgo: SendgoClient):
    return sendgo.brand_message.campaign(campaign_id)
```

A broadcast is processed asynchronously upstream, so the send response only acknowledges acceptance — poll `GET /campaigns/{campaign_id}` for progress.

---

## Background tasks

The core client is synchronous, so calling it directly inside an `async def` endpoint blocks the event loop. Move sending to a background task:

```python
from fastapi import BackgroundTasks, Depends, FastAPI
from sendgo import Sendgo, SendgoError
from sendgo_fastapi import SendgoDep

def deliver(sendgo: Sendgo, phone: str, order_no: str) -> None:
    try:
        sendgo.alimtalk.send(
            template_code="ORDER_CONFIRM_001",
            contacts=[{"contact": phone, "var1": order_no}],
        )
    except SendgoError as e:
        logger.error("Sendgo %s [%s]: %s", e.status_code, e.error_code, e)

@app.post("/orders", status_code=202)
async def create_order(
    phone: str,
    order_no: str,
    background: BackgroundTasks,
    sendgo = Depends(SendgoDep),
):
    background.add_task(deliver, sendgo, phone, order_no)

    return {"accepted": True}
```

For anything that must survive a restart, use a real queue (Celery, ARQ, Dramatiq) rather than `BackgroundTasks`.

### Running in a thread pool

If you need the result inside the request, keep the event loop free with `run_in_threadpool`:

```python
from starlette.concurrency import run_in_threadpool

@app.post("/notify")
async def notify(phone: str, sendgo: SendgoClient):
    return await run_in_threadpool(
        sendgo.alimtalk.send,
        template_code="ORDER_CONFIRM_001",
        contacts=[{"contact": phone}],
    )
```

---

## Testing

Override the dependency so no request leaves your test suite:

```python
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from sendgo_fastapi import SendgoDep

fake = MagicMock()
app.dependency_overrides[SendgoDep] = lambda: fake

client = TestClient(app)
client.post("/notify", params={"phone": "01012345678", "order_no": "ORD-001"})

fake.alimtalk.send.assert_called_once()

app.dependency_overrides.clear()
```

---

## Error handling

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

```python
from fastapi import HTTPException
from sendgo import SendgoError

@app.post("/notify")
async def notify(phone: str, sendgo: SendgoClient):
    try:
        return sendgo.alimtalk.send(
            template_code="ORDER_CONFIRM_001",
            contacts=[{"contact": phone}],
        )
    except SendgoError as e:
        if e.error_code in ("INVALID_ACCESS_KEY", "INVALID_SECRET_KEY", "IP_NOT_ALLOWED"):
            # Our configuration is wrong, not the caller's request.
            logger.critical("Sendgo configuration error: %s", e.error_code)
            raise HTTPException(status_code=500, detail="Messaging unavailable") from e

        if e.error_code == "PAYMENT_REQUIRED":
            raise HTTPException(status_code=402, detail="Out of credit") from e

        if e.status_code >= 500:
            raise HTTPException(status_code=502, detail="Upstream error") from e

        raise HTTPException(status_code=400, detail=e.error_code) from e
```

Branch on `error_code`, 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.

---

## Settings reference

| Environment variable | Field | Required | Default | Description |
|----------------------|-------|----------|---------|-------------|
| `SENDGO_ACCESS_KEY` | `access_key` | **required** | — | Sendgo access key |
| `SENDGO_SECRET_KEY` | `secret_key` | **required** | — | Sendgo secret key |
| `SENDGO_KAKAO_SENDER_KEY` | `kakao_sender_key` | optional | `None` | Kakao sender profile key |
| `SENDGO_SMS_SENDER_KEY` | `sms_sender_key` | optional | `None` | SMS caller ID key |
| `SENDGO_API_VERSION` | `api_version` | optional | `"v1"` | API version (`v1` \| `v2`) |
| `SENDGO_BASE_URL` | `base_url` | 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`.

```python
@app.post("/shorten")
async def shorten(target_url: str, sendgo: SendgoClient):
    created = sendgo.short_url.create(target_url=target_url)

    return {"shortUrl": created["data"]["shortUrl"]}

@app.get("/shorten/{code}/stats")
async def link_stats(code: str, sendgo: SendgoClient):
    return sendgo.short_url.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-fastapi` (PyPI)
- **Repository**: [send-go/fastapi](https://github.com/send-go/fastapi)
- **Registry**: https://pypi.org/project/sendgo-fastapi/
- **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_KAKAO_SENDER_KEY`, and a caller ID under **Sender numbers** for SMS.