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
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)];
}
Comment on lines +77 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guests special case doesn't skip global options, inconsistent with alias handling above.

The alias rewriting at lines 57-64 correctly scans past global options to find the command token. However, this special case checks args[0] directly. If a user runs partiful --format json guests abc123, the deprecation rewrite won't trigger because args[0] is '--format', not 'guests'.

🔧 Proposed fix to use cmdIndex for consistency
   // Special case: `partiful guests <id>` → `partiful guests list <id>`
-  if (args[0] === 'guests' && args[1] && !['list', 'invite', '--help', '-h'].includes(args[1])) {
+  const rewrittenArgs = process.argv.slice(2);
+  // Re-scan for command index after potential alias rewrite
+  let guestsCmdIndex = 0;
+  while (guestsCmdIndex < rewrittenArgs.length && rewrittenArgs[guestsCmdIndex].startsWith('-')) {
+    guestsCmdIndex += optsWithValue.has(rewrittenArgs[guestsCmdIndex]) ? 2 : 1;
+  }
+  if (rewrittenArgs[guestsCmdIndex] === 'guests' && rewrittenArgs[guestsCmdIndex + 1] && !['list', 'invite', '--help', '-h'].includes(rewrittenArgs[guestsCmdIndex + 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)];
+    process.argv = [
+      ...process.argv.slice(0, 2),
+      ...rewrittenArgs.slice(0, guestsCmdIndex),
+      'guests', 'list',
+      ...rewrittenArgs.slice(guestsCmdIndex + 1),
+    ];
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli.js` around lines 77 - 81, The special-case rewrite for "partiful
guests <id>" fails to account for global options; update the logic to use the
same cmdIndex scanning used by the alias handling instead of checking args[0].
Locate the code around the existing alias handling that computes cmdIndex and
change the guests branch to find the command token at args[cmdIndex], emit the
same deprecation message, and rewrite process.argv by splicing in
'guests','list' at the original command position (using cmdIndex+2 relative to
process.argv.slice(0,2)) so global flags remain in place; ensure you still guard
against allowed subcommands like 'list','invite','--help','-h' by checking
args[cmdIndex+1].


program.parse();
}
117 changes: 106 additions & 11 deletions src/commands/blasts.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,118 @@
/**
* Blasts commands: send (stub)
* Blasts commands: send, list
*
* Endpoint: POST https://api.partiful.com/createTextBlast
* Discovered via browser interception 2026-03-24.
* See docs/research/2026-03-24-text-blast-endpoint.md
*/

import { jsonError } from '../lib/output.js';
import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js';
import { apiRequest } from '../lib/http.js';
import { jsonOutput, jsonError } from '../lib/output.js';
import { PartifulError, ValidationError } from '../lib/errors.js';
import readline from 'readline';

const VALID_TO_VALUES = ['GOING', 'MAYBE', 'DECLINED', 'SENT', 'INTERESTED', 'WAITLIST', 'APPROVED', 'RESPONDED_TO_FIND_A_TIME'];
const MAX_MESSAGE_LENGTH = 480;
const MAX_BLASTS_PER_EVENT = 10;

async function confirm(question) {
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
return new Promise(resolve => {
rl.question(question + ' [y/N]: ', answer => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
});
});
}

export function registerBlastsCommands(program) {
const blasts = program.command('blasts').description('Text blasts to event guests');

blasts
.command('send')
.description('Send a text blast to event guests (requires browser — stub)')
.description('Send a text blast to event guests')
.argument('<eventId>', 'Event ID')
.option('--message <msg>', 'Message to send')
.action(async (eventId) => {
jsonError(
`Text blasts require Firestore SDK (not available via REST). Use the web UI: https://partiful.com/e/${eventId}`,
5,
'not_implemented',
{ workaround: `https://partiful.com/e/${eventId}` }
);
.requiredOption('--message <msg>', 'Message to send (max 480 chars)')
.option('--to <statuses>', 'Comma-separated guest statuses to send to (default: GOING)', 'GOING')
.option('--show-on-event-page', 'Show blast in event activity feed (default: true)')
.option('--no-show-on-event-page', 'Hide blast from event activity feed')
.action(async (eventId, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();

try {
// Validate message length
if (opts.message.length > MAX_MESSAGE_LENGTH) {
throw new ValidationError(`Message exceeds ${MAX_MESSAGE_LENGTH} char limit (got ${opts.message.length})`);
}

// Parse and validate 'to' statuses
const toStatuses = opts.to.split(',').map(s => s.trim().toUpperCase());
for (const status of toStatuses) {
if (!VALID_TO_VALUES.includes(status)) {
throw new ValidationError(
`Invalid status "${status}". Valid: ${VALID_TO_VALUES.join(', ')}`
);
}
}

// Default showOnEventPage to true unless explicitly disabled
const showOnEventPage = opts.showOnEventPage !== false;

const config = loadConfig();
const token = await getValidToken(config);

const payload = {
data: wrapPayload(config, {
params: {
eventId,
message: {
text: opts.message,
to: toStatuses,
showOnEventPage,
},
},
amplitudeSessionId: Date.now(),
userId: config.userId || null,
}),
};

if (globalOpts.dryRun) {
jsonOutput({ dryRun: true, endpoint: '/createTextBlast', payload }, {}, globalOpts);
return;
}
Comment on lines +80 to +83
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing globalOpts in jsonOutput calls breaks --output flag.

The jsonOutput function accepts an opts parameter to support --output <path>, but all three calls omit it. This means --output won't work for dry-run, cancelled, or successful responses.

Proposed fix
         if (globalOpts.dryRun) {
-          jsonOutput({ dryRun: true, endpoint: '/createTextBlast', payload });
+          jsonOutput({ dryRun: true, endpoint: '/createTextBlast', payload }, {}, globalOpts);
           return;
         }
           if (!ok) {
-            jsonOutput({ cancelled: true, message: 'Blast not sent' });
+            jsonOutput({ cancelled: true, message: 'Blast not sent' }, {}, globalOpts);
             return;
           }
-        jsonOutput({
+        jsonOutput({
           sent: true,
           eventId,
           to: toStatuses,
           messageLength: opts.message.length,
           showOnEventPage,
           response: result?.result?.data || result?.result || result,
-        });
+        }, {}, globalOpts);

Also applies to: 95-96, 102-109

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/blasts.js` around lines 80 - 83, The jsonOutput calls inside the
createTextBlast flow are missing the opts argument, so the --output path from
globalOpts isn’t honored; update each jsonOutput invocation (the dry-run branch
where globalOpts.dryRun is checked, the cancelled branch, and the success branch
returning the response) to pass globalOpts as the second parameter (i.e.,
jsonOutput(outputData, globalOpts)) so the output option is respected; look for
calls to jsonOutput in this file (blasts.js) around the createTextBlast handling
and add the opts argument accordingly.


// Safety confirmation unless --yes
if (!globalOpts.yes) {
console.error(`\nText Blast Preview:`);
console.error(` Event: ${eventId}`);
console.error(` To: ${toStatuses.join(', ')}`);
console.error(` Show on event page: ${showOnEventPage}`);
console.error(` Message: "${opts.message}"`);
console.error('');
const ok = await confirm('Send this text blast? This will SMS real people');
if (!ok) {
jsonOutput({ cancelled: true, message: 'Blast not sent' }, {}, globalOpts);
return;
}
}

const result = await apiRequest('POST', '/createTextBlast', token, payload, globalOpts.verbose);

jsonOutput({
sent: true,
eventId,
to: toStatuses,
messageLength: opts.message.length,
showOnEventPage,
response: result?.result?.data || result?.result || result,
}, {}, globalOpts);
} catch (err) {
if (err instanceof PartifulError) {
jsonError(err.message, err.exitCode, err.type, err.details);
} else {
jsonError(err.message, 1, 'blast_error');
}
}
});
}