@itm-studio/partner-sdk 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ npm install @itm-studio/partner-sdk
16
16
  ## Quick Start
17
17
 
18
18
  ```typescript
19
- import { createITMPartnerClient } from '@itm-studio/partner-sdk';
19
+ import { createITMPartnerClient } from "@itm-studio/partner-sdk";
20
20
 
21
21
  const itm = createITMPartnerClient({
22
22
  token: process.env.ITM_PARTNER_TOKEN!,
@@ -25,7 +25,7 @@ const itm = createITMPartnerClient({
25
25
  // Get all upcoming moments for your brand
26
26
  const { getPartnerMomentsForBrand } = await itm.query({
27
27
  getPartnerMomentsForBrand: {
28
- __args: { status: 'UPCOMING', take: 10 },
28
+ __args: { status: "UPCOMING", take: 10 },
29
29
  moments: {
30
30
  uid: true,
31
31
  name: true,
@@ -70,18 +70,18 @@ Keep the partner token on your server. Do not expose it from browser code or cli
70
70
  For request-scoped mutations such as partner brand subscriptions, create the client inside the incoming request and forward `x-forwarded-for` and `user-agent` so the backend can enrich compliance metadata when those fields are omitted from `input.compliance`.
71
71
 
72
72
  ```typescript
73
- import { createITMPartnerClient } from '@itm-studio/partner-sdk';
73
+ import { createITMPartnerClient } from "@itm-studio/partner-sdk";
74
74
 
