# Reddit Conversions API — SignUp Event (Python Implementation Guide)

This document specifies **exactly** how to send a Reddit Conversions API (CAPI)
**SignUp** conversion event. It is a faithful port of the existing PHP service
(`app/Services/RedditCapiService.php`) reduced to the SignUp path only.

Follow it as-is and Reddit will accept the event identically to the PHP flow.

---

## 1. Overview

- **Goal:** POST a single `SignUp` conversion event to Reddit's server-to-server
  Conversions API when a user signs up.
- **Transport:** HTTPS `POST`, JSON body, Bearer-token auth.
- **Idempotency:** every event carries a `conversion_id` (dedup key). The same
  `conversion_id` sent via the Reddit Pixel + CAPI collapses into one conversion.
- **Fire-and-forget:** treat it as a side effect. Never let a Reddit failure break
  your signup flow — catch everything, log, and move on.

---

## 2. Credentials / configuration

Use the **same values** the PHP app uses (from its `.env`). Get them from your
environment / secrets manager — do not hardcode.

| Env var | Meaning | Example |
|---|---|---|
| `REDDIT_CAPI_ENABLED` | Master on/off switch (`true`/`false`) | `true` |
| `REDDIT_PIXEL_ID` | Reddit pixel / ad-account id (goes in the URL) | `t2_mvex3rva` |
| `REDDIT_CONVERSION_ACCESS_TOKEN` | Bearer token for the Conversions API | `eyJ...` (long) |
| `REDDIT_TEST_ID` | If set, routes every event to Reddit **Test Events** | `t2_2hn083pm52` |

> ⚠️ **Test vs production:** while `REDDIT_TEST_ID` is set, events show up **only**
> in Reddit Events Manager → *Test Events* and are **not** counted as production
> conversions. Leave it set while developing; clear it (send empty/none) for real
> conversions.

---

## 3. Endpoint & authentication

```
POST https://ads-api.reddit.com/api/v3/pixels/{PIXEL_ID}/conversion_events
Authorization: Bearer {ACCESS_TOKEN}
Content-Type: application/json
```

- `{PIXEL_ID}` = `REDDIT_PIXEL_ID`.
- Timeout: **10 seconds** (matches PHP).
- Success = HTTP `2xx`. A successful body looks like:
  ```json
  {"data":{"message":"Successfully processed 1 conversion events."}}
  ```

---

## 4. Input data — what YOU pass

You call `send_signup_event(data, ...)` with a `data` dict. **All fields are
optional**; empty ones are simply omitted from the payload. Provide as many as you
have — more identifiers = better matching.

| Key | Type | Notes |
|---|---|---|
| `email` | str | **SHA-256 hashed**, normalized (trim + lowercase) before hashing. Use `hash_email()`. |
| `external_id` | str | Your stable user id (e.g. eUserID). Gets rewritten into a unique event id — see §8. |
| `client_ip` | str | End-user IP (`ip_address`). Sent in the clear. |
| `user_agent` | str | End-user browser UA. Sent in the clear. |
| `click_id` | str | Reddit click id (`rdt_cid`) captured from the landing URL, if any. |
| `device_type` | str | `web`, `ios`, or `android`. Drives `action_source` and idfa/aaid. |
| `advertising_id` | str | IDFA (iOS) / AAID (Android). Only used when `device_type` is `ios`/`android`. |
| `action_source` | str | Optional override. Defaults to `APP` for ios/android, else `WEBSITE`. |
| `conversion_id` | str | Optional dedup override. If you also fire the Reddit **Pixel**, pass the SAME id to both. |
| `test_id` | str | Optional per-call override of `REDDIT_TEST_ID`. |

> SignUp has **no** `value` / `currency` (those are Purchase-only). Don't send them.

**Hashing rules (Reddit requirement):**
- `email` → **must** be SHA-256 of the normalized string (`email.strip().lower()`).
- `ip_address`, `user_agent`, `external_id`, `idfa`, `aaid` → sent **plain** (not hashed).

