Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions docs/research/2026-03-24-auth-flow-endpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Partiful Auth Flow — Endpoint Research

**Date:** 2026-03-24
**Method:** Browser interception on partiful.com login page + app bundle analysis
**Source:** Module 31983 in `_app-86627f0803d70c85.js`

---

## Auth Endpoints

### 1. Send Verification Code

```
POST https://api.partiful.com/sendAuthCode
```

**Request:**
```json
{
"data": {
"params": {
"displayName": "Kaleb Cole",
"phoneNumber": "+12066993977",
"silent": false,
"channelPreference": "sms",
"captchaToken": "<optional-recaptcha-token>",
"useAppleBusinessUpdates": false
}
}
}
```

**Notes:**
- `channelPreference` can be `"sms"` or `"whatsapp"` (UI shows "Send with WhatsApp instead")
- `captchaToken` appears optional (invisible reCAPTCHA, may be needed for untrusted clients)
- `silent` flag exists for silent auth flows
- There's also `sendAuthCodeTrusted` variant for trusted phone sources

### 2. Verify Code & Get Login Token

```
POST https://api.partiful.com/getLoginToken
```

**Request:**
```json
{
"data": {
"params": {
"phoneNumber": "+12066993977",
"authCode": "889885",
"affiliateId": null,
"utms": {}
}
}
}
```

**Response:**
```json
{
"result": {
"data": {
"token": "<firebase-custom-jwt>",
"shouldUseCookiePersistence": false,
"isNewUser": false
}
}
}
```

### 3. Exchange Custom Token for Firebase Auth

```
POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=AIzaSyCky6PJ7cHRdBKk5X7gjuWERWaKWBHr4_k
```

**Request:**
```json
{
"token": "<custom-jwt-from-step-2>",
"returnSecureToken": true
}
```

**Response (Firebase standard):**
```json
{
"idToken": "<firebase-id-token>",
"refreshToken": "<firebase-refresh-token>",
"expiresIn": "3600"
}
```

### 4. (Optional) Look Up User Info

```
POST https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=AIzaSyCky6PJ7cHRdBKk5X7gjuWERWaKWBHr4_k
```

**Request:**
```json
{
"idToken": "<firebase-id-token>"
}
```

---

## Complete CLI Auth Flow

```
sendAuthCode(phone) → SMS arrives → getLoginToken(phone, code) → signInWithCustomToken(token) → save refreshToken
```

No reCAPTCHA needed for the REST API calls when using the standard `data.params` envelope.

## Firebase API Key

`AIzaSyCky6PJ7cHRdBKk5X7gjuWERWaKWBHr4_k` — Partiful's Firebase web API key (public, embedded in client).

## SMS Source

- Phone: `+18449460698` — Partiful's SMS sender
- Message format: `{code} is your Partiful verification code`
- Code: 6 digits
123 changes: 123 additions & 0 deletions docs/research/2026-03-24-text-blast-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Partiful Text Blast API — Endpoint Research

**Date:** 2026-03-24
**Method:** Browser interception on partiful.com (logged in as Kaleb Cole)
**Source file:** `6631.793447c2446d40ae.js` → `SendTextBlastModal.tsx`

---

## Endpoint

```
POST https://api.partiful.com/createTextBlast
```

Uses the same Firebase callable function pattern as all other Partiful API endpoints:
- Auth via Firebase access token in `Authorization: Bearer <token>` header
- Request body wrapped in standard `data.params` envelope
- Includes `amplitudeDeviceId`, `amplitudeSessionId`, `userId` metadata

## Request Payload

```json
{
"data": {
"params": {
"eventId": "<event-id>",
"message": {
"text": "<message-text>",
"to": ["GOING", "MAYBE", "DECLINED"],
"showOnEventPage": true,
"images": [
{
"url": "<uploaded-image-url>",
"upload": {
"contentType": "image/jpeg",
"size": 123456
}
}
]
}
},
"amplitudeDeviceId": "<device-id>",
"amplitudeSessionId": <timestamp>,
"userId": "<firebase-uid>"
}
}
```

### Field Details

| Field | Type | Required | Notes |
|---|---|---|---|
| `eventId` | string | Yes | Partiful event ID |
| `message.text` | string | Yes | Message body, max 480 chars |
| `message.to` | string[] | Yes | Array of guest statuses to send to |
| `message.showOnEventPage` | boolean | Yes | Whether to show in activity feed |
| `message.images` | array | No | Uploaded image objects (max 1 image, max 5MB total) |

### Valid `to` Values (Guest Status Enum — `LF`)

