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

`sendgo-django` wraps the [`sendgo-python`](https://github.com/send-go/python) core so the client is configured from `settings.SENDGO` and built lazily on first use.

Requires Django 4.2+ and Python 3.10+.

---

## Install

```bash
pip install sendgo-django
```

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

---

## Configure

### 1. Add the app

```python
# settings.py
INSTALLED_APPS = [
    # ...
    "sendgo_django",
]
```

### 2. Add the settings dict

```python
# settings.py
import os

SENDGO = {
    "ACCESS_KEY": os.environ["SENDGO_ACCESS_KEY"],
    "SECRET_KEY": os.environ["SENDGO_SECRET_KEY"],
    "KAKAO_SENDER_KEY": os.environ.get("SENDGO_KAKAO_SENDER_KEY"),
    "SMS_SENDER_KEY": os.environ.get("SENDGO_SMS_SENDER_KEY"),
    "API_VERSION": "v2",
}
```

`ACCESS_KEY` and `SECRET_KEY` are required — omitting either raises `ImproperlyConfigured` on first use rather than failing silently at send time.

---

## Quick start

```python
from sendgo_django import client

client.alimtalk.send(
    template_code="ORDER_CONFIRM_001",
    contacts=[{"contact": "01012345678", "name": "Gildong Hong", "var1": "ORD-001"}],
)
```

`client` is a `SimpleLazyObject`, so importing it at module level does **not** read settings or build anything. The core client is created on first attribute access and memoised after that — safe to import in `models.py`, signals, or anywhere else that loads during startup.

Every channel from the core is available on it:

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

---

## 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 `"API_VERSION": "v2"`.

```python
from sendgo_django import client

# Single send — channel friends
client.brand_message.send(
    targeting="M",
    message_type="FL",
    friend_template_uuid="9cd5460b-6458-4edc-9b11-c26d3013c340",
    contacts=[{"contact": "01012345678", "var1": "29,000 KRW"}],
)

# Broadcast — every consenting channel friend (no contacts)
client.brand_message.broadcast(
    message_type="FW",
    friend_template_uuid="9cd5460b-6458-4edc-9b11-c26d3013c340",
)

# Campaign lookups. `from` is a Python keyword, so the argument is `from_`.
campaigns = client.brand_message.campaigns(from_="2026-08-01", count=10)
one = client.brand_message.campaign("1f0a6d0e-6b3b-4f0f-9b2f-2f6f6a1b7c11")
```

---

## Sending from a signal

Send after the transaction commits, not inside it — otherwise a rollback still leaves the message delivered:

```python
# orders/signals.py
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Order
from .tasks import send_order_confirm

@receiver(post_save, sender=Order)
def notify_order_created(sender, instance, created, **kwargs):
    if not created:
        return

    transaction.on_commit(
        lambda: send_order_confirm.delay(instance.phone, instance.number)
    )
```

---

## Celery task

Sending is a network call, so keep it out of the request cycle:

```python
# orders/tasks.py
from celery import shared_task
from sendgo import SendgoError
from sendgo_django import client

@shared_task(bind=True, max_retries=3)
def send_order_confirm(self, phone: str, order_no: str):
    try:
        client.alimtalk.send(
            template_code="ORDER_CONFIRM_001",
            contacts=[{"contact": phone, "var1": order_no}],
        )
    except SendgoError as exc:
        # 5xx is transient; a 4xx will not succeed on retry.
        if exc.status_code >= 500:
            raise self.retry(exc=exc, countdown=2 ** self.request.retries)
        raise
```

---

## Management command

```python
# orders/management/commands/send_promo.py
from django.core.management.base import BaseCommand
from sendgo_django import client

class Command(BaseCommand):
    help = "Broadcast the current promotion to every consenting channel friend"

    def handle(self, *args, **options):
        result = client.brand_message.broadcast(
            message_type="FW",
            friend_template_uuid="9cd5460b-6458-4edc-9b11-c26d3013c340",
        )

        self.stdout.write(self.style.SUCCESS(f"Accepted: {result['data']['campaignId']}"))
```

---

## Testing

Patch the lazy object's underlying client so no request leaves your test suite:

```python
from unittest.mock import MagicMock, patch

@patch("sendgo_django.conf.get_client")
def test_order_creation_sends_alimtalk(get_client, db):
    fake = MagicMock()
    get_client.return_value = fake

    Order.objects.create(phone="01012345678", number="ORD-001")

    fake.alimtalk.send.assert_called_once()
```

Patching `get_client` rather than the lazy object keeps each test isolated. `sendgo_django.conf.reset()` drops the memoised instance if you need to rebuild it mid-test.

---

## Error handling

```python
from sendgo import SendgoError
from sendgo_django import client

try:
    client.alimtalk.send(
        template_code="ORDER_CONFIRM_001",
        contacts=[{"contact": "01012345678"}],
    )
except SendgoError as e:
    if e.error_code in ("INVALID_ACCESS_KEY", "INVALID_SECRET_KEY"):
        logger.critical("Check the Sendgo API keys")
    elif e.error_code == "PAYMENT_REQUIRED":
        logger.critical("Out of Sendgo credit")
    elif e.error_code == "IP_NOT_ALLOWED":
        logger.critical("IP is not allow-listed")
    elif e.status_code >= 500:
        retry_later()
    else:
        logger.error("Sendgo %s: %s", e.status_code, 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

| Key | Required | Default | Description |
|-----|----------|---------|-------------|
| `ACCESS_KEY` | **required** | — | Sendgo access key |
| `SECRET_KEY` | **required** | — | Sendgo secret key |
| `KAKAO_SENDER_KEY` | optional | `None` | Kakao sender profile key |
| `SMS_SENDER_KEY` | optional | `None` | SMS caller ID key |
| `API_VERSION` | optional | `"v1"` | API version (`v1` \| `v2`) |
| `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
from sendgo_django import client

created = client.short_url.create(
    target_url="https://example.com/promotions/summer-sale",
    title="Summer sale landing",
)

code = created["data"]["code"]
stats = client.short_url.stats(code, from_="2026-08-01")
```

`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-django` (PyPI)
- **Repository**: [send-go/django](https://github.com/send-go/django)
- **Registry**: https://pypi.org/project/sendgo-django/
- **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 `KAKAO_SENDER_KEY`, and a caller ID under **Sender numbers** for SMS.