---

## 5. How the payload is built (field-by-field rules)

Given your input `data`, the service derives the wire payload like this:

| Wire field | Rule |
|---|---|
| `type.tracking_type` | constant `"SignUp"` |
| `user.email` | `data.email` verbatim (you already SHA-256'd it) |
| `user.external_id` | **rewritten** → `generate_event_id(external_id, "1")` = `"{external_id}-1-{base36 ms}"` |
| `user.ip_address` | `data.client_ip` |
| `user.user_agent` | `data.user_agent` |
| `user.idfa` | `data.advertising_id` **iff** `device_type == "ios"` |
| `user.aaid` | `data.advertising_id` **iff** `device_type == "android"` |
| `click_id` | `data.click_id` — omitted if empty |
| `event_at` | current time in **milliseconds** (int) |
| `action_source` | `data.action_source` or (`APP` if ios/android else `WEBSITE`) |
| `metadata.conversion_id` | `data.conversion_id` or `generate_conversion_id(external_id, "SignUp")` |
| `data.test_id` | `data.test_id` or `REDDIT_TEST_ID` — omitted if empty |

Any `user.*` field whose source is empty is **dropped** (no null keys).

---

## 6. Final JSON payload (real example)

This is an actual accepted SignUp body (input: hashed email, `external_id`
`debug-external-id`, ip, ua, `click_id`, `device_type=web`):

```json
{
  "data": {
    "events": [
      {
        "type": { "tracking_type": "SignUp" },
        "user": {
          "email": "1d54dff623b42c139522ee327ff07a4999d0029be23129beeb5ea7c76309970a",
          "ip_address": "203.0.113.10",
          "user_agent": "Mozilla/5.0 (reddit-signup-test)",
          "external_id": "debug-external-id-1-ms5zraf6"
        },
        "click_id": "5381866200743635381",
        "event_at": 1785323906178,
        "metadata": {
          "conversion_id": "a8652f4c2d87bbb06b281d29aac051d1b4077909e782438c1273a83e74fcbfaa"
        },
        "action_source": "WEBSITE"
      }
    ],
    "test_id": "t2_2hn083pm52"
  }
}
```

Note how `external_id` in becomes `debug-external-id-1-ms5zraf6` out.

---

## 7. Full Python implementation (drop-in)

No third-party deps except `requests`. Python 3.9+.

```python
"""reddit_capi_signup.py — send a Reddit CAPI SignUp conversion event."""

import hashlib
import time

import requests

ENDPOINT = "https://ads-api.reddit.com/api/v3/pixels/{pixel_id}/conversion_events"
TRACKING_TYPE_SIGNUP = "SignUp"
ACTION_SOURCE_APP = "APP"
ACTION_SOURCE_WEBSITE = "WEBSITE"
HTTP_TIMEOUT_SECONDS = 10

_BASE36_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"


# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _now_ms() -> int:
    """Current unix time in milliseconds (matches PHP round(microtime(true)*1000))."""
    return int(round(time.time() * 1000))


def _to_base36(number: int) -> str:
    """Lowercase base-36 of a non-negative int (matches PHP base_convert(n, 10, 36))."""
    if number == 0:
        return "0"
    out = ""
    while number > 0:
        number, rem = divmod(number, 36)
        out = _BASE36_DIGITS[rem] + out
    return out


def generate_event_id(external_id: str, event_type: str) -> str:
    """
    Time-based, unique event id.

    Format: "{external_id}-{event_type}-{base36 ms timestamp}"
    For SignUp, event_type is "1". Example: "u12345-1-ms5zraf6".
    """
    return f"{external_id}-{event_type}-{_to_base36(_now_ms())}"


def generate_conversion_id(external_id: str, tracking_type: str) -> str:
    """
    Deduplication id: SHA-256 of "{seed}-{tracking_type}-{base36 ms}".
    seed falls back to 'anon' when external_id is empty.
    """
    seed = external_id if external_id else "anon"
    raw = f"{seed}-{tracking_type}-{_to_base36(_now_ms())}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


def hash_email(email: str) -> str:
    """Reddit requires SHA-256 of the normalized (trimmed + lowercased) email."""
    return hashlib.sha256(email.strip().lower().encode("utf-8")).hexdigest()


def _build_user(data: dict) -> dict:
    """Assemble the `user` object, including only non-empty fields."""
    user = {}
    if data.get("email"):
        user["email"] = str(data["email"])
    if data.get("external_id"):
        user["external_id"] = str(data["external_id"])
    if data.get("client_ip"):
        user["ip_address"] = str(data["client_ip"])
    if data.get("user_agent"):
        user["user_agent"] = str(data["user_agent"])

    ad_id = data.get("advertising_id")
    device = str(data.get("device_type") or "").lower()
    if ad_id:
        if device == "ios":
            user["idfa"] = str(ad_id)
        elif device == "android":
            user["aaid"] = str(ad_id)
    return user


# --------------------------------------------------------------------------- #
# Public API
# --------------------------------------------------------------------------- #
def send_signup_event(
    data: dict,
    *,
    pixel_id: str,
    access_token: str,
    test_id: str | None = None,
    enabled: bool = True,
    timeout: int = HTTP_TIMEOUT_SECONDS,
) -> dict:
    """
    Send one SignUp conversion event to Reddit.

    Returns:
        {"success": True}                              on 2xx
        {"success": False, "skipped": True}            when disabled/unconfigured
        {"success": False, "error": {...}|"..."}       on HTTP error / exception
    """
    if not enabled or not pixel_id or not access_token:
        return {"success": False, "skipped": True}

    # Never mutate the caller's dict.
    data = dict(data)

    # Rewrite external_id into a unique, time-based event id. "1" == SignUp.
    data["external_id"] = generate_event_id(data.get("external_id") or "anon", "1")

    device = str(data.get("device_type") or "").lower()
    action_source = data.get("action_source") or (
        ACTION_SOURCE_APP if device in ("ios", "android") else ACTION_SOURCE_WEBSITE
    )

    conversion_id = data.get("conversion_id") or generate_conversion_id(
        str(data.get("external_id") or ""), TRACKING_TYPE_SIGNUP
    )

    event = {
        "event_at": _now_ms(),
        "action_source": action_source,
        "type": {"tracking_type": TRACKING_TYPE_SIGNUP},
        "user": _build_user(data),
        "metadata": {"conversion_id": conversion_id},
    }
    if data.get("click_id"):
        event["click_id"] = data["click_id"]

    body = {"data": {"events": [event]}}

    effective_test_id = data.get("test_id") or test_id
    if effective_test_id:
        body["data"]["test_id"] = effective_test_id

    url = ENDPOINT.format(pixel_id=pixel_id)
    try:
        resp = requests.post(
            url,
            json=body,
            headers={"Authorization": f"Bearer {access_token}"},
            timeout=timeout,
        )
        if resp.ok:  # 2xx
            return {"success": True}
        return {"success": False, "error": {"status": resp.status_code, "body": resp.text}}
    except requests.RequestException as exc:
        return {"success": False, "error": str(exc)}
```

---

## 8. `generate_event_id` explained (as requested)

```python
def generate_event_id(external_id: str, event_type: str) -> str:
    return f"{external_id}-{event_type}-{_to_base36(_now_ms())}"
```

- **Purpose:** produce a unique, time-ordered id per event. For SignUp the
  `event_type` discriminator is the string `"1"` (Purchase uses `"4"`, Custom `"5"`).
- **Shape:** `"{external_id}-{event_type}-{base36(now_ms)}"`.
  - `now_ms` = current unix time × 1000 (integer milliseconds).
  - `base36` = lowercase `[0-9a-z]` encoding of that integer.
- **Example:** `external_id="u12345"`, `event_type="1"`, at ms `1785323906178`
  → base36 `ms5zraf6` → **`u12345-1-ms5zraf6`**.
- This rewritten value is what goes into `user.external_id` on the wire, and is
  also the seed for `generate_conversion_id`. It is **not** hashed.

> Design note: because the timestamp is baked in, `external_id` is unique **per
> event** rather than stable per user. This mirrors the existing PHP behavior — keep
> it identical so SignUp events line up with the app's other Reddit events. If Reddit
> later needs a stable user key for cross-event matching, that's a separate change to
> coordinate on both sides.

---

## 9. Deduplication (`conversion_id`) & the Reddit Pixel

- `metadata.conversion_id` is Reddit's dedup key. If the **same** `conversion_id`
  arrives via both the browser **Pixel** and this server-side **CAPI** call, Reddit
  counts it **once**.
- **CAPI-only (no pixel):** do nothing special — the auto-generated `conversion_id`
  is fine.
- **If you ALSO fire the Reddit Pixel** for signup on the client: generate one
  `conversion_id` and pass the **same** string to both the Pixel event and this
  function's `data["conversion_id"]`. Otherwise you'll double-count.

---

## 10. Test mode

- If `REDDIT_TEST_ID` (or a per-call `test_id`) is set, the payload includes
  `data.test_id`, routing the event to **Test Events** in Reddit Events Manager.
- Verify there during development. Remember: **test events are not production
  conversions.** Clear `test_id` to go live.

---

## 11. Response handling & errors

| Outcome | Return value | What it means |
|---|---|---|
| HTTP 2xx | `{"success": True}` | Accepted. Body: `{"data":{"message":"Successfully processed 1 conversion events."}}` |
| Disabled/unconfigured | `{"success": False, "skipped": True}` | `enabled` false or missing pixel/token — **not** an error |
| HTTP non-2xx | `{"success": False, "error": {"status", "body"}}` | Log it; do not raise into your signup flow |
| Network/timeout/exception | `{"success": False, "error": "<msg>"}` | Log it; do not raise |

Log the full request + response for every send (mask the bearer token). A skipped
result is expected/benign; a non-skipped failure deserves an error log.

---

## 12. Example usage

```python
import os
from reddit_capi_signup import send_signup_event, hash_email

result = send_signup_event(
    {
        "email": hash_email("New.User@Example.com"),  # normalized + SHA-256
        "external_id": "u12345",                       # your stable user id
        "client_ip": request_ip,                       # from your web request
        "user_agent": request_user_agent,
        "click_id": rdt_cid,                           # rdt_cid from landing URL, if present
        "device_type": "web",                          # or "ios" / "android"
    },
    pixel_id=os.environ["REDDIT_PIXEL_ID"],
    access_token=os.environ["REDDIT_CONVERSION_ACCESS_TOKEN"],
    test_id=os.environ.get("REDDIT_TEST_ID") or None,
    enabled=os.environ.get("REDDIT_CAPI_ENABLED", "false").lower() == "true",
)

if not result["success"] and not result.get("skipped"):
    logger.error("Reddit SignUp not sent: %s", result.get("error"))
```

---

## 13. Checklist / gotchas

- [ ] `email` is **SHA-256 of `email.strip().lower()`** — never send raw email.
- [ ] `ip_address`, `user_agent`, `external_id`, `idfa`, `aaid` are sent **plain**.
- [ ] `event_at` is **milliseconds** (int), not seconds.
- [ ] `device_type` `ios`/`android` → `action_source=APP` and idfa/aaid populated;
      everything else → `WEBSITE`.
- [ ] SignUp carries **no** `value`/`currency`.
- [ ] `REDDIT_TEST_ID` set ⇒ **Test Events only**. Clear it for production.
- [ ] Failures are logged, never raised into the signup path.
- [ ] Use the **same** `REDDIT_PIXEL_ID` / access token as the PHP app so events land
      on the same pixel.