From module `73621` in the app bundle:

| Value | UI Label | Typical Use in Blasts |
|---|---|---|
| `GOING` | Going | ✅ Primary target |
| `MAYBE` | Maybe | ✅ Common target |
| `DECLINED` | Can't Go | ✅ Available |
| `SENT` | Invited | ⚠️ Only for small groups |
| `INTERESTED` | Interested | Available |
| `WAITLIST` | Waitlist | Available (if enabled) |
| `APPROVED` | Approved | Available (ticketed events) |
| `RESPONDED_TO_FIND_A_TIME` | Find-a-Time | Available |

**Note:** The UI shows "Texts to large groups of Invited guests are not allowed" — there's a server-side limit on blasting to `SENT` status guests.

### Limits

- **Max 10 text blasts per event** (enforced client-side as `f=10`)
- **Max 480 characters** per message
- **Max 5MB** total image upload size
- **Max 1 image** per blast
- **Allowed image types:** Checked via `ee.V2` array (likely standard image MIME types)
- **Event must not be expired** (`EVENT_EXPIRED` check)
- **Must have guests** (`NO_GUESTS` check)

## UI → API Mapping

| UI Element | API Field |
|---|---|
| "Going (9)" pill (selected) | `to: ["GOING"]` |
| "Maybe (1)" pill (selected) | `to` includes `"MAYBE"` |
| "Can't Go (1)" pill (selected) | `to` includes `"DECLINED"` |
| "Select all (11)" | `to: ["GOING", "MAYBE", "DECLINED"]` (all available) |
| "Also show in activity feed" checkbox | `showOnEventPage: true/false` |
| Message textarea | `message.text` |
| Image upload | `message.images` array |

## Other Discovered Endpoints (Same Session)

| Endpoint | Method | Purpose |
|---|---|---|
| `getContacts` | POST | List user's contacts (paginated, 1000/page) |
| `getHostTicketTypes` | POST | Get ticket types for an event |
| `getEventDiscoverStatus` | POST | Check if event is discoverable |
| `getEventTicketingEligibility` | POST | Check ticketing eligibility |
| `getEventPermission` | POST | Check user's permissions on event |
| `getUsers` | POST | Batch lookup users by ID (batches of ~5-10) |

## Previously Discovered Endpoints (Earlier Sessions)

| Endpoint | Method | Purpose |
|---|---|---|
| `addGuest` | POST | RSVP / add guest to event |
| `removeGuest` | POST | Remove guest from event |
| `getMutualsV2` | POST | Get mutual connections |
| `getInvitableContactsV2` | POST | Get invitable contacts for event |
| `getGuestsCsvV2` | POST | Server-side CSV export of guest list |

## Auth Token for CLI

The CLI already has auth working via Firebase refresh token → access token exchange.
The `createTextBlast` endpoint uses the same auth pattern — just needs the access token
in the standard callable function request envelope.
35 changes: 35 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,40 @@ export function run() {
jsonOutput({ version: program.version(), cli: 'partiful', node: process.version }, {}, globalOpts);
});

// Deprecated aliases — rewrite argv before parsing
const args = process.argv.slice(2);
const aliasMap = {
'list': ['events', 'list'],
'get': ['events', 'get'],
'cancel': ['events', 'cancel'],
'clone': ['events', '+clone'],
};

// Find first non-option token (skip --format <val>, -o <val>, etc.)
const optsWithValue = new Set(['--format', '-o', '--output']);
let cmdIndex = 0;
while (cmdIndex < args.length && args[cmdIndex].startsWith('-')) {
cmdIndex += optsWithValue.has(args[cmdIndex]) ? 2 : 1;
}

const legacy = args[cmdIndex];
if (legacy && aliasMap[legacy]) {
const rewritten = [
...args.slice(0, cmdIndex),
...aliasMap[legacy],
...args.slice(cmdIndex + 1),
];
process.stderr.write(
`[deprecated] "partiful ${legacy}" → use "partiful ${aliasMap[legacy].join(' ')}" instead\n`
);
process.argv = [...process.argv.slice(0, 2), ...rewritten];
}

// Special case: `partiful guests <id>` → `partiful guests list <id>`
if (args[0] === 'guests' && args[1] && !['list', 'invite', '--help', '-h'].includes(args[1])) {
process.stderr.write(`[deprecated] "partiful guests <id>" → use "partiful guests list <id>" instead\n`);
process.argv = [...process.argv.slice(0, 2), 'guests', 'list', ...args.slice(1)];
}

program.parse();
}
Loading