# Reddit Conversions API — SignUp Event (phptest)

**Date:** 2026-07-01
**Project:** phptest (Laravel)
**Status:** Approved design, pending implementation plan

## Goal

Send a server-side **SignUp** conversion event to the Reddit Conversions API (CAPI)
whenever a brand-new user is created via the `User/SocialLogin` API, so Reddit can
attribute signups back to ad clicks.

The event fires **only for newly created users**, is sent **asynchronously** (never
blocks or breaks the signup response), is gated behind an **on/off feature flag**, and
degrades to a safe no-op when the flag is off or Reddit credentials are not configured.

## Trigger point

`app/Services/Auth/BaseAuthService.php` → `getUserData()`, inside the existing
`if (! $isExisting)` branch (currently holds the commented-out Meta signup call).
This is reached at the end of `createNewUser()` for every fresh signup, and has
access to the created `$user`, `$userInfo` (email), and `$postData`.

## Reddit Conversions API v3 — reference

Official doc: `https://ads-api.reddit.com/docs/v3/capi-direct-integration`

- **Endpoint:** `POST https://ads-api.reddit.com/api/v3/pixels/{pixel_id}/conversion_events`
- **Auth header:** `Authorization: Bearer {conversion_access_token}`
- **Top-level body:** `{ "test_mode": <bool>, "events": [ … ] }`
- **`event_at`:** Unix epoch timestamp in **milliseconds**
- **`event_type.tracking_type`:** `"Sign Up"` (exact string, with a space)
- **`click_id`:** event-level field (alongside `user`), plaintext — sourced from `rdt_cid`

### Field → source → hashing map

| Reddit field | Source (phptest) | Sent as |
|---|---|---|
| `user.email` | `$user->vHashedEmail` | **pre-hashed** — sent verbatim (already `sha256(vEmail)`) |
| `user.external_id` | `$user->vEUserID` | plaintext |
| `user.aaid` **or** `user.idfa` | `$postData['advertising_id']`, routed by `$postData['vDeviceType']` (android → `aaid`, ios → `idfa`) | plaintext |
| `user.ip_address` | `request()->ip()` | plaintext |
| `user.user_agent` | `request()->userAgent()` | plaintext |
| `click_id` (event level) | `$postData['rdt_cid']` | plaintext |
| `event_at` | send time, `round(microtime(true) * 1000)` | ms epoch |
| `event_type.tracking_type` | constant | `"Sign Up"` |