75
75
  export function createITMClientForRequest(request: Request) {
76
76
  return createITMPartnerClient({
77
77
  token: process.env.ITM_PARTNER_TOKEN!,
78
78
  headers: () => {
79
- const forwardedFor = request.headers.get('x-forwarded-for');
80
- const userAgent = request.headers.get('user-agent');
79
+ const forwardedFor = request.headers.get("x-forwarded-for");
80
+ const userAgent = request.headers.get("user-agent");
81
81
 
82
82
  return {
83
- ...(forwardedFor ? { 'x-forwarded-for': forwardedFor } : {}),
84
- ...(userAgent ? { 'user-agent': userAgent } : {}),
83
+ ...(forwardedFor ? { "x-forwarded-for": forwardedFor } : {}),
84
+ ...(userAgent ? { "user-agent": userAgent } : {}),
85
85
  };
86
86
  },
87
87
  });
@@ -92,21 +92,41 @@ If you also send `input.compliance`, the backend keeps the values you provide an
92
92
 
93
93
  ## Money Amounts
94
94
 
95
- Partner API price values are always encoded as `amount * 100`. Divide by `100` before rendering a major-unit display amount (dollars for USD, euros for EUR, yen for JPY, etc.).
96
-
97
- This convention also applies to zero-decimal currencies such as JPY and KRW. The rendering conversion is always `price / 100`; zero-decimal status only controls whether the formatted display shows fractional digits. For example, `2500` should be displayed as `JPY 25`, not `JPY 2,500`.
95
+ Dynamic-pricing price values use the currency's integer minor unit: cents for
96
+ USD and EUR, and whole units for zero-decimal currencies such as JPY and KRW.
97
+ Do not multiply zero-decimal dynamic-pricing amounts by 100. Older partner tier
98
+ read/edit fields retain the backend's historical cents representation; use the
99
+ dynamic-pricing API for price changes on enrolled tiers.
98
100
 
99
101
  ```typescript
100
- const ZERO_DECIMAL_CURRENCIES = new Set(['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF']);
101
-
102
- function formatPartnerPrice(priceInCents: number, currencyCode: string) {
103
- if (priceInCents === 0) return 'Free';
104
-
105
- const amount = priceInCents / 100;
106
- const maximumFractionDigits = ZERO_DECIMAL_CURRENCIES.has(currencyCode) ? 0 : 2;
107
-
108
- return new Intl.NumberFormat('en-US', {
109
- style: 'currency',
102
+ const ZERO_DECIMAL_CURRENCIES = new Set([
103
+ "BIF",
104
+ "CLP",
105
+ "DJF",
106
+ "GNF",
107
+ "JPY",
108
+ "KMF",
109
+ "KRW",
110
+ "MGA",
111
+ "PYG",
112
+ "RWF",
113
+ "UGX",
114
+ "VND",
115
+ "VUV",
116
+ "XAF",
117
+ "XOF",
118
+ "XPF",
119
+ ]);
120
+
121
+ function formatPartnerPrice(priceInMinorUnits: number, currencyCode: string) {
122
+ if (priceInMinorUnits === 0) return "Free";
123
+
124
+ const zeroDecimal = ZERO_DECIMAL_CURRENCIES.has(currencyCode);
125
+ const amount = zeroDecimal ? priceInMinorUnits : priceInMinorUnits / 100;
126
+ const maximumFractionDigits = zeroDecimal ? 0 : 2;
127
+
128
+ return new Intl.NumberFormat("en-US", {
129
+ style: "currency",
110
130
  currency: currencyCode,
111
131
  minimumFractionDigits: maximumFractionDigits,
112
132
  maximumFractionDigits,
@@ -114,6 +134,95 @@ function formatPartnerPrice(priceInCents: number, currencyCode: string) {
114
134
  }
115
135
  ```
116
136
 
137
+ ## Dynamic Pricing
138
+
139
+ Dynamic pricing is a controlled actuator, not a hosted rules engine. A brand
140
+ admin first enables a moment policy and enrolls paid tiers with minimum and
141
+ maximum prices. The partner then reads current context and explicitly chooses
142
+ which enrolled tiers to update.
143
+
144
+ Always fetch fresh context immediately before an apply. Admission windows,
145
+ policy version, bounds, configured supply, and current price form the stable
146
+ pricing concurrency contract. Live sales do not invalidate
147
+ `pricingContextVersion`. Each update must separately declare whether it is
148
+ inventory-independent or provide a remaining-supply/fill-rate range checked
149
+ under the price-write lock. The entire batch is atomic.
150
+
151
+ ```typescript
152
+ const { getPartnerMomentDynamicPricingContext: context } = await itm.query({
153
+ getPartnerMomentDynamicPricingContext: {
154
+ __args: { input: { momentUid } },
155
+ policy: { version: true, enabled: true },
156
+ tiers: {
157
+ ticketTierUid: true,
158
+ currentPrice: true,
159
+ minPrice: true,
160
+ maxPrice: true,
161
+ eligible: true,
162
+ pricingContextVersion: true,
163
+ supply: true,
164
+ remainingSupply: true,
165
+ fillRateBps: true,
166
+ admissionWindows: { startsAt: true, endsAt: true },
167
+ },
168
+ },
169
+ });
170
+
171
+ const tier = context.tiers.find((candidate) => candidate.eligible)!;
172
+ const { applyPartnerMomentDynamicPricing: batch } = await itm.mutation({
173
+ applyPartnerMomentDynamicPricing: {
174
+ __args: {
175
+ input: {
176
+ momentUid,
177
+ mode: "APPLY", // use PREVIEW to validate without changing prices
178
+ idempotencyKey: crypto.randomUUID(),
179
+ policyVersion: context.policy.version,
180
+ reason: "60% fill threshold reached",
181
+ strategyKey: "capacity-step",
182
+ strategyVersion: "1.0.0",
183
+ updates: [
184
+ {
185
+ ticketTierUid: tier.ticketTierUid,
186
+ newPrice: tier.currentPrice + 500,
187
+ expectedPrice: tier.currentPrice,
188
+ expectedPricingContextVersion: tier.pricingContextVersion,
189
+ inventoryGuard: {
190
+ mode: "RANGE",
191
+ metric: "FILL_RATE_BPS",
192
+ minimum: 6000,
193
+ maximum: 6999,
194
+ },
195
+ },
196
+ ],
197
+ },
198
+ },
199
+ uid: true,
200
+ status: true,
201
+ failureCode: true,
202
+ failureMessage: true,
203
+ items: {
204
+ ticketTierUid: true,
205
+ status: true,
206
+ previousPrice: true,
207
+ resultingPrice: true,
208
+ failureCode: true,
209
+ },
210
+ },
211
+ });
212
+ ```
213
+
214
+ Reuse an idempotency key only for the identical request. `PROCESSING` can be
215
+ polled with `getPartnerDynamicPricingBatch`; terminal statuses are `PREVIEWED`,
216
+ `APPLIED`, `NO_CHANGES`, `REJECTED`, `CONFLICTED`, and `FAILED`. Legacy
217
+ `editPartnerTicketTiersForMoment` price edits are rejected for tiers enrolled
218
+ in an enabled dynamic pricing policy.
219
+
220
+ Use `{ mode: 'INDEPENDENT' }` only when the strategy intentionally does not
221
+ depend on live capacity. Range bounds are inclusive; fill rate uses integer
222
+ basis points (`6000` = 60%). Refresh after `validUntil`. Because price is stored
223
+ per tier, every upcoming admission window for a managed tier must fall within
224
+ the configured horizon before that tier is eligible.
225
+
117
226
  ## Available Queries
118
227
 
119
228
  ### `getPartnerMomentsForBrand`
@@ -124,10 +233,10 @@ Get paginated moments (events) for your brand.
124
233
  const { getPartnerMomentsForBrand } = await itm.query({
125
234
  getPartnerMomentsForBrand: {
126
235
  __args: {
127
- take: 20, // items per page (default: 50, omit to use default)
128
- cursor: nextCursor, // from previous response
129
- status: 'UPCOMING', // UPCOMING | LIVE | ENDED
130
- sortOrder: 'ASC', // ASC | DESC by start date (default: DESC)
236
+ take: 20, // items per page (default: 50, omit to use default)
237
+ cursor: nextCursor, // from previous response
238
+ status: "UPCOMING", // UPCOMING | LIVE | ENDED
239
+ sortOrder: "ASC", // ASC | DESC by start date (default: DESC)
131
240
  },
132
241
  moments: { uid: true, name: true, startDate: true },
133
242
  totalCount: true,
@@ -137,6 +246,60 @@ const { getPartnerMomentsForBrand } = await itm.query({
137
246
  });
138
247
  ```
139
248
 
249
+ ### `getPartnerDrops`
250
+
251
+ Get paginated brand drops for the brand associated with your partner token.
252
+ Use this for partner-hosted drop grids, release pages, and filtered merch or
253
+ music drop listings.
254
+
255
+ Supported filters:
256
+
257
+ - `search`: case-insensitive match across drop name, subtitle, and slug.
258
+ - `isPublic`: filter public or private drops.
259
+ - `ctaType`: filter by `EXTERNAL_URL`, `GATED_LINK`, `MUSIC_LINK`, or `SUBSCRIBE`.
260
+ - `cursor` and `take`: use `nextCursor` while `hasNextPage` is true.
261
+
262
+ ```typescript
263
+ const { getPartnerDrops } = await itm.query({
264
+ getPartnerDrops: {
265
+ __args: {
266
+ take: 20,
267
+ cursor: nextCursor,
268
+ search: "release",
269
+ isPublic: true,
270
+ ctaType: "EXTERNAL_URL", // EXTERNAL_URL | GATED_LINK | MUSIC_LINK | SUBSCRIBE
271
+ },
272
+ drops: {
273
+ uid: true,
274
+ name: true,
275
+ slug: true,
276
+ isPublic: true,
277
+ ctaType: true,
278
+ ctaUrl: true,
279
+ captureName: true,
280
+ captureEmail: true,
281
+ coverImage: { url: true, mimeType: true },
282
+ coverImageMobile: { url: true, mimeType: true },
283
+ musicLink: {
284
+ title: true,
285
+ artistName: true,
286
+ releaseType: true,
287
+ sourceUrl: true,
288
+ odesliPageUrl: true,
289
+ thumbnailUrl: true,
290
+ },
291
+ },
292
+ totalCount: true,
293
+ hasNextPage: true,
294
+ nextCursor: true,
295
+ },
296
+ });
297
+ ```
298
+
299
+ The response is always scoped by the partner token; do not pass a brand ID.
300
+ `coverImageMobile` and `musicLink` can be `null`, so handle those as optional in
301
+ rendering code.
302
+
140
303
  ### `getPartnerMoment`
141
304
 
142
305
  Get a single moment by slug.
@@ -144,7 +307,7 @@ Get a single moment by slug.
144
307
  ```typescript
145
308
  const { getPartnerMoment } = await itm.query({
146
309
  getPartnerMoment: {
147
- __args: { slug: 'my-event-slug' },
310
+ __args: { slug: "my-event-slug" },
148
311
  uid: true,
149
312
  name: true,
150
313
  startDate: true,
@@ -172,7 +335,7 @@ Get approved public lineup entries for a moment. Use `getPartnerMoment` first if
172
335
  ```typescript
173
336
  const { getPartnerMoment } = await itm.query({
174
337
  getPartnerMoment: {
175
- __args: { slug: 'my-event-slug' },
338
+ __args: { slug: "my-event-slug" },
176
339
  uid: true,
177
340
  lineupDisplayLabel: true,
178
341
  },
@@ -186,7 +349,7 @@ if (momentUid) {
186
349
  __args: {
187
350
  momentUid,
188
351
  take: 50,
189
- lineupType: 'DJ', // optional: LINEUP | CO_HOST | PROMOTER | DJ | SPEAKER | VENDOR
352
+ lineupType: "DJ", // optional: LINEUP | CO_HOST | PROMOTER | DJ | SPEAKER | VENDOR
190
353
  },
191
354
  lineups: {
192
355
  uid: true,
@@ -206,7 +369,10 @@ if (momentUid) {
206
369
  },
207
370
  });
208
371
 
209
- console.log(getPartnerMoment?.lineupDisplayLabel, getPublicMomentLineups.lineups);
372
+ console.log(
373
+ getPartnerMoment?.lineupDisplayLabel,
374
+ getPublicMomentLineups.lineups
375
+ );
210
376
  }
211
377
  ```
212
378
 
@@ -221,7 +387,7 @@ Get public lineup invitation details by response token. This is useful if you bu
221
387
  ```typescript
222
388
  const { getMomentLineupByToken } = await itm.query({
223
389
  getMomentLineupByToken: {
224
- __args: { token: 'lineup-response-token' },
390
+ __args: { token: "lineup-response-token" },
225
391
  artistName: true,
226
392
  momentName: true,
227
393
  brandName: true,
@@ -232,7 +398,7 @@ const { getMomentLineupByToken } = await itm.query({
232
398
  });
233
399
  ```
234
400
 
235
- ### `getMomentStats`
401
+ ### `getPartnerMomentStats`
236
402
 
237
403
  Get cached partner-facing counts for a moment owned by the authenticated partner's brand.
238
404
 
@@ -242,9 +408,9 @@ Get cached partner-facing counts for a moment owned by the authenticated partner
242
408
  - `ticketRsvpRequestsCount` counts ticket RSVP requests.
243
409
 
244
410
  ```typescript
245
- const { getMomentStats } = await itm.query({
246
- getMomentStats: {
247
- __args: { momentSlug: 'my-event-slug' },
411
+ const { getPartnerMomentStats } = await itm.query({
412
+ getPartnerMomentStats: {
413
+ __args: { momentSlug: "my-event-slug" },
248
414
  ticketsCount: true,
249
415
  waitlistCount: true,
250
416
  externalRsvpCount: true,
@@ -253,7 +419,56 @@ const { getMomentStats } = await itm.query({
253
419
  });
254
420
  ```
255
421
 
256
- > **Note:** `getMomentStats` returns `null` if the moment does not exist or is not owned by the authenticated partner's brand.
422
+ > **Note:** `getPartnerMomentStats` returns `null` if the moment does not exist or is not owned by the authenticated partner's brand.
423
+
424
+ ### `getPartnerTicketTiersForMoment`
425
+
426
+ Get paginated, partner-facing ticket tiers for a moment. Use this for pricing,
427
+ inventory, and dynamic catalog views without selecting the full public `Moment`
428
+ payload.
429
+
430
+ Provide either `momentSlug` or `momentUid`. If `isActive` is omitted, the backend
431
+ returns active tiers only; pass `isActive: false` only when you intentionally need
432
+ inactive tiers.
433
+
434
+ ```typescript
435
+ const { getPartnerTicketTiersForMoment } = await itm.query({
436
+ getPartnerTicketTiersForMoment: {
437
+ __args: {
438
+ input: {
439
+ momentSlug: "my-event-slug",
440
+ take: 20,
441
+ search: "general",
442
+ isPaid: true,
443
+ minPrice: 1000, // cents
444
+ maxPrice: 5000, // cents
445
+ },
446
+ },
447
+ ticketTiers: {
448
+ uid: true,
449
+ name: true,
450
+ label: true,
451
+ labelColor: true,
452
+ price: true, // cents
453
+ supply: true,
454
+ remainingSupply: true,
455
+ fillRate: true, // 0-1 sold-through ratio
456
+ isActive: true,
457
+ isPrivate: true,
458
+ slotDate: true,
459
+ slotDurationHours: true,
460
+ },
461
+ totalCount: true,
462
+ hasNextPage: true,
463
+ nextCursor: true,
464
+ },
465
+ });
466
+ ```
467
+
468
+ Supported filters include `ticketTierUids`, `search`, `isActive`, `isPrivate`,
469
+ `isPaid`, exact/min/max `price`, exact/min/max `supply`, exact/min/max
470
+ `remainingSupply`, min/max `fillRate`, admission-window bounds (`startsAt`,
471
+ `endsAt`), and slot date bounds (`slotDateStart`, `slotDateEnd`).
257
472
 
258
473
  ### `getPartnerTicketsForMoment`
259
474
 
@@ -263,8 +478,8 @@ Get paginated tickets for a moment.
263
478
  const { getPartnerTicketsForMoment } = await itm.query({
264
479
  getPartnerTicketsForMoment: {
265
480
  __args: {
266
- momentSlug: 'my-event-slug',
267
- checkedIn: true, // optional: filter by check-in status
481
+ momentSlug: "my-event-slug",
482
+ checkedIn: true, // optional: filter by check-in status
268
483
  take: 50,
269
484
  },
270
485
  tickets: {
@@ -290,7 +505,7 @@ Get paginated customers (users with their tickets) for a moment.
290
505
  ```typescript
291
506
  const { getPartnerCustomersForMoment } = await itm.query({
292
507
  getPartnerCustomersForMoment: {
293
- __args: { momentSlug: 'my-event-slug', take: 50 },
508
+ __args: { momentSlug: "my-event-slug", take: 50 },
294
509
  customers: {
295
510
  user: { uid: true, name: true, primaryEmail: true, phoneNumber: true },
296
511
  tickets: { uid: true, price: true, redeemedAt: true },
@@ -309,7 +524,7 @@ Get a moment collection by slug. Collections group related moments together (e.g
309
524
  ```typescript
310
525
  const { getPartnerMomentCollection } = await itm.query({
311
526
  getPartnerMomentCollection: {
312
- __args: { slug: 'summer-series' },
527
+ __args: { slug: "summer-series" },
313
528
  uid: true,
314
529
  name: true,
315
530
  description: true,
@@ -350,10 +565,10 @@ You can filter moments within a collection using `queryFilters`:
350
565
  ```typescript
351
566
  const { getPartnerMomentCollection } = await itm.query({
352
567
  getPartnerMomentCollection: {
353
- __args: { slug: 'summer-series' },
568
+ __args: { slug: "summer-series" },
354
569
  name: true,
355
570
  moments: {
356
- __args: { queryFilters: ['upcoming'] },
571
+ __args: { queryFilters: ["upcoming"] },
357
572
  uid: true,
358
573
  name: true,
359
574
  startDate: true,
@@ -411,7 +626,7 @@ Filter by moment to get echoes scoped to a specific event:
411
626
  ```typescript
412
627
  const { getPartnerPublicEchoes } = await itm.query({
413
628
  getPartnerPublicEchoes: {
414
- __args: { momentSlug: 'my-event-slug', take: 10 },
629
+ __args: { momentSlug: "my-event-slug", take: 10 },
415
630
  echoes: {
416
631
  uid: true,
417
632
  type: true,
@@ -445,8 +660,8 @@ const { respondToMomentLineup } = await itm.mutation({
445
660
  respondToMomentLineup: {
446
661
  __args: {
447
662
  input: {
448
- token: 'lineup-response-token',
449
- status: 'APPROVED', // APPROVED or REJECTED
663
+ token: "lineup-response-token",
664
+ status: "APPROVED", // APPROVED or REJECTED
450
665
  },
451
666
  },
452
667
  status: true,
@@ -470,10 +685,10 @@ Use OTP subscribe when you want the user to confirm the phone number before the
470
685
 
471
686
  ### Flow Summary
472
687
 
473
- | Mode | First mutation | First response | Second mutation |
474
- |---|---|---|---|
475
- | Direct | `createPartnerBrandSubscription` with `requireOtpVerification: false` or omitted | `verificationRequired: false` and `subscription` populated | None |
476
- | OTP | `createPartnerBrandSubscription` with `requireOtpVerification: true` | `verificationRequired: true`, `verificationToken`, `subscription: null` | `verifyPartnerBrandSubscription` |
688
+ | Mode | First mutation | First response | Second mutation |
689
+ | ------ | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------- |
690
+ | Direct | `createPartnerBrandSubscription` with `requireOtpVerification: false` or omitted | `verificationRequired: false` and `subscription` populated | None |
691
+ | OTP | `createPartnerBrandSubscription` with `requireOtpVerification: true` | `verificationRequired: true`, `verificationToken`, `subscription: null` | `verifyPartnerBrandSubscription` |
477
692
 
478
693
  ### Input Behavior
479
694
 
@@ -508,27 +723,27 @@ The field is part of the generated SDK input types:
508
723
  import type {
509
724
  CreatePartnerBrandSubscriptionInput,
510
725
  PostSubscriptionMessageInput,
511
- } from '@itm-studio/partner-sdk';
726
+ } from "@itm-studio/partner-sdk";
512
727
 
513
728
  const welcomeMessage: PostSubscriptionMessageInput = {
514
729
  sms: {
515
- body: 'Welcome to Acme. Reply STOP to opt out.',
516
- mediaUrls: ['https://cdn.example.com/welcome-banner.png'],
730
+ body: "Welcome to Acme. Reply STOP to opt out.",
731
+ mediaUrls: ["https://cdn.example.com/welcome-banner.png"],
517
732
  },
518
733
  email: {
519
- subject: 'Welcome to Acme',
520
- body: 'Thanks for subscribing. Your first update is on the way.',
734
+ subject: "Welcome to Acme",
735
+ body: "Thanks for subscribing. Your first update is on the way.",
521
736
  },
522
737
  push: {
523
- title: 'Welcome to Acme',
524
- body: 'Tap to see what is dropping this week.',
738
+ title: "Welcome to Acme",
739
+ body: "Tap to see what is dropping this week.",
525
740
  },
526
741
  };
527
742
 
528
743
  const input: CreatePartnerBrandSubscriptionInput = {
529
- phoneNumber: '+14155550111',
530
- fullName: 'Jane Doe',
531
- email: 'jane@example.com',
744
+ phoneNumber: "+14155550111",
745
+ fullName: "Jane Doe",
746
+ email: "jane@example.com",
532
747
  postSubscriptionMessage: welcomeMessage,
533
748
  };
534
749
  ```
@@ -538,21 +753,21 @@ await itm.mutation({
538
753
  createPartnerBrandSubscription: {
539
754
  __args: {
540
755
  input: {
541
- phoneNumber: '+14155550111',
542
- fullName: 'Jane Doe',
543
- email: 'jane@example.com',
756
+ phoneNumber: "+14155550111",
757
+ fullName: "Jane Doe",
758
+ email: "jane@example.com",
544
759
  postSubscriptionMessage: {
545
760
  sms: {
546
- body: 'Welcome to Acme — your first drop ships Friday. Reply STOP to opt out.',
547
- mediaUrls: ['https://cdn.example.com/welcome-banner.png'],
761
+ body: "Welcome to Acme — your first drop ships Friday. Reply STOP to opt out.",
762
+ mediaUrls: ["https://cdn.example.com/welcome-banner.png"],
548
763
  },
549
764
  email: {
550
- subject: 'Welcome to Acme',
551
- body: 'Hi Jane,\n\nThanks for subscribing — here is what to expect next...',
765
+ subject: "Welcome to Acme",
766
+ body: "Hi Jane,\n\nThanks for subscribing — here is what to expect next...",
552
767
  },
553
768
  push: {
554
- title: 'Welcome to Acme',
555
- body: 'Tap to see what is dropping this week.',
769
+ title: "Welcome to Acme",
770
+ body: "Tap to see what is dropping this week.",
556
771
  },
557
772
  },
558
773
  },
@@ -565,20 +780,20 @@ await itm.mutation({
565
780
 
566
781
  **Direct vs OTP timing:**
567
782
 
568
- | Mode | Where to pass `postSubscriptionMessage` | When delivery starts |
569
- |---|---|---|
570
- | Direct subscribe | Initial `createPartnerBrandSubscription` call | After the subscription is created or reactivated |
571
- | OTP subscribe | Initial `createPartnerBrandSubscription` call with `requireOtpVerification: true` | After `verifyPartnerBrandSubscription` succeeds |
783
+ | Mode | Where to pass `postSubscriptionMessage` | When delivery starts |
784
+ | ---------------- | --------------------------------------------------------------------------------- | ------------------------------------------------ |
785
+ | Direct subscribe | Initial `createPartnerBrandSubscription` call | After the subscription is created or reactivated |
786
+ | OTP subscribe | Initial `createPartnerBrandSubscription` call with `requireOtpVerification: true` | After `verifyPartnerBrandSubscription` succeeds |
572
787
 
573
788
  Do not pass `postSubscriptionMessage` to `verifyPartnerBrandSubscription`; that mutation only accepts `verificationToken` and `code`.
574
789
 
575
790
  **Channel inputs:**
576
791
 
577
- | Channel | Required | Optional | Notes |
578
- |---|---|---|---|
579
- | `sms` | `body` | `mediaUrls` (string array of image URLs for MMS) | Delivered to `phoneNumber`. |
580
- | `email` | `subject`, `body` | — | `body` is plain text or markdown, rendered via React Email. Skipped if the user has no primary email or the brand has no configured email alias. |
581
- | `push` | `title`, `body` | — | Skipped if the user has no push tokens or has opted out of brand messages. |
792
+ | Channel | Required | Optional | Notes |
793
+ | ------- | ----------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
794
+ | `sms` | `body` | `mediaUrls` (string array of image URLs for MMS) | Delivered to `phoneNumber`. |
795
+ | `email` | `subject`, `body` | — | `body` is plain text or markdown, rendered via React Email. Skipped if the user has no primary email or the brand has no configured email alias. |
796
+ | `push` | `title`, `body` | — | Skipped if the user has no push tokens or has opted out of brand messages. |
582
797
 
583
798
  **Delivery semantics:**
584
799
 
@@ -602,10 +817,10 @@ await itm.mutation({
602
817
  createPartnerBrandSubscription: {
603
818
  __args: {
604
819
  input: {
605
- phoneNumber: '+14155550111',
820
+ phoneNumber: "+14155550111",
606
821
  postSubscriptionMessage: {
607
822
  sms: {
608
- body: 'You are subscribed to Acme updates. Reply STOP to opt out.',
823
+ body: "You are subscribed to Acme updates. Reply STOP to opt out.",
609
824
  },
610
825
  },
611
826
  },
@@ -623,16 +838,16 @@ const { createPartnerBrandSubscription } = await itm.mutation({
623
838
  createPartnerBrandSubscription: {
624
839
  __args: {
625
840
  input: {
626
- phoneNumber: '+14155550112',
841
+ phoneNumber: "+14155550112",
627
842
  requireOtpVerification: true,
628
- email: 'jane@example.com',
843
+ email: "jane@example.com",
629
844
  postSubscriptionMessage: {
630
845
  sms: {
631
- body: 'Thanks for joining Acme. Reply STOP to opt out.',
846
+ body: "Thanks for joining Acme. Reply STOP to opt out.",
632
847
  },
633
848
  email: {
634
- subject: 'You are subscribed',
635
- body: 'Your subscription is confirmed. We will send updates here.',
849
+ subject: "You are subscribed",
850
+ body: "Your subscription is confirmed. We will send updates here.",
636
851
  },
637
852
  },
638
853
  },
@@ -648,7 +863,7 @@ await itm.mutation({
648
863
  __args: {
649
864
  input: {
650
865
  verificationToken: createPartnerBrandSubscription.verificationToken!,
651
- code: '123456',
866
+ code: "123456",
652
867
  },
653
868
  },
654
869
  uid: true,
@@ -671,17 +886,17 @@ const { createPartnerBrandSubscription } = await itm.mutation({
671
886
  createPartnerBrandSubscription: {
672
887
  __args: {
673
888
  input: {
674
- phoneNumber: '+14155550111',
675
- fullName: 'Jane Doe',
676
- email: 'jane@example.com',
677
- listNames: ['VIP', 'Newsletter'],
889
+ phoneNumber: "+14155550111",
890
+ fullName: "Jane Doe",
891
+ email: "jane@example.com",
892
+ listNames: ["VIP", "Newsletter"],
678
893
  compliance: {
679
- pageUrl: 'https://example.com/signup',
680
- submissionMethod: 'partner_sdk',
894
+ pageUrl: "https://example.com/signup",
895
+ submissionMethod: "partner_sdk",
681
896
  },
682
897
  postSubscriptionMessage: {
683
898
  sms: {
684
- body: 'Welcome to Acme updates. Reply STOP to opt out.',
899
+ body: "Welcome to Acme updates. Reply STOP to opt out.",
685
900
  },
686
901
  },
687
902
  },
@@ -718,12 +933,12 @@ const { createPartnerBrandSubscription } = await itm.mutation({
718
933
  createPartnerBrandSubscription: {
719
934
  __args: {
720
935
  input: {
721
- phoneNumber: '+14155550112',
936
+ phoneNumber: "+14155550112",
722
937
  requireOtpVerification: true,
723
- fullName: 'Otp User',
938
+ fullName: "Otp User",
724
939
  compliance: {
725
- pageUrl: 'https://example.com/otp-signup',
726
- submissionMethod: 'partner_sdk',
940
+ pageUrl: "https://example.com/otp-signup",
941
+ submissionMethod: "partner_sdk",
727
942
  },
728
943
  },
729
944
  },
@@ -733,18 +948,17 @@ const { createPartnerBrandSubscription } = await itm.mutation({
733
948
  });
734
949
 
735
950
  if (!createPartnerBrandSubscription.verificationToken) {
736
- throw new Error('Expected OTP verification to be required');
951
+ throw new Error("Expected OTP verification to be required");
737
952
  }
738
953
 
739
954
  // Collect the SMS code from the user in your UI or API flow.
740
- const code = '123456';
955
+ const code = "123456";
741
956
 
742
957
  const { verifyPartnerBrandSubscription } = await itm.mutation({
743
958
  verifyPartnerBrandSubscription: {
744
959
  __args: {
745
960
  input: {
746
- verificationToken:
747
- createPartnerBrandSubscription.verificationToken,
961
+ verificationToken: createPartnerBrandSubscription.verificationToken,
748
962
  code,
749
963
  },
750
964
  },
@@ -770,17 +984,17 @@ This is the recommended mode when:
770
984
  This is the full two-step flow most integrators will want to implement from a server route or server action.
771
985
 
772
986
  ```typescript
773
- import { createITMPartnerClient } from '@itm-studio/partner-sdk';
987
+ import { createITMPartnerClient } from "@itm-studio/partner-sdk";
774
988
 
775
989
  function createRequestScopedITM(request: Request) {
776
990
  return createITMPartnerClient({
777
991
  token: process.env.ITM_PARTNER_TOKEN!,
778
992
  headers: () => ({
779
- ...(request.headers.get('x-forwarded-for')
780
- ? { 'x-forwarded-for': request.headers.get('x-forwarded-for')! }
993
+ ...(request.headers.get("x-forwarded-for")
994
+ ? { "x-forwarded-for": request.headers.get("x-forwarded-for")! }
781
995
  : {}),
782
- ...(request.headers.get('user-agent')
783
- ? { 'user-agent': request.headers.get('user-agent')! }
996
+ ...(request.headers.get("user-agent")
997
+ ? { "user-agent": request.headers.get("user-agent")! }
784
998
  : {}),
785
999
  }),
786
1000
  });
@@ -793,24 +1007,24 @@ export async function startOtpSubscription(request: Request) {
793
1007
  createPartnerBrandSubscription: {
794
1008
  __args: {
795
1009
  input: {
796
- phoneNumber: '+14155550112',
1010
+ phoneNumber: "+14155550112",
797
1011
  requireOtpVerification: true,
798
- fullName: 'Otp User',
799
- email: 'otp@example.com',
800
- listNames: ['Members', 'SMS'],
1012
+ fullName: "Otp User",
1013
+ email: "otp@example.com",
1014
+ listNames: ["Members", "SMS"],
801
1015
  compliance: {
802
- pageUrl: 'https://example.com/join',
803
- submissionMethod: 'partner_sdk',
1016
+ pageUrl: "https://example.com/join",
1017
+ submissionMethod: "partner_sdk",
804
1018
  submittedAt: new Date().toISOString(),
805
- timezone: 'America/New_York',
1019
+ timezone: "America/New_York",
806
1020
  },
807
1021
  postSubscriptionMessage: {
808
1022
  sms: {
809
- body: 'You are subscribed. Reply STOP to opt out.',
1023
+ body: "You are subscribed. Reply STOP to opt out.",
810
1024
  },
811
1025
  email: {
812
- subject: 'Welcome',
813
- body: 'Your subscription is confirmed.',
1026
+ subject: "Welcome",
1027
+ body: "Your subscription is confirmed.",
814
1028
  },
815
1029
  },
816
1030
  },
@@ -825,13 +1039,13 @@ export async function startOtpSubscription(request: Request) {
825
1039
 
826
1040
  if (!createPartnerBrandSubscription.verificationRequired) {
827
1041
  return {
828
- status: 'subscribed',
1042
+ status: "subscribed",
829
1043
  subscriptionUid: createPartnerBrandSubscription.subscription?.uid ?? null,
830
1044
  };
831
1045
  }
832
1046
 
833
1047
  return {
834
- status: 'otp_required',
1048
+ status: "otp_required",
835
1049
  verificationToken: createPartnerBrandSubscription.verificationToken,
836
1050
  };
837
1051
  }
@@ -839,7 +1053,7 @@ export async function startOtpSubscription(request: Request) {
839
1053
  export async function finishOtpSubscription(
840
1054
  request: Request,
841
1055
  verificationToken: string,
842
- code: string,
1056
+ code: string
843
1057
  ) {
844
1058
  const itm = createRequestScopedITM(request);
845
1059
 
@@ -880,7 +1094,7 @@ Get a presigned S3 URL to upload a file directly.
880
1094
  ```typescript
881
1095
  const { getPartnerUploadUrl } = await itm.mutation({
882
1096
  getPartnerUploadUrl: {
883
- __args: { filename: 'event-cover', fileExtension: 'jpg' },
1097
+ __args: { filename: "event-cover", fileExtension: "jpg" },
884
1098
  url: true,
885
1099
  filename: true,
886
1100
  },
@@ -888,9 +1102,9 @@ const { getPartnerUploadUrl } = await itm.mutation({
888
1102
 
889
1103
  // Upload the file to S3 using the presigned URL
890
1104
  await fetch(getPartnerUploadUrl.url, {
891
- method: 'PUT',
1105
+ method: "PUT",
892
1106
  body: fileBuffer, // Buffer, Blob, or ReadableStream
893
- headers: { 'Content-Type': 'image/jpeg' },
1107
+ headers: { "Content-Type": "image/jpeg" },
894
1108
  });
895
1109
  ```
896
1110
 
@@ -902,7 +1116,7 @@ After uploading, register the asset so it's linked to your brand.
902
1116
  const { createPartnerMediaAsset } = await itm.mutation({
903
1117
  createPartnerMediaAsset: {
904
1118
  __args: {
905
- mimetype: 'image/jpeg',
1119
+ mimetype: "image/jpeg",
906
1120
  filename: getPartnerUploadUrl.filename, // from Step 1
907
1121
  dimensions: { width: 1920, height: 1080 },
908
1122
  },
@@ -924,37 +1138,38 @@ const { createPartnerMoment } = await itm.mutation({
924
1138
  createPartnerMoment: {
925
1139
  __args: {
926
1140
  input: {
927
- name: 'Summer Rooftop Party',
928
- description: 'A rooftop party in NYC',
929
- blurb: 'Join us on the roof!',
930
- startDate: '2026-07-15T20:00:00.000Z',
931
- endDate: '2026-07-16T02:00:00.000Z',
932
- timezone: 'America/New_York',
933
- type: 'IRL',
1141
+ name: "Summer Rooftop Party",
1142
+ description: "A rooftop party in NYC",
1143
+ blurb: "Join us on the roof!",
1144
+ startDate: "2026-07-15T20:00:00.000Z",
1145
+ endDate: "2026-07-16T02:00:00.000Z",
1146
+ timezone: "America/New_York",
1147
+ type: "IRL",
1148
+ capacity: 250,
934
1149
  coverImageUid: createPartnerMediaAsset.uid, // from upload flow
935
1150
  venue: {
936
- name: 'Rooftop Bar',
937
- city: 'New York',
938
- country: 'US',
939
- address: '123 Main St',
1151
+ name: "Rooftop Bar",
1152
+ city: "New York",
1153
+ country: "US",
1154
+ address: "123 Main St",
940
1155
  },
941
- category: 'PARTY',
942
- subcategory: 'ROOFTOP_PARTY',
1156
+ category: "PARTY",
1157
+ subcategory: "ROOFTOP_PARTY",
943
1158
  ticketTiers: [
944
1159
  {
945
- name: 'General Admission',
946
- price: 2500, // $25.00 in cents
1160
+ name: "General Admission",
1161
+ price: 2500, // $25.00 in cents
947
1162
  supply: 200,
948
1163
  maxPerUser: 4,
949
- currencyCode: 'USD',
1164
+ currencyCode: "USD",
950
1165
  },
951
1166
  {
952
- name: 'VIP',
953
- price: 7500, // $75.00 in cents
1167
+ name: "VIP",
1168
+ price: 7500, // $75.00 in cents
954
1169
  supply: 50,
955
1170
  maxPerUser: 2,
956
- currencyCode: 'USD',
957
- description: 'Includes open bar',
1171
+ currencyCode: "USD",
1172
+ description: "Includes open bar",
958
1173
  },
959
1174
  ],
960
1175
  },
@@ -965,6 +1180,7 @@ const { createPartnerMoment } = await itm.mutation({
965
1180
  startDate: true,
966
1181
  endDate: true,
967
1182
  status: true,
1183
+ capacity: true,
968
1184
  coverImage: { url: true },
969
1185
  venue: { name: true, city: true },
970
1186
  ticketTiers: {
@@ -978,19 +1194,93 @@ const { createPartnerMoment } = await itm.mutation({
978
1194
  });
979
1195
  ```
980
1196
 
981
- **Required fields:** `name`, `description`, `blurb`, `startDate`, `endDate`, `timezone`, `type`
1197
+ **Required fields:** `name`, `description`, `startDate`, `endDate`, `timezone`, `type`
982
1198
 
983
1199
  **Moment types:**
1200
+
984
1201
  - `IRL` — in-person event (requires `venue`)
985
- - `DIGITAL` — online event (no venue needed)
1202
+ - `DIGITAL` — online event (no venue needed; accepts `onlineConfig`)
1203
+ - `HYBRID` — venue plus online access. When creating ticket tiers for a hybrid moment, set each tier's `attendanceMode` to `IN_PERSON` or `ONLINE`.
1204
+
1205
+ For digital or hybrid moments, pass `onlineConfig` when you want ITM to store the meeting details:
1206
+
1207
+ ```typescript
1208
+ await itm.mutation({
1209
+ createPartnerMoment: {
1210
+ __args: {
1211
+ input: {
1212
+ name: "Hybrid Show",
1213
+ description: "Attend in person or online",
1214
+ startDate: "2026-07-15T20:00:00.000Z",
1215
+ endDate: "2026-07-15T23:00:00.000Z",
1216
+ timezone: "America/New_York",
1217
+ type: "HYBRID",
1218
+ onlineConfig: {
1219
+ provider: "ZOOM", // GOOGLE_MEET | MS_TEAMS | OTHER | ZOOM
1220
+ meetingUrl: "https://zoom.us/j/123456789",
1221
+ meetingPasscode: "club",
1222
+ },
1223
+ venue: { name: "Main Room", city: "New York", country: "US" },
1224
+ ticketTiers: [
1225
+ {
1226
+ name: "Online Admission",
1227
+ price: 1500,
1228
+ supply: 300,
1229
+ maxPerUser: 4,
1230
+ currencyCode: "USD",
1231
+ attendanceMode: "ONLINE",
1232
+ },
1233
+ ],
1234
+ },
1235
+ },
1236
+ uid: true,
1237
+ type: true,
1238
+ ticketTiers: { uid: true, attendanceMode: true },
1239
+ },
1240
+ });
1241
+ ```
986
1242
 
987
1243
  **Ticket tier pricing:** `price` is in **cents** (e.g. `2500` = $25.00 for USD). Use `0` for free tiers. See [Money Amounts](#money-amounts) for display formatting, including zero-decimal currencies.
988
1244
 
1245
+ ### `editPartnerTicketTiersForMoment`
1246
+
1247
+ Bulk edit partner-owned ticket tiers for a moment. Provide either `momentSlug` or
1248
+ `momentUid`, then target specific tiers by `ticketTierUid`.
1249
+
1250
+ This endpoint intentionally supports only `price` and `supply` updates. Use
1251
+ `getPartnerTicketTiersForMoment` first if you need to discover tier UIDs.
1252
+
1253
+ ```typescript
1254
+ const { editPartnerTicketTiersForMoment } = await itm.mutation({
1255
+ editPartnerTicketTiersForMoment: {
1256
+ __args: {
1257
+ input: {
1258
+ momentSlug: "my-event-slug",
1259
+ updates: [
1260
+ {
1261
+ ticketTierUid: "tier-uid",
1262
+ price: 3000, // cents
1263
+ supply: 120,
1264
+ },
1265
+ ],
1266
+ },
1267
+ },
1268
+ uid: true,
1269
+ name: true,
1270
+ price: true,
1271
+ supply: true,
1272
+ remainingSupply: true,
1273
+ fillRate: true,
1274
+ isActive: true,
1275
+ },
1276
+ });
1277
+ ```
1278
+
989
1279
  ### Complete Upload + Create Moment Flow
990
1280
 
991
1281
  ```typescript
992
- import { createITMPartnerClient } from '@itm-studio/partner-sdk';
993
- import { readFile } from 'fs/promises';
1282
+ import { createITMPartnerClient } from "@itm-studio/partner-sdk";
1283
+ import { readFile } from "fs/promises";
994
1284
 
995
1285
  const itm = createITMPartnerClient({
996
1286
  token: process.env.ITM_PARTNER_TOKEN!,
@@ -999,25 +1289,25 @@ const itm = createITMPartnerClient({
999
1289
  // 1. Get presigned upload URL
1000
1290
  const { getPartnerUploadUrl } = await itm.mutation({
1001
1291
  getPartnerUploadUrl: {
1002
- __args: { filename: 'event-cover', fileExtension: 'jpg' },
1292
+ __args: { filename: "event-cover", fileExtension: "jpg" },
1003
1293
  url: true,
1004
1294
  filename: true,
1005
1295
  },
1006
1296
  });
1007
1297
 
1008
1298
  // 2. Upload file to S3
1009
- const file = await readFile('./event-cover.jpg');
1299
+ const file = await readFile("./event-cover.jpg");
1010
1300
  await fetch(getPartnerUploadUrl.url, {
1011
- method: 'PUT',
1301
+ method: "PUT",
1012
1302
  body: file,
1013
- headers: { 'Content-Type': 'image/jpeg' },
1303
+ headers: { "Content-Type": "image/jpeg" },
1014
1304
  });
1015
1305
 
1016
1306
  // 3. Register the uploaded asset
1017
1307
  const { createPartnerMediaAsset } = await itm.mutation({
1018
1308
  createPartnerMediaAsset: {
1019
1309
  __args: {
1020
- mimetype: 'image/jpeg',
1310
+ mimetype: "image/jpeg",
1021
1311
  filename: getPartnerUploadUrl.filename,
1022
1312
  dimensions: { width: 1920, height: 1080 },
1023
1313
  },
@@ -1030,26 +1320,26 @@ const { createPartnerMoment } = await itm.mutation({
1030
1320
  createPartnerMoment: {
1031
1321
  __args: {
1032
1322
  input: {
1033
- name: 'My Event',
1034
- description: 'An amazing event',
1035
- blurb: 'Come join us!',
1036
- startDate: '2026-07-15T20:00:00.000Z',
1037
- endDate: '2026-07-16T02:00:00.000Z',
1038
- timezone: 'America/New_York',
1039
- type: 'IRL',
1323
+ name: "My Event",
1324
+ description: "An amazing event",
1325
+ blurb: "Come join us!",
1326
+ startDate: "2026-07-15T20:00:00.000Z",
1327
+ endDate: "2026-07-16T02:00:00.000Z",
1328
+ timezone: "America/New_York",
1329
+ type: "IRL",
1040
1330
  coverImageUid: createPartnerMediaAsset.uid,
1041
1331
  venue: {
1042
- name: 'The Venue',
1043
- city: 'New York',
1044
- country: 'US',
1332
+ name: "The Venue",
1333
+ city: "New York",
1334
+ country: "US",
1045
1335
  },
1046
1336
  ticketTiers: [
1047
1337
  {
1048
- name: 'General Admission',
1338
+ name: "General Admission",
1049
1339
  price: 2500,
1050
1340
  supply: 100,
1051
1341
  maxPerUser: 4,
1052
- currencyCode: 'USD',
1342
+ currencyCode: "USD",
1053
1343
  },
1054
1344
  ],
1055
1345
  },
@@ -1061,7 +1351,9 @@ const { createPartnerMoment } = await itm.mutation({
1061
1351
  },
1062
1352
  });
1063
1353
 
1064
- console.log(`Created: ${createPartnerMoment.slug} (${createPartnerMoment.status})`);
1354
+ console.log(
1355
+ `Created: ${createPartnerMoment.slug} (${createPartnerMoment.status})`
1356
+ );
1065
1357
  ```
1066
1358
 
1067
1359
  ## Pagination
@@ -1074,7 +1366,7 @@ let cursor: string | null = null;
1074
1366
  do {
1075
1367
  const { getPartnerTicketsForMoment } = await itm.query({
1076
1368
  getPartnerTicketsForMoment: {
1077
- __args: { momentSlug: 'my-event', cursor, take: 50 },
1369
+ __args: { momentSlug: "my-event", cursor, take: 50 },
1078
1370
  tickets: { uid: true, user: { name: true } },
1079
1371
  hasNextPage: true,
1080
1372
  nextCursor: true,
@@ -1097,7 +1389,7 @@ For staging or local development:
1097
1389
  ```typescript
1098
1390
  const itm = createITMPartnerClient({
1099
1391
  token: process.env.ITM_PARTNER_TOKEN!,
1100
- baseUrl: 'https://staging-api.itm.studio/graphql',
1392
+ baseUrl: "https://staging-api.itm.studio/graphql",
1101
1393
  });
1102
1394
  ```
1103
1395
 
@@ -1157,12 +1449,12 @@ npm run schema:publish # Publish schema to registry
1157
1449
 
1158
1450
  ## CI/CD
1159
1451
 
1160
- | Job | Trigger | What it does |
1161
- |---|---|---|
1162
- | `build-and-test` | Every push & PR | Install, generate, lint, test, build |
1163
- | `schema-check` | PRs only | Validates schema changes won't break partners |
1164
- | `schema-publish` | Push to main | Publishes schema to Hive registry |
1165
- | `publish` | Push to main | Auto-bumps patch version and publishes to NPM |
1452
+ | Job | Trigger | What it does |
1453
+ | ---------------- | --------------- | --------------------------------------------- |
1454
+ | `build-and-test` | Every push & PR | Install, generate, lint, test, build |
1455
+ | `schema-check` | PRs only | Validates schema changes won't break partners |
1456
+ | `schema-publish` | Push to main | Publishes schema to Hive registry |
1457
+ | `publish` | Push to main | Auto-bumps patch version and publishes to NPM |
1166
1458
 
1167
1459
  ## Adding New Partner Endpoints
1168
1460