@itm-studio/partner-sdk 0.2.5 → 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
  });
@@ -90,6 +90,139 @@ export function createITMClientForRequest(request: Request) {
90
90
 
91
91
  If you also send `input.compliance`, the backend keeps the values you provide and backfills missing `ipAddress` and `userAgent` from the forwarded headers when available.
92
92
 
93
+ ## Money Amounts
94
+
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.
100
+
101
+ ```typescript
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",
130
+ currency: currencyCode,
131
+ minimumFractionDigits: maximumFractionDigits,
132
+ maximumFractionDigits,
133
+ }).format(amount);
134
+ }
135
+ ```
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
+
93
226
  ## Available Queries
94
227
 
95
228
  ### `getPartnerMomentsForBrand`
@@ -100,10 +233,10 @@ Get paginated moments (events) for your brand.
100
233
  const { getPartnerMomentsForBrand } = await itm.query({
101
234
  getPartnerMomentsForBrand: {
102
235
  __args: {
103
- take: 20, // items per page (default: 50, omit to use default)
104
- cursor: nextCursor, // from previous response
105
- status: 'UPCOMING', // UPCOMING | LIVE | ENDED
106
- 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)
107
240
  },
108
241
  moments: { uid: true, name: true, startDate: true },
109
242
  totalCount: true,
@@ -113,6 +246,60 @@ const { getPartnerMomentsForBrand } = await itm.query({
113
246
  });
114
247
  ```
115
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
+
116
303
  ### `getPartnerMoment`
117
304
 
118
305
  Get a single moment by slug.
@@ -120,7 +307,7 @@ Get a single moment by slug.
120
307
  ```typescript
121
308
  const { getPartnerMoment } = await itm.query({
122
309
  getPartnerMoment: {
123
- __args: { slug: 'my-event-slug' },
310
+ __args: { slug: "my-event-slug" },
124
311
  uid: true,
125
312
  name: true,
126
313
  startDate: true,
@@ -148,7 +335,7 @@ Get approved public lineup entries for a moment. Use `getPartnerMoment` first if
148
335
  ```typescript
149
336
  const { getPartnerMoment } = await itm.query({
150
337
  getPartnerMoment: {
151
- __args: { slug: 'my-event-slug' },
338
+ __args: { slug: "my-event-slug" },
152
339
  uid: true,
153
340
  lineupDisplayLabel: true,
154
341
  },
@@ -162,7 +349,7 @@ if (momentUid) {
162
349
  __args: {
163
350
  momentUid,
164
351
  take: 50,
165
- lineupType: 'DJ', // optional: LINEUP | CO_HOST | PROMOTER | DJ | SPEAKER | VENDOR
352
+ lineupType: "DJ", // optional: LINEUP | CO_HOST | PROMOTER | DJ | SPEAKER | VENDOR
166
353
  },
167
354
  lineups: {
168
355
  uid: true,
@@ -182,7 +369,10 @@ if (momentUid) {
182
369
  },
183
370
  });
184
371
 
185
- console.log(getPartnerMoment?.lineupDisplayLabel, getPublicMomentLineups.lineups);
372
+ console.log(
373
+ getPartnerMoment?.lineupDisplayLabel,
374
+ getPublicMomentLineups.lineups
375
+ );
186
376
  }
187
377
  ```
188
378
 
@@ -197,7 +387,7 @@ Get public lineup invitation details by response token. This is useful if you bu
197
387
  ```typescript
198
388
  const { getMomentLineupByToken } = await itm.query({
199
389
  getMomentLineupByToken: {
200
- __args: { token: 'lineup-response-token' },
390
+ __args: { token: "lineup-response-token" },
201
391
  artistName: true,
202
392
  momentName: true,
203
393
  brandName: true,
@@ -208,7 +398,7 @@ const { getMomentLineupByToken } = await itm.query({
208
398
  });
209
399
  ```
210
400
 
211
- ### `getMomentStats`
401
+ ### `getPartnerMomentStats`
212
402
 
213
403
  Get cached partner-facing counts for a moment owned by the authenticated partner's brand.
214
404
 
@@ -218,9 +408,9 @@ Get cached partner-facing counts for a moment owned by the authenticated partner
218
408
  - `ticketRsvpRequestsCount` counts ticket RSVP requests.
219
409
 
220
410
  ```typescript
221
- const { getMomentStats } = await itm.query({
222
- getMomentStats: {
223
- __args: { momentSlug: 'my-event-slug' },
411
+ const { getPartnerMomentStats } = await itm.query({
412
+ getPartnerMomentStats: {
413
+ __args: { momentSlug: "my-event-slug" },
224
414
  ticketsCount: true,
225
415
  waitlistCount: true,
226
416
  externalRsvpCount: true,
@@ -229,7 +419,56 @@ const { getMomentStats } = await itm.query({
229
419
  });
230
420
  ```
231
421
 
232
- > **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`).
233
472
 
234
473
  ### `getPartnerTicketsForMoment`
235
474
 
@@ -239,8 +478,8 @@ Get paginated tickets for a moment.
239
478
  const { getPartnerTicketsForMoment } = await itm.query({
240
479
  getPartnerTicketsForMoment: {
241
480
  __args: {
242
- momentSlug: 'my-event-slug',
243
- checkedIn: true, // optional: filter by check-in status
481
+ momentSlug: "my-event-slug",
482
+ checkedIn: true, // optional: filter by check-in status
244
483
  take: 50,
245
484
  },
246
485
  tickets: {
@@ -266,7 +505,7 @@ Get paginated customers (users with their tickets) for a moment.
266
505
  ```typescript
267
506
  const { getPartnerCustomersForMoment } = await itm.query({
268
507
  getPartnerCustomersForMoment: {
269
- __args: { momentSlug: 'my-event-slug', take: 50 },
508
+ __args: { momentSlug: "my-event-slug", take: 50 },
270
509
  customers: {
271
510
  user: { uid: true, name: true, primaryEmail: true, phoneNumber: true },
272
511
  tickets: { uid: true, price: true, redeemedAt: true },
@@ -285,7 +524,7 @@ Get a moment collection by slug. Collections group related moments together (e.g
285
524
  ```typescript
286
525
  const { getPartnerMomentCollection } = await itm.query({
287
526
  getPartnerMomentCollection: {
288
- __args: { slug: 'summer-series' },
527
+ __args: { slug: "summer-series" },
289
528
  uid: true,
290
529
  name: true,
291
530
  description: true,
@@ -326,10 +565,10 @@ You can filter moments within a collection using `queryFilters`:
326
565
  ```typescript
327
566
  const { getPartnerMomentCollection } = await itm.query({
328
567
  getPartnerMomentCollection: {
329
- __args: { slug: 'summer-series' },
568
+ __args: { slug: "summer-series" },
330
569
  name: true,
331
570
  moments: {
332
- __args: { queryFilters: ['upcoming'] },
571
+ __args: { queryFilters: ["upcoming"] },
333
572
  uid: true,
334
573
  name: true,
335
574
  startDate: true,
@@ -387,7 +626,7 @@ Filter by moment to get echoes scoped to a specific event:
387
626
  ```typescript
388
627
  const { getPartnerPublicEchoes } = await itm.query({
389
628
  getPartnerPublicEchoes: {
390
- __args: { momentSlug: 'my-event-slug', take: 10 },
629
+ __args: { momentSlug: "my-event-slug", take: 10 },
391
630
  echoes: {
392
631
  uid: true,
393
632
  type: true,
@@ -421,8 +660,8 @@ const { respondToMomentLineup } = await itm.mutation({
421
660
  respondToMomentLineup: {
422
661
  __args: {
423
662
  input: {
424
- token: 'lineup-response-token',
425
- status: 'APPROVED', // APPROVED or REJECTED
663
+ token: "lineup-response-token",
664
+ status: "APPROVED", // APPROVED or REJECTED
426
665
  },
427
666
  },
428
667
  status: true,
@@ -446,10 +685,10 @@ Use OTP subscribe when you want the user to confirm the phone number before the
446
685
 
447
686
  ### Flow Summary
448
687
 
449
- | Mode | First mutation | First response | Second mutation |
450
- |---|---|---|---|
451
- | Direct | `createPartnerBrandSubscription` with `requireOtpVerification: false` or omitted | `verificationRequired: false` and `subscription` populated | None |
452
- | 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` |
453
692
 
454
693
  ### Input Behavior
455
694
 
@@ -484,27 +723,27 @@ The field is part of the generated SDK input types:
484
723
  import type {
485
724
  CreatePartnerBrandSubscriptionInput,
486
725
  PostSubscriptionMessageInput,
487
- } from '@itm-studio/partner-sdk';
726
+ } from "@itm-studio/partner-sdk";
488
727
 
489
728
  const welcomeMessage: PostSubscriptionMessageInput = {
490
729
  sms: {
491
- body: 'Welcome to Acme. Reply STOP to opt out.',
492
- 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"],
493
732
  },
494
733
  email: {
495
- subject: 'Welcome to Acme',
496
- 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.",
497
736
  },
498
737
  push: {
499
- title: 'Welcome to Acme',
500
- body: 'Tap to see what is dropping this week.',
738
+ title: "Welcome to Acme",
739
+ body: "Tap to see what is dropping this week.",
501
740
  },
502
741
  };
503
742
 
504
743
  const input: CreatePartnerBrandSubscriptionInput = {
505
- phoneNumber: '+14155550111',
506
- fullName: 'Jane Doe',
507
- email: 'jane@example.com',
744
+ phoneNumber: "+14155550111",
745
+ fullName: "Jane Doe",
746
+ email: "jane@example.com",
508
747
  postSubscriptionMessage: welcomeMessage,
509
748
  };
510
749
  ```
@@ -514,21 +753,21 @@ await itm.mutation({
514
753
  createPartnerBrandSubscription: {
515
754
  __args: {
516
755
  input: {
517
- phoneNumber: '+14155550111',
518
- fullName: 'Jane Doe',
519
- email: 'jane@example.com',
756
+ phoneNumber: "+14155550111",
757
+ fullName: "Jane Doe",
758
+ email: "jane@example.com",
520
759
  postSubscriptionMessage: {
521
760
  sms: {
522
- body: 'Welcome to Acme — your first drop ships Friday. Reply STOP to opt out.',
523
- 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"],
524
763
  },
525
764
  email: {
526
- subject: 'Welcome to Acme',
527
- 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...",
528
767
  },
529
768
  push: {
530
- title: 'Welcome to Acme',
531
- body: 'Tap to see what is dropping this week.',
769
+ title: "Welcome to Acme",
770
+ body: "Tap to see what is dropping this week.",
532
771
  },
533
772
  },
534
773
  },
@@ -541,20 +780,20 @@ await itm.mutation({
541
780
 
542
781
  **Direct vs OTP timing:**
543
782
 
544
- | Mode | Where to pass `postSubscriptionMessage` | When delivery starts |
545
- |---|---|---|
546
- | Direct subscribe | Initial `createPartnerBrandSubscription` call | After the subscription is created or reactivated |
547
- | 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 |
548
787
 
549
788
  Do not pass `postSubscriptionMessage` to `verifyPartnerBrandSubscription`; that mutation only accepts `verificationToken` and `code`.
550
789
 
551
790
  **Channel inputs:**
552
791
 
553
- | Channel | Required | Optional | Notes |
554
- |---|---|---|---|
555
- | `sms` | `body` | `mediaUrls` (string array of image URLs for MMS) | Delivered to `phoneNumber`. |
556
- | `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. |
557
- | `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. |
558
797
 
559
798
  **Delivery semantics:**
560
799
 
@@ -578,10 +817,10 @@ await itm.mutation({
578
817
  createPartnerBrandSubscription: {
579
818
  __args: {
580
819
  input: {
581
- phoneNumber: '+14155550111',
820
+ phoneNumber: "+14155550111",
582
821
  postSubscriptionMessage: {
583
822
  sms: {
584
- 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.",
585
824
  },
586
825
  },
587
826
  },
@@ -599,16 +838,16 @@ const { createPartnerBrandSubscription } = await itm.mutation({
599
838
  createPartnerBrandSubscription: {
600
839
  __args: {
601
840
  input: {
602
- phoneNumber: '+14155550112',
841
+ phoneNumber: "+14155550112",
603
842
  requireOtpVerification: true,
604
- email: 'jane@example.com',
843
+ email: "jane@example.com",
605
844
  postSubscriptionMessage: {
606
845
  sms: {
607
- body: 'Thanks for joining Acme. Reply STOP to opt out.',
846
+ body: "Thanks for joining Acme. Reply STOP to opt out.",
608
847
  },
609
848
  email: {
610
- subject: 'You are subscribed',
611
- 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.",
612
851
  },
613
852
  },
614
853
  },
@@ -624,7 +863,7 @@ await itm.mutation({
624
863
  __args: {
625
864
  input: {
626
865
  verificationToken: createPartnerBrandSubscription.verificationToken!,
627
- code: '123456',
866
+ code: "123456",
628
867
  },
629
868
  },
630
869
  uid: true,
@@ -647,17 +886,17 @@ const { createPartnerBrandSubscription } = await itm.mutation({
647
886
  createPartnerBrandSubscription: {
648
887
  __args: {
649
888
  input: {
650
- phoneNumber: '+14155550111',
651
- fullName: 'Jane Doe',
652
- email: 'jane@example.com',
653
- listNames: ['VIP', 'Newsletter'],
889
+ phoneNumber: "+14155550111",
890
+ fullName: "Jane Doe",
891
+ email: "jane@example.com",
892
+ listNames: ["VIP", "Newsletter"],
654
893
  compliance: {
655
- pageUrl: 'https://example.com/signup',
656
- submissionMethod: 'partner_sdk',
894
+ pageUrl: "https://example.com/signup",
895
+ submissionMethod: "partner_sdk",
657
896
  },
658
897
  postSubscriptionMessage: {
659
898
  sms: {
660
- body: 'Welcome to Acme updates. Reply STOP to opt out.',
899
+ body: "Welcome to Acme updates. Reply STOP to opt out.",
661
900
  },
662
901
  },
663
902
  },
@@ -694,12 +933,12 @@ const { createPartnerBrandSubscription } = await itm.mutation({
694
933
  createPartnerBrandSubscription: {
695
934
  __args: {
696
935
  input: {
697
- phoneNumber: '+14155550112',
936
+ phoneNumber: "+14155550112",
698
937
  requireOtpVerification: true,
699
- fullName: 'Otp User',
938
+ fullName: "Otp User",
700
939
  compliance: {
701
- pageUrl: 'https://example.com/otp-signup',
702
- submissionMethod: 'partner_sdk',
940
+ pageUrl: "https://example.com/otp-signup",
941
+ submissionMethod: "partner_sdk",
703
942
  },
704
943
  },
705
944
  },
@@ -709,18 +948,17 @@ const { createPartnerBrandSubscription } = await itm.mutation({
709
948
  });
710
949
 
711
950
  if (!createPartnerBrandSubscription.verificationToken) {
712
- throw new Error('Expected OTP verification to be required');
951
+ throw new Error("Expected OTP verification to be required");
713
952
  }
714
953
 
715
954
  // Collect the SMS code from the user in your UI or API flow.
716
- const code = '123456';
955
+ const code = "123456";
717
956
 
718
957
  const { verifyPartnerBrandSubscription } = await itm.mutation({
719
958
  verifyPartnerBrandSubscription: {
720
959
  __args: {
721
960
  input: {
722
- verificationToken:
723
- createPartnerBrandSubscription.verificationToken,
961
+ verificationToken: createPartnerBrandSubscription.verificationToken,
724
962
  code,
725
963
  },
726
964
  },
@@ -746,17 +984,17 @@ This is the recommended mode when:
746
984
  This is the full two-step flow most integrators will want to implement from a server route or server action.
747
985
 
748
986
  ```typescript
749
- import { createITMPartnerClient } from '@itm-studio/partner-sdk';
987
+ import { createITMPartnerClient } from "@itm-studio/partner-sdk";
750
988
 
751
989
  function createRequestScopedITM(request: Request) {
752
990
  return createITMPartnerClient({
753
991
  token: process.env.ITM_PARTNER_TOKEN!,
754
992
  headers: () => ({
755
- ...(request.headers.get('x-forwarded-for')
756
- ? { '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")! }
757
995
  : {}),
758
- ...(request.headers.get('user-agent')
759
- ? { 'user-agent': request.headers.get('user-agent')! }
996
+ ...(request.headers.get("user-agent")
997
+ ? { "user-agent": request.headers.get("user-agent")! }
760
998
  : {}),
761
999
  }),
762
1000
  });
@@ -769,24 +1007,24 @@ export async function startOtpSubscription(request: Request) {
769
1007
  createPartnerBrandSubscription: {
770
1008
  __args: {
771
1009
  input: {
772
- phoneNumber: '+14155550112',
1010
+ phoneNumber: "+14155550112",
773
1011
  requireOtpVerification: true,
774
- fullName: 'Otp User',
775
- email: 'otp@example.com',
776
- listNames: ['Members', 'SMS'],
1012
+ fullName: "Otp User",
1013
+ email: "otp@example.com",
1014
+ listNames: ["Members", "SMS"],
777
1015
  compliance: {
778
- pageUrl: 'https://example.com/join',
779
- submissionMethod: 'partner_sdk',
1016
+ pageUrl: "https://example.com/join",
1017
+ submissionMethod: "partner_sdk",
780
1018
  submittedAt: new Date().toISOString(),
781
- timezone: 'America/New_York',
1019
+ timezone: "America/New_York",
782
1020
  },
783
1021
  postSubscriptionMessage: {
784
1022
  sms: {
785
- body: 'You are subscribed. Reply STOP to opt out.',
1023
+ body: "You are subscribed. Reply STOP to opt out.",
786
1024
  },
787
1025
  email: {
788
- subject: 'Welcome',
789
- body: 'Your subscription is confirmed.',
1026
+ subject: "Welcome",
1027
+ body: "Your subscription is confirmed.",
790
1028
  },
791
1029
  },
792
1030
  },
@@ -801,13 +1039,13 @@ export async function startOtpSubscription(request: Request) {
801
1039
 
802
1040
  if (!createPartnerBrandSubscription.verificationRequired) {
803
1041
  return {
804
- status: 'subscribed',
1042
+ status: "subscribed",
805
1043
  subscriptionUid: createPartnerBrandSubscription.subscription?.uid ?? null,
806
1044
  };
807
1045
  }
808
1046
 
809
1047
  return {
810
- status: 'otp_required',
1048
+ status: "otp_required",
811
1049
  verificationToken: createPartnerBrandSubscription.verificationToken,
812
1050
  };
813
1051
  }
@@ -815,7 +1053,7 @@ export async function startOtpSubscription(request: Request) {
815
1053
  export async function finishOtpSubscription(
816
1054
  request: Request,
817
1055
  verificationToken: string,
818
- code: string,
1056
+ code: string
819
1057
  ) {
820
1058
  const itm = createRequestScopedITM(request);
821
1059
 
@@ -856,7 +1094,7 @@ Get a presigned S3 URL to upload a file directly.
856
1094
  ```typescript
857
1095
  const { getPartnerUploadUrl } = await itm.mutation({
858
1096
  getPartnerUploadUrl: {
859
- __args: { filename: 'event-cover', fileExtension: 'jpg' },
1097
+ __args: { filename: "event-cover", fileExtension: "jpg" },
860
1098
  url: true,
861
1099
  filename: true,
862
1100
  },
@@ -864,9 +1102,9 @@ const { getPartnerUploadUrl } = await itm.mutation({
864
1102
 
865
1103
  // Upload the file to S3 using the presigned URL
866
1104
  await fetch(getPartnerUploadUrl.url, {
867
- method: 'PUT',
1105
+ method: "PUT",
868
1106
  body: fileBuffer, // Buffer, Blob, or ReadableStream
869
- headers: { 'Content-Type': 'image/jpeg' },
1107
+ headers: { "Content-Type": "image/jpeg" },
870
1108
  });
871
1109
  ```
872
1110
 
@@ -878,7 +1116,7 @@ After uploading, register the asset so it's linked to your brand.
878
1116
  const { createPartnerMediaAsset } = await itm.mutation({
879
1117
  createPartnerMediaAsset: {
880
1118
  __args: {
881
- mimetype: 'image/jpeg',
1119
+ mimetype: "image/jpeg",
882
1120
  filename: getPartnerUploadUrl.filename, // from Step 1
883
1121
  dimensions: { width: 1920, height: 1080 },
884
1122
  },
@@ -900,37 +1138,38 @@ const { createPartnerMoment } = await itm.mutation({
900
1138
  createPartnerMoment: {
901
1139
  __args: {
902
1140
  input: {
903
- name: 'Summer Rooftop Party',
904
- description: 'A rooftop party in NYC',
905
- blurb: 'Join us on the roof!',
906
- startDate: '2026-07-15T20:00:00.000Z',
907
- endDate: '2026-07-16T02:00:00.000Z',
908
- timezone: 'America/New_York',
909
- 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,
910
1149
  coverImageUid: createPartnerMediaAsset.uid, // from upload flow
911
1150
  venue: {
912
- name: 'Rooftop Bar',
913
- city: 'New York',
914
- country: 'US',
915
- address: '123 Main St',
1151
+ name: "Rooftop Bar",
1152
+ city: "New York",
1153
+ country: "US",
1154
+ address: "123 Main St",
916
1155
  },
917
- category: 'PARTY',
918
- subcategory: 'ROOFTOP_PARTY',
1156
+ category: "PARTY",
1157
+ subcategory: "ROOFTOP_PARTY",
919
1158
  ticketTiers: [
920
1159
  {
921
- name: 'General Admission',
922
- price: 2500, // $25.00 in cents
1160
+ name: "General Admission",
1161
+ price: 2500, // $25.00 in cents
923
1162
  supply: 200,
924
1163
  maxPerUser: 4,
925
- currencyCode: 'USD',
1164
+ currencyCode: "USD",
926
1165
  },
927
1166
  {
928
- name: 'VIP',
929
- price: 7500, // $75.00 in cents
1167
+ name: "VIP",
1168
+ price: 7500, // $75.00 in cents
930
1169
  supply: 50,
931
1170
  maxPerUser: 2,
932
- currencyCode: 'USD',
933
- description: 'Includes open bar',
1171
+ currencyCode: "USD",
1172
+ description: "Includes open bar",
934
1173
  },
935
1174
  ],
936
1175
  },
@@ -941,6 +1180,7 @@ const { createPartnerMoment } = await itm.mutation({
941
1180
  startDate: true,
942
1181
  endDate: true,
943
1182
  status: true,
1183
+ capacity: true,
944
1184
  coverImage: { url: true },
945
1185
  venue: { name: true, city: true },
946
1186
  ticketTiers: {
@@ -954,19 +1194,93 @@ const { createPartnerMoment } = await itm.mutation({
954
1194
  });
955
1195
  ```
956
1196
 
957
- **Required fields:** `name`, `description`, `blurb`, `startDate`, `endDate`, `timezone`, `type`
1197
+ **Required fields:** `name`, `description`, `startDate`, `endDate`, `timezone`, `type`
958
1198
 
959
1199
  **Moment types:**
1200
+
960
1201
  - `IRL` — in-person event (requires `venue`)
961
- - `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
+ ```
1242
+
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.
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.
962
1252
 
963
- **Ticket tier pricing:** `price` is in **cents** (e.g. `2500` = $25.00). Use `0` for free tiers.
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
+ ```
964
1278
 
965
1279
  ### Complete Upload + Create Moment Flow
966
1280
 
967
1281
  ```typescript
968
- import { createITMPartnerClient } from '@itm-studio/partner-sdk';
969
- import { readFile } from 'fs/promises';
1282
+ import { createITMPartnerClient } from "@itm-studio/partner-sdk";
1283
+ import { readFile } from "fs/promises";
970
1284
 
971
1285
  const itm = createITMPartnerClient({
972
1286
  token: process.env.ITM_PARTNER_TOKEN!,
@@ -975,25 +1289,25 @@ const itm = createITMPartnerClient({
975
1289
  // 1. Get presigned upload URL
976
1290
  const { getPartnerUploadUrl } = await itm.mutation({
977
1291
  getPartnerUploadUrl: {
978
- __args: { filename: 'event-cover', fileExtension: 'jpg' },
1292
+ __args: { filename: "event-cover", fileExtension: "jpg" },
979
1293
  url: true,
980
1294
  filename: true,
981
1295
  },
982
1296
  });
983
1297
 
984
1298
  // 2. Upload file to S3
985
- const file = await readFile('./event-cover.jpg');
1299
+ const file = await readFile("./event-cover.jpg");
986
1300
  await fetch(getPartnerUploadUrl.url, {
987
- method: 'PUT',
1301
+ method: "PUT",
988
1302
  body: file,
989
- headers: { 'Content-Type': 'image/jpeg' },
1303
+ headers: { "Content-Type": "image/jpeg" },
990
1304
  });
991
1305
 
992
1306
  // 3. Register the uploaded asset
993
1307
  const { createPartnerMediaAsset } = await itm.mutation({
994
1308
  createPartnerMediaAsset: {
995
1309
  __args: {
996
- mimetype: 'image/jpeg',
1310
+ mimetype: "image/jpeg",
997
1311
  filename: getPartnerUploadUrl.filename,
998
1312
  dimensions: { width: 1920, height: 1080 },
999
1313
  },
@@ -1006,26 +1320,26 @@ const { createPartnerMoment } = await itm.mutation({
1006
1320
  createPartnerMoment: {
1007
1321
  __args: {
1008
1322
  input: {
1009
- name: 'My Event',
1010
- description: 'An amazing event',
1011
- blurb: 'Come join us!',
1012
- startDate: '2026-07-15T20:00:00.000Z',
1013
- endDate: '2026-07-16T02:00:00.000Z',
1014
- timezone: 'America/New_York',
1015
- 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",
1016
1330
  coverImageUid: createPartnerMediaAsset.uid,
1017
1331
  venue: {
1018
- name: 'The Venue',
1019
- city: 'New York',
1020
- country: 'US',
1332
+ name: "The Venue",
1333
+ city: "New York",
1334
+ country: "US",
1021
1335
  },
1022
1336
  ticketTiers: [
1023
1337
  {
1024
- name: 'General Admission',
1338
+ name: "General Admission",
1025
1339
  price: 2500,
1026
1340
  supply: 100,
1027
1341
  maxPerUser: 4,
1028
- currencyCode: 'USD',
1342
+ currencyCode: "USD",
1029
1343
  },
1030
1344
  ],
1031
1345
  },
@@ -1037,7 +1351,9 @@ const { createPartnerMoment } = await itm.mutation({
1037
1351
  },
1038
1352
  });
1039
1353
 
1040
- console.log(`Created: ${createPartnerMoment.slug} (${createPartnerMoment.status})`);
1354
+ console.log(
1355
+ `Created: ${createPartnerMoment.slug} (${createPartnerMoment.status})`
1356
+ );
1041
1357
  ```
1042
1358
 
1043
1359
  ## Pagination
@@ -1050,7 +1366,7 @@ let cursor: string | null = null;
1050
1366
  do {
1051
1367
  const { getPartnerTicketsForMoment } = await itm.query({
1052
1368
  getPartnerTicketsForMoment: {
1053
- __args: { momentSlug: 'my-event', cursor, take: 50 },
1369
+ __args: { momentSlug: "my-event", cursor, take: 50 },
1054
1370
  tickets: { uid: true, user: { name: true } },
1055
1371
  hasNextPage: true,
1056
1372
  nextCursor: true,
@@ -1073,7 +1389,7 @@ For staging or local development:
1073
1389
  ```typescript
1074
1390
  const itm = createITMPartnerClient({
1075
1391
  token: process.env.ITM_PARTNER_TOKEN!,
1076
- baseUrl: 'https://staging-api.itm.studio/graphql',
1392
+ baseUrl: "https://staging-api.itm.studio/graphql",
1077
1393
  });
1078
1394
  ```
1079
1395
 
@@ -1133,12 +1449,12 @@ npm run schema:publish # Publish schema to registry
1133
1449
 
1134
1450
  ## CI/CD
1135
1451
 
1136
- | Job | Trigger | What it does |
1137
- |---|---|---|
1138
- | `build-and-test` | Every push & PR | Install, generate, lint, test, build |
1139
- | `schema-check` | PRs only | Validates schema changes won't break partners |
1140
- | `schema-publish` | Push to main | Publishes schema to Hive registry |
1141
- | `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 |
1142
1458
 
1143
1459
  ## Adding New Partner Endpoints
1144
1460