**Email uses the stored `tbl_Users.vHashedEmail` column** (already `sha256(vEmail)`; socialLogin
lowercases the email upstream). It is sent verbatim — the service does **not** re-hash it.
`getUserLoginData()` does not currently select this column, so add `'tbl_Users.vHashedEmail'`
to its select list ([UserQuery.php:285](../../../app/Http/Queries/UserQuery.php#L285)) so
`$user->vHashedEmail` is available in `getUserData()`.

**All other identifiers are sent in plaintext.** `external_id` (`vEUserID`) and the device id
(`aaid`/`idfa`) are passed as-is; Reddit hashes cleartext PII server-side. The service therefore
performs **no hashing of its own** — email is the only hashed field and it arrives pre-hashed.
Only non-empty fields are included in the payload.

Reddit requires at least one attribution signal per event; email + click_id + advertising id
together give strong coverage.

## Architecture

One new service, mirroring existing phptest patterns.

### New file: `app/Services/RedditCapiService.php`

- Constructor reads `config('services.reddit.*')` (pattern of `MetaCapiService`).
- Uses the **`Http` facade** with `timeout()` + `retry()` (pattern of
  `BajajHealthEventService`) — Reddit has no PHP SDK.
- Public: `sendSignupEvent(array $data): array` → `['success' => bool, 'error'? => mixed]`.
- Private: `buildUser(array): array`, `execute(array): array`. No hashing helper is needed —
  email arrives pre-hashed and every other field is plaintext.
- **Feature-flag + config guard:** at the top of `sendSignupEvent()`, return
  `['success' => false]` without any HTTP call when **any** of these hold: the `enabled` flag
  is off, `pixel_id` is empty, or `access_token` is empty. The `enabled` flag is the primary
  on/off switch; empty credentials are a secondary safety net. Log a debug notice on skip.
- **Header sanitizing:** never log the bearer token in cleartext (pattern of
  `BajajHealthEventService::sanitizeHeaders()`).

### Example request body built by the service

```json
{
  "test_mode": false,
  "events": [
    {
      "event_at": 1751376000000,
      "event_type": { "tracking_type": "Sign Up" },
      "click_id": "<rdt_cid plaintext>",
      "user": {
        "email": "<sha256 hex (pre-hashed vHashedEmail)>",
        "external_id": "<plaintext vEUserID>",
        "aaid": "<plaintext advertising id>",
        "ip_address": "<plaintext ip>",
        "user_agent": "<plaintext ua>"
      }
    }
  ]
}
```

## Wiring

### `config/services.php` — add alongside the `meta` block

```php
'reddit' => [
    'enabled'      => env('REDDIT_CAPI_ENABLED', false),
    'pixel_id'     => env('REDDIT_PIXEL_ID'),
    'access_token' => env('REDDIT_CONVERSION_ACCESS_TOKEN'),
    'test_mode'    => env('REDDIT_TEST_MODE', false),
],
```

### `.env` (values supplied by the team — credentials are ready)

```
REDDIT_CAPI_ENABLED=false
REDDIT_PIXEL_ID=
REDDIT_CONVERSION_ACCESS_TOKEN=
REDDIT_TEST_MODE=false
```

`REDDIT_CAPI_ENABLED` is the on/off switch: sending happens only when it is `true`.

### `BaseAuthService`

- Import `App\Services\RedditCapiService`, add a `protected $redditCapiService` property,
  add it to the constructor signature and assignment (same shape as `MetaCapiService` /
  `BajajHealthEventService` at the existing constructor).

### Call site — inside `if (! $isExisting)` in `getUserData()`

```php
if (! $isExisting) {
    $hashedEmail = $user->vHashedEmail;          // pre-hashed sha256(vEmail)
    $eUserID     = $user->vEUserID;
    $ip          = request()->ip();
    $ua          = request()->userAgent();
    $adId        = $postData['advertising_id'] ?? null;
    $device      = $postData['vDeviceType'] ?? null;
    $clickId     = $postData['rdt_cid'] ?? null;

    defer(function () use ($hashedEmail, $eUserID, $ip, $ua, $adId, $device, $clickId) {
        $res = $this->redditCapiService->sendSignupEvent([
            'email'          => $hashedEmail,   // already hashed; service sends verbatim
            'external_id'    => $eUserID,
            'client_ip'      => $ip,
            'user_agent'     => $ua,
            'advertising_id' => $adId,
            'device_type'    => $device,
            'click_id'       => $clickId,
        ]);
        if (! ($res['success'] ?? false)) {
            Log::error('Reddit Signup Event Error', $res);
        }
    });
}
```

Request-derived values (`ip`, `user_agent`) and `postData` fields are captured **before**
the `defer` closure, because `defer()` runs after the request lifecycle and `request()`
inside it is unreliable.

## Error handling

- All failures (non-2xx, transport exceptions, empty config) are caught inside the service,
  returned as `['success' => false, 'error' => …]`, and logged by the caller via
  `Log::error` (DB log channel). Nothing is thrown; signup is never affected.
- Bearer token is redacted from any log output.

## Testing (Pest, `Http::fake()`)

1. **Payload correctness** — asserts the event hits the v3 pixel endpoint with the correct
   `Authorization` bearer, `event_type.tracking_type = "Sign Up"`, `event_at` in ms, the
   pre-hashed `email` sent verbatim (equals `vHashedEmail`, not re-hashed), and
   `external_id`/`advertising_id`/`ip`/`ua`/`click_id` all plaintext.
2. **Advertising-id routing** — android `vDeviceType` → `aaid`, ios → `idfa`.
3. **Feature flag** — `enabled = false` → no HTTP call, returns failure; `enabled = true`
   with valid config → event is sent.
4. **New-users only** — event fires when `! $isExisting`, and does **not** fire for existing
   users.
5. **Config guard** — empty `pixel_id`/`access_token` → no HTTP call, returns failure.
6. **Failure isolation** — a Reddit API failure logs an error but the login response is
   unchanged and successful.

## To confirm during implementation (via Reddit Test Events dashboard, `REDDIT_TEST_MODE=true`)

- Match quality is reported for the hashed fields as sent (validates `vHashedEmail`
  normalization matches Reddit's expectation of lowercase+trim before sha256).

## Out of scope

- Any events other than SignUp (Purchase/Lead/etc.).
- Retro-firing for existing users.
- A queued-job variant (using `defer()`; `QUEUE_CONNECTION=sync` in this project